query parsing and diagnostics

This commit is contained in:
2026-05-14 02:20:17 +02:00
parent a152968466
commit e5197e15e2
13 changed files with 777 additions and 90 deletions

View File

@@ -13,6 +13,11 @@ The score below remains a product-depth estimate against mature CMIS products.
The selected OpenCMIS baseline is now stable preparation evidence for The selected OpenCMIS baseline is now stable preparation evidence for
repository/type and object/content services, not a full CMIS certification. repository/type and object/content services, not a full CMIS certification.
Read-side contract update: `KONT-WP-0016` adds a documented bounded query
subset, common CMIS `ORDER BY`, target/either relationship filters, enriched
relationship and ACL projections, and explicit `notSupported` diagnostics for
unsupported navigation selectors.
Status: baseline scorecard for the current Browser Binding subset. Status: baseline scorecard for the current Browser Binding subset.
## Purpose ## Purpose
@@ -72,9 +77,9 @@ CMIS interoperability importance rather than engine-internal importance.
| Metric | Score | | Metric | Score |
| --- | ---: | | --- | ---: |
| OpenCMIS selected-baseline infrastructure score | 99.1% | | OpenCMIS selected-baseline infrastructure score | 99.1% |
| Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 60% | | Weighted CMIS 1.1 depth vs Hyland Alfresco benchmark | 63% |
| Controlled-client Browser Binding usefulness | 82% | | Controlled-client Browser Binding usefulness | 84% |
| Broad commodity CMIS client compatibility | 55% | | Broad commodity CMIS client compatibility | 57% |
Interpretation: the OpenCMIS infrastructure score measures the selected Interpretation: the OpenCMIS infrastructure score measures the selected
`repository-type` and `object-content` harness baseline only. The current CMIS `repository-type` and `object-content` harness baseline only. The current CMIS
@@ -94,9 +99,9 @@ broad ECM/CMIS replacement surface.
| Object write service | 8 | 72% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set/append/delete, `bulkUpdateProperties`, and `createDocumentFromSource` exist.<br>- No broad filing mutation, raw physical delete, checkout/checkin, or policy/item creation.<br>- Delete remains intentionally governed, not raw repository removal. | | Object write service | 8 | 72% | Hyland Alfresco ACS | - `createDocument`, `createFolder`, scoped `moveObject`, folder rename, selected standard property updates, custom metadata updates, content stream set/append/delete, `bulkUpdateProperties`, and `createDocumentFromSource` exist.<br>- No broad filing mutation, raw physical delete, checkout/checkin, or policy/item creation.<br>- Delete remains intentionally governed, not raw repository removal. |
| Content stream read/write | 8 | 86% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, whole-object `appendContentStream`, no-content compatibility streams, content tombstones, partial body slicing, and offset-zero full-stream classification exist.<br>- Digest verification and governed access exist.<br>- Chunk-level blob composition remains a later optimization for very large append workloads. | | Content stream read/write | 8 | 86% | Hyland Alfresco ACS | - Byte streaming, explicit content headers, multipart Browser Binding create, deduplicating `setContentStream`, whole-object `appendContentStream`, no-content compatibility streams, content tombstones, partial body slicing, and offset-zero full-stream classification exist.<br>- Digest verification and governed access exist.<br>- Chunk-level blob composition remains a later optimization for very large append workloads. |
| 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. | | 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. | | Discovery/query | 8 | 42% | Hyland Alfresco ACS | - Bounded `SELECT *` document queries support equality, `LIKE`, `IN`, `AND`, paging, and common CMIS property ordering.<br>- Capability flags now advertise `capabilityOrderBy=common` rather than overclaiming custom ordering.<br>- Missing joins, full text, nested predicates, arbitrary projections, and broad CMIS SQL coverage. |
| 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. | | Relationships | 5 | 70% | Hyland Alfresco ACS | - Relationship object projection, source filters, target filters, either-direction filters, confidence, direction, provenance, and visibility gates exist.<br>- Protected relationship leakage is covered by profile gates.<br>- Missing relationship creation through CMIS and deeper relationship 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. | | ACL service | 6 | 48% | Hyland Alfresco ACS | - Discover-only ACL projection has stable principal ids, principal kinds, permission mapping, direct/inherited markers, and policy authority metadata.<br>- `applyACL` is blocked as not implemented.<br>- Missing propagation, ACL mutation, and repository-wide principal/group enumeration. |
| 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. | | 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 | 60% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.<br>- CMIS change-token conflicts are now enforced for property/content mutations.<br>- Missing richer change event typing and broader token semantics across optional services. | | Change log | 5 | 60% | Hyland Alfresco ACS | - Audit-backed object-id change entries and paging exist.<br>- CMIS change-token conflicts are now enforced for property/content mutations.<br>- Missing richer change event typing and broader token semantics across optional services. |
| Multi-filing and unfiling | 4 | 25% | Hyland Alfresco ACS | - Projection-only parent maps exist and are useful for navigation.<br>- Standard CMIS `capabilityMultifiling` is correctly false.<br>- No add/remove filing mutations or canonical folder membership model. | | 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. |
@@ -108,7 +113,7 @@ broad ECM/CMIS replacement surface.
| Web Services binding | 2 | 0% | Hyland Alfresco ACS | - No SOAP/WSDL stack.<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 | 86% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.<br>- Selected `repository-type` and `object-content` baselines complete with one local transport warning and no object/content warnings.<br>- Evidence still covers a selected baseline, not the full OpenCMIS TCK surface. | | External conformance evidence | 3 | 86% | OpenCMIS TCK against Alfresco-like server behavior | - OpenCMIS Browser Binding session creation succeeds against `compat-tck`.<br>- Selected `repository-type` and `object-content` baselines complete with one local transport warning and no object/content warnings.<br>- Evidence still covers a selected baseline, not the full OpenCMIS TCK surface. |
Weighted result from this table: **60%**. Weighted result from this table: **63%**.
## Most Important Gaps ## Most Important Gaps
@@ -126,9 +131,10 @@ Weighted result from this table: **60%**.
optional services are added. optional services are added.
3. **Query depth** 3. **Query depth**
- Add a real CMIS SQL subset parser instead of a two-query allowlist. - Expand only where it stays natural: additional indexed metadata fields,
- Support basic `WHERE`, equality predicates, paging, ordering where claimed, richer comparator support, and selected client-requested predicates.
and diagnostics for everything outside the subset. - Keep joins, full text, and arbitrary CMIS SQL unsupported unless a real
integration need appears.
4. **Navigation depth** 4. **Navigation depth**
- Decide whether `getDescendants` and `getFolderTree` are worth implementing - Decide whether `getDescendants` and `getFolderTree` are worth implementing

View File

@@ -50,15 +50,15 @@ Practical strategy:
| Object service write | Governed subset. | `createDocument`, custom metadata updates, `setContentStream`, and delete-request lifecycle transition are supported by authoring profiles. Unsupported standard property updates now fail with diagnostics. | Medium | | Object service write | Governed subset. | `createDocument`, custom metadata updates, `setContentStream`, and delete-request lifecycle transition are supported by authoring profiles. Unsupported standard property updates now fail with diagnostics. | Medium |
| Content streams | Implemented subset. | Descriptor and byte-stream routes exist; `setContentStream` and whole-object `appendContentStream` write through deduplicating blob storage, while `deleteContentStream` tombstones the CMIS projection. Chunk-level append composition remains deferred. | Low | | Content streams | Implemented subset. | Descriptor and byte-stream routes exist; `setContentStream` and whole-object `appendContentStream` write through deduplicating blob storage, while `deleteContentStream` tombstones the CMIS projection. Chunk-level append composition remains deferred. | Low |
| Versioning | Projection only. | Latest-version properties can be projected from engine versions, but CMIS checkout/PWC/all-versions services are not advertised. | Low if unsupported remains acceptable | | Versioning | Projection only. | Latest-version properties can be projected from engine versions, but CMIS checkout/PWC/all-versions services are not advertised. | Low if unsupported remains acceptable |
| Discovery/query | Implemented narrow subset. | `SELECT * FROM cmis:document` and `SELECT * FROM kontextual:document` are supported. Joins, order-by, full CMIS SQL predicates, and full-text are flagged unsupported. | Medium | | Discovery/query | Implemented bounded subset. | `SELECT *` document queries support equality, `LIKE`, `IN`, `AND`, paging, and common CMIS property ordering. Joins, full text, nested predicates, arbitrary projection lists, and custom-property ordering are flagged unsupported. | Medium |
| Relationships | Implemented subset. | Relationship object projections and source filters are covered and profile-gated. | Low | | Relationships | Implemented subset. | Relationship object projections, source filters, target filters, either-direction filters, provenance, confidence, and profile-gated visibility are covered. | Low |
| ACL service | Discover only. | ACL projection is supported; `applyACL` is not authorized even for authoring profiles and returns an unimplemented diagnostic. | Low | | ACL service | Discover only. | ACL projection is supported with stable principal/permission vocabulary, direct/inherited markers, and policy authority metadata; `applyACL` returns an unimplemented diagnostic. | Low |
| Policy service | Unsupported. | `applyPolicy`/`removePolicy` are explicitly unsupported; engine policy remains native, not CMIS policy objects. | Low | | Policy service | Unsupported. | `applyPolicy`/`removePolicy` are explicitly unsupported; engine policy remains native, not CMIS policy objects. | Low |
| Change log | Implemented subset. | Audit-backed object-id change entries and paging are supported; full property-level change details are not advertised. | Low | | Change log | Implemented subset. | Audit-backed object-id change entries and paging are supported; full property-level change details are not advertised. | Low |
| Multi-filing/unfiling | Projection only. | Multiple virtual parents are exposed as a Kontextual repository feature, while CMIS `capabilityMultifiling` and unfiling stay false. | Low | | Multi-filing/unfiling | Projection only. | Multiple virtual parents are exposed as a Kontextual repository feature, while CMIS `capabilityMultifiling` and unfiling stay false. | Low |
| Renditions | Unsupported. | Capability is `none`; derived representations are not exposed as CMIS rendition streams. | Low | | Renditions | Unsupported. | Capability is `none`; derived representations are not exposed as CMIS rendition streams. | Low |
| Retention and hold | Unsupported. | Not advertised; left as native governance metadata until a real integration requires CMIS legal-hold semantics. | Low | | Retention and hold | Unsupported. | Not advertised; left as native governance metadata until a real integration requires CMIS legal-hold semantics. | Low |
| Bulk update | Unsupported. | `bulkUpdateProperties` is explicitly unsupported. | Low | | Bulk update | Profile-scoped subset. | `bulkUpdateProperties` is available for the TCK compatibility profile through existing governed property updates and change-token handling; it remains narrow and is not enabled on normal authoring profiles. | Low |
| Browser JSON binding | FastAPI JSON service already exists. | Need CMIS Browser Binding routes, selectors/actions, multipart/content stream behavior. | High | | Browser JSON binding | FastAPI JSON service already exists. | Need CMIS Browser Binding routes, selectors/actions, multipart/content stream behavior. | High |
| AtomPub binding | No AtomPub/XML binding. | Need XML/Atom feed generation and protocol semantics. | Very High | | AtomPub binding | No AtomPub/XML binding. | Need XML/Atom feed generation and protocol semantics. | Very High |
| Web Services binding | No SOAP stack. | Need WSDL/SOAP implementation. | Very High | | Web Services binding | No SOAP stack. | Need WSDL/SOAP implementation. | Very High |
@@ -72,7 +72,8 @@ Maintain a constrained CMIS 1.1 Browser Binding profile:
projection. projection.
- Explicitly unsupported or read-only: AtomPub, Web Services, descendants/tree, - Explicitly unsupported or read-only: AtomPub, Web Services, descendants/tree,
full ACL mutation, retention/hold, mutating multifiling/unfiling, PWC/versioning full ACL mutation, retention/hold, mutating multifiling/unfiling, PWC/versioning
services, renditions, bulk updates, order-by, and full CMIS SQL joins. services, renditions, custom-property ordering, broad bulk-update exposure,
and full CMIS SQL joins.
Then expand by profile: Then expand by profile:

View File

@@ -0,0 +1,94 @@
# CMIS Read-Side Contract
Date: 2026-05-14
Workplan: `KONT-WP-0016`
Status: release-stable Browser Binding subset
## Boundary
The CMIS layer is a connector projection over native `kontextual-engine`
services. Native assets, classifications, metadata records, relationships,
audit events, policy decisions, and blob representations remain authoritative.
CMIS does not own a second object store, a separate ACL model, or an independent
filing graph.
## Release-Stable Capabilities
| Capability | Release posture | Contract |
| --- | --- | --- |
| Repository and type discovery | Supported | Browser Binding service document, repository info, base type definitions, and property definitions are stable. Optional unsupported features are declared in `unsupported_features`. |
| Navigation | Bounded supported subset | `children`, `parent`, `parents`, `object`, `properties`, `allowableActions`, `policies`, and `content` are supported through profile-gated folder/path projections. |
| Descendants and folder tree | Unsupported | `descendants` and `folderTree` return CMIS `notSupported`; repository flags remain false. This avoids creating a broad ECM tree contract before there is a native need. |
| Query | Bounded supported subset | `SELECT * FROM cmis:document` and `SELECT * FROM kontextual:document` support simple `WHERE` predicates joined by `AND`, deterministic paging, and common-property ordering. |
| Relationships | Supported read projection | Source, target, and either-direction filters map to the native relationship repository. Relationship objects expose source/target ids, predicate, confidence, direction, provenance, actor, and a stable relationship change token. |
| ACL discovery | Supported projection | `getACL` exposes direct actor permissions plus synthetic/public inheritance markers. The native policy gateway remains the authority; `applyACL` stays unsupported. |
| Change tokens | Supported for governed mutations | Asset `cmis:changeToken` is the current version id. Folder workspace tokens and relationship tokens are projection tokens. Stale asset mutation tokens map to CMIS `updateConflict`. |
## Query Subset
Accepted grammar:
```sql
SELECT * FROM cmis:document
SELECT * FROM kontextual:document
SELECT * FROM cmis:document WHERE <field> = '<value>' [AND ...]
SELECT * FROM cmis:document WHERE <text-field> LIKE '<pattern>' [AND ...]
SELECT * FROM cmis:document WHERE <multi-field> IN ('<value>', ...) [AND ...]
SELECT * FROM cmis:document ... ORDER BY <common-cmis-field> [ASC|DESC]
```
Filterable fields:
`cmis:objectId`, `cmis:name`, `cmis:objectTypeId`, `cmis:baseTypeId`,
`cmis:description`, `kontextual:assetId`, `kontextual:assetType`,
`kontextual:sensitivity`, `kontextual:lifecycle`, `kontextual:owner`,
`kontextual:topics`, and `kontextual:reviewState`.
Orderable fields:
`cmis:objectId`, `cmis:name`, `cmis:creationDate`, and
`cmis:lastModificationDate`.
Unsupported joins, `OR`, nested expressions, full text, arbitrary projection
lists, and custom-property ordering return CMIS `notSupported` diagnostics with
the supported grammar and field sets included.
## Compatibility Notes
- `capabilityQuery` remains `metadataonly`.
- `capabilityOrderBy` is now `common`, not `none`, because common CMIS property
ordering is implemented and covered by tests.
- `capabilityGetDescendants` and `capabilityGetFolderTree` remain false.
- Multifiling remains projection-only. Mutation semantics are deliberately out
of scope for this read-side contract.
- The ACL vocabulary is intentionally small: `cmis:read`, `cmis:write`, and
`cmis:delete`, with `direct`, `inherited`, `principal_kind`, and `source`
markers.
## Evidence
Focused tests run on 2026-05-14:
```text
python3 -m pytest \
tests/cmis/test_cmis_runtime_browser_binding.py \
tests/cmis/test_cmis_browser_binding_api.py \
tests/cmis/test_cmis_compliance_flags.py \
tests/cmis/test_cmis_contract_examples.py
Result: 21 passed, 16 skipped in 5.19s
```
Browser Binding API verification with optional service extras:
```text
.venv/bin/python -m pytest tests/cmis/test_cmis_browser_binding_api.py -q
Result: 16 passed in 39.09s
```
The default system-Python run skips Browser Binding API tests when FastAPI/HTTPX
are unavailable and skips capacity probes unless `KONTEXTUAL_RUN_CAPACITY=1` is
set. The capacity probe exercises 400 documents and 250 relationships over
query and target-filter paths, while relying on the shared performance history
monitor for drift tracking.

View File

@@ -29,11 +29,11 @@ Out of scope for `0.1.0`:
| Area | Gate | Current state | | Area | Gate | Current state |
| --- | --- | --- | | --- | --- | --- |
| CMIS read-side contract | Query, navigation, relationship, ACL, and change-token contracts are release-stable or explicitly waived. | `KONT-WP-0016` created as a pre-release dependency. | | CMIS read-side contract | Query, navigation, relationship, ACL, and change-token contracts are release-stable or explicitly waived. | `KONT-WP-0016` implemented locally; full release verification still required. |
| Tests | Full suite passes in the project venv. | `.venv/bin/python -m pytest -q` passed: `166 passed`, `14 skipped`; advisory performance drift warnings recorded. | | Tests | Full suite passes in the project venv. | `.venv/bin/python -m pytest -q` passed: `166 passed`, `15 skipped`; advisory performance drift warnings recorded. |
| CMIS evidence | OpenCMIS selected baseline completes with no unexpected findings. | `run-20260513T223537Z` completed; only local HTTP warning remains. | | CMIS evidence | OpenCMIS selected baseline completes with no unexpected findings. | `run-20260513T223537Z` completed; only local HTTP warning remains. |
| Transport | Released CMIS access points are served behind HTTPS. | Required deployment gate; local loopback warning is accepted only for harness runs. | | Transport | Released CMIS access points are served behind HTTPS. | Required deployment gate; local loopback warning is accepted only for harness runs. |
| Capability honesty | Scorecard, unsupported catalog, and examples match behavior. | Updated for `appendContentStream`; final doc review required. | | Capability honesty | Scorecard, unsupported catalog, and examples match behavior. | Updated for `appendContentStream` and WP-0016 read-side contract; final doc review required. |
| Packaging | Version, dependencies, optional extras, and install smoke are checked. | `pyproject.toml` is already `0.1.0`; build/install smoke still required. | | Packaging | Version, dependencies, optional extras, and install smoke are checked. | `pyproject.toml` is already `0.1.0`; build/install smoke still required. |
| Configuration | Storage backend, S3 settings, local blob path, and environment defaults are documented. | Existing docs cover backends; release runbook should point to them. | | Configuration | Storage backend, S3 settings, local blob path, and environment defaults are documented. | Existing docs cover backends; release runbook should point to them. |
| Data safety | Blob cleanup, backups, restore path, and migration posture are documented. | Cleanup exists; backup/restore release notes still needed. | | Data safety | Blob cleanup, backups, restore path, and migration posture are documented. | Cleanup exists; backup/restore release notes still needed. |
@@ -54,23 +54,21 @@ Out of scope for `0.1.0`:
## Release Procedure ## Release Procedure
1. Complete or explicitly waive `KONT-WP-0016`. 1. Run `.venv/bin/python -m pytest -q`.
2. Run `.venv/bin/python -m pytest -q`. 2. Run the OpenCMIS selected baseline through `guide-board` and persist the
3. Run the OpenCMIS selected baseline through `guide-board` and persist the
evidence document. evidence document.
4. Run State Hub consistency check and ensure workplans are registered. 3. Run State Hub consistency check and ensure workplans are registered.
5. Run packaging smoke: build wheel/sdist and import `kontextual_engine` from a 4. Run packaging smoke: build wheel/sdist and import `kontextual_engine` from a
clean install. clean install.
6. Review security/configuration: HTTPS termination, profile exposure, secrets, 5. Review security/configuration: HTTPS termination, profile exposure, secrets,
local/S3 blob backend settings, and dependency licenses. local/S3 blob backend settings, and dependency licenses.
7. Update `CHANGELOG.md` or release notes with capabilities, known limitations, 6. Update `CHANGELOG.md` or release notes with capabilities, known limitations,
and accepted warnings. and accepted warnings.
8. Tag the release only after the gates above are green or explicitly waived. 7. Tag the release only after the gates above are green or explicitly waived.
## Release Decision ## Release Decision
The current foundation is close to a controlled `0.1.0` preview, but CMIS The current foundation is close to a controlled `0.1.0` preview. With the
read-side contracts should be settled before release if external projects will `KONT-WP-0016` read-side contract settled locally, the remaining release work is
build on the engine. After `KONT-WP-0016`, the remaining release work is mainly mainly discipline around repeatable verification, packaging, deployment
discipline around repeatable verification, packaging, deployment posture, and posture, and clearly documented limits.
clearly documented limits.

View File

@@ -125,10 +125,10 @@
{ {
"id": "discovery-query", "id": "discovery-query",
"service": "discovery", "service": "discovery",
"examples": ["lexical-query", "metadata-filter", "relationship-scoped-query", "unsupported-join"], "examples": ["lexical-query", "metadata-filter", "relationship-scoped-query", "bounded-order-by", "unsupported-join"],
"supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"], "supported_profiles": ["readonly-browser", "governed-authoring", "admin-export", "compat-tck"],
"must_validate": ["query_capabilities", "paging", "unsupported_grammar_diagnostics"], "must_validate": ["query_capabilities", "paging", "bounded_order_by", "unsupported_grammar_diagnostics"],
"unsupported": ["full_cmis_sql_joins", "order_by"] "unsupported": ["full_cmis_sql_joins", "custom_order_by"]
}, },
{ {
"id": "relationships", "id": "relationships",
@@ -174,7 +174,7 @@
"private_working_copy": "capability_not_supported", "private_working_copy": "capability_not_supported",
"all_versions_search": "capability_not_supported", "all_versions_search": "capability_not_supported",
"full_cmis_sql_joins": "query_not_supported", "full_cmis_sql_joins": "query_not_supported",
"order_by": "query_not_supported", "custom_order_by": "custom_order_by_not_supported",
"apply_acl": "operation_not_implemented", "apply_acl": "operation_not_implemented",
"apply_policy": "capability_not_supported", "apply_policy": "capability_not_supported",
"remove_policy": "capability_not_supported", "remove_policy": "capability_not_supported",

View File

@@ -7,6 +7,7 @@ requests into service/runtime contracts and must not own domain behavior.
from __future__ import annotations from __future__ import annotations
import json import json
import re
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from datetime import datetime from datetime import datetime
from email import policy from email import policy
@@ -90,6 +91,43 @@ from kontextual_engine.services import (
API_VERSION = "v1" API_VERSION = "v1"
OPENAPI_VERSION = "1.0.0" OPENAPI_VERSION = "1.0.0"
CMIS_APPEND_MAX_COMPOSED_BYTES = 64 * 1024 * 1024 CMIS_APPEND_MAX_COMPOSED_BYTES = 64 * 1024 * 1024
CMIS_QUERY_SUPPORTED = [
"SELECT * FROM cmis:document",
"SELECT * FROM kontextual:document",
"SELECT * FROM cmis:document WHERE <filterable-field> = '<value>' [AND ...]",
"SELECT * FROM cmis:document WHERE <text-field> LIKE '<pattern>' [AND ...]",
"SELECT * FROM cmis:document WHERE <multi-field> IN ('<value>', ...) [AND ...]",
"SELECT * FROM cmis:document ... ORDER BY <orderable-field> [ASC|DESC]",
]
CMIS_QUERY_FILTERABLE_FIELDS = {
"cmis:objectId",
"cmis:name",
"cmis:objectTypeId",
"cmis:baseTypeId",
"cmis:description",
"kontextual:assetId",
"kontextual:assetType",
"kontextual:sensitivity",
"kontextual:lifecycle",
"kontextual:owner",
"kontextual:topics",
"kontextual:reviewState",
}
CMIS_QUERY_ORDERABLE_FIELDS = {
"cmis:objectId",
"cmis:name",
"cmis:creationDate",
"cmis:lastModificationDate",
}
CMIS_QUERY_LIKE_FIELDS = {
"cmis:name",
"cmis:description",
"kontextual:assetId",
"kontextual:assetType",
"kontextual:owner",
"kontextual:topics",
"kontextual:reviewState",
}
AGENT_OPERATION_CATALOG: tuple[dict[str, Any], ...] = ( AGENT_OPERATION_CATALOG: tuple[dict[str, Any], ...] = (
@@ -1593,19 +1631,13 @@ class ServiceRuntime:
decision = mapper.access_point.decide_action(CMISAction.QUERY, context) decision = mapper.access_point.decide_action(CMISAction.QUERY, context)
if not decision.allowed: if not decision.allowed:
raise _cmis_authorization_error(decision, "query") raise _cmis_authorization_error(decision, "query")
normalized = query.strip().lower() query_spec = _parse_cmis_query(query)
if normalized not in {"select * from cmis:document", "select * from kontextual:document"}:
raise ValidationError(
"Unsupported CMIS query subset",
details={
"query": query,
"supported": ["SELECT * FROM cmis:document", "SELECT * FROM kontextual:document"],
},
)
projections = self._cmis_document_projections(mapper, context) projections = self._cmis_document_projections(mapper, context)
projections = _apply_cmis_query_spec(projections, query_spec)
paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)] paged = projections[max(skip_count, 0) : max(skip_count, 0) + max(max_items, 0)]
return { return {
"query": query, "query": query,
"query_spec": query_spec,
"results": paged, "results": paged,
"num_items": len(paged), "num_items": len(paged),
"has_more_items": len(projections) > max(skip_count, 0) + len(paged), "has_more_items": len(projections) > max(skip_count, 0) + len(paged),
@@ -1618,19 +1650,55 @@ class ServiceRuntime:
context: OperationContext, context: OperationContext,
*, *,
object_id: str | None = None, object_id: str | None = None,
target_id: str | None = None,
relationship_direction: str | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
mapper = self._cmis_mapper(access_point_id) mapper = self._cmis_mapper(access_point_id)
decision = mapper.access_point.decide_action(CMISAction.GET_RELATIONSHIPS, context) decision = mapper.access_point.decide_action(CMISAction.GET_RELATIONSHIPS, context)
if not decision.allowed: if not decision.allowed:
raise _cmis_authorization_error(decision, "getRelationships") raise _cmis_authorization_error(decision, "getRelationships")
source_id = _cmis_asset_id(object_id) if object_id else None direction = (relationship_direction or "source").strip().lower()
if direction not in {"source", "target", "either"}:
raise ValidationError(
"Unsupported CMIS relationship direction",
details={
"code": "cmis.relationship_direction_unsupported",
"operation": "getObjectRelationships",
"direction": relationship_direction,
"supported": ["source", "target", "either"],
},
)
source_filter = _cmis_asset_id(object_id) if object_id and direction == "source" else None
target_filter = _cmis_asset_id(object_id) if object_id and direction == "target" else None
if target_id:
target_filter = _cmis_asset_id(target_id)
relationships = self.repository.list_relationships(source_id=source_filter, target_id=target_filter)
if object_id and direction == "either":
asset_id = _cmis_asset_id(object_id)
relationships = [
relationship
for relationship in relationships
if relationship.source_id == asset_id
or (
relationship.target_kind == RelationshipTargetKind.ASSET
and relationship.target_id == asset_id
)
]
projections = [ projections = [
projection.to_dict() projection.to_dict()
for relationship in self.repository.list_relationships(source_id=source_id) for relationship in relationships
if self._cmis_relationship_visible(mapper, relationship, context) if self._cmis_relationship_visible(mapper, relationship, context)
if (projection := mapper.map_relationship(relationship, context)) if (projection := mapper.map_relationship(relationship, context))
] ]
return {"items": projections, "count": len(projections)} return {
"items": projections,
"count": len(projections),
"filters": {
"object_id": object_id,
"target_id": target_id,
"relationship_direction": direction,
},
}
def cmis_change_log( def cmis_change_log(
self, self,
@@ -3629,26 +3697,38 @@ def create_app(runtime: ServiceRuntime | None = None):
) )
def unsupported_browser_selector(selector: str | None) -> dict[str, Any]: def unsupported_browser_selector(selector: str | None) -> dict[str, Any]:
unsupported_details: dict[str, Any] = {
"cmisselector": selector,
"supported": [
"repositoryInfo",
"typeChildren",
"typeDescendants",
"typeDefinition",
"query",
"object",
"children",
"parent",
"parents",
"properties",
"allowableActions",
"policies",
"content",
],
}
if selector in {"descendants", "folderTree"}:
unsupported_details.update(
{
"code": "cmis.not_supported",
"cmis_exception": "notSupported",
"unsupported_feature": "get_descendants"
if selector == "descendants"
else "get_folder_tree",
"release_contract": "Navigation tree selectors remain unsupported for the first release.",
}
)
raise ValidationError( raise ValidationError(
"Unsupported CMIS Browser Binding selector", "Unsupported CMIS Browser Binding selector",
details={ details=unsupported_details,
"cmisselector": selector,
"supported": [
"repositoryInfo",
"typeChildren",
"typeDescendants",
"typeDefinition",
"query",
"object",
"children",
"parent",
"parents",
"properties",
"allowableActions",
"policies",
"content",
],
},
) )
async def browser_action_payload(request: Request) -> dict[str, Any]: async def browser_action_payload(request: Request) -> dict[str, Any]:
@@ -4230,9 +4310,18 @@ def create_app(runtime: ServiceRuntime | None = None):
def cmis_relationships( def cmis_relationships(
access_point_id: str, access_point_id: str,
object_id: str | None = Query(None), object_id: str | None = Query(None),
target_id: str | None = Query(None, alias="targetId"),
relationship_direction: str | None = Query(None, alias="relationshipDirection"),
context: OperationContext = Depends(context_from_headers), context: OperationContext = Depends(context_from_headers),
) -> dict[str, Any]: ) -> dict[str, Any]:
return response(runtime.cmis_relationships, access_point_id, context, object_id=object_id) return response(
runtime.cmis_relationships,
access_point_id,
context,
object_id=object_id,
target_id=target_id,
relationship_direction=relationship_direction,
)
@app.get("/cmis/{access_point_id}/browser/changes", tags=["cmis"]) @app.get("/cmis/{access_point_id}/browser/changes", tags=["cmis"])
def cmis_changes( def cmis_changes(
@@ -4702,6 +4791,212 @@ def _normalize_cmis_path(path: str) -> str:
return "/" + "/".join(parts) return "/" + "/".join(parts)
_CMIS_QUERY_RE = re.compile(
r"^\s*SELECT\s+(?P<select>\*|[A-Za-z0-9_:\s,]+)\s+FROM\s+(?P<from>[A-Za-z0-9_:]+)"
r"(?:\s+WHERE\s+(?P<where>.*?))?"
r"(?:\s+ORDER\s+BY\s+(?P<order>[A-Za-z0-9_:]+)(?:\s+(?P<direction>ASC|DESC))?)?\s*$",
re.IGNORECASE,
)
_CMIS_QUERY_CONDITION_RE = re.compile(
r"^(?P<field>[A-Za-z0-9_:]+)\s*(?P<op>=|LIKE)\s*(?P<value>'.*?'|\".*?\"|[^\s]+)\s*$",
re.IGNORECASE,
)
_CMIS_QUERY_IN_RE = re.compile(
r"^(?P<field>[A-Za-z0-9_:]+)\s+IN\s*\((?P<values>.*)\)\s*$",
re.IGNORECASE,
)
def _parse_cmis_query(query: str) -> dict[str, Any]:
match = _CMIS_QUERY_RE.match(query)
if not match:
raise _unsupported_cmis_query(query, "Only a bounded SELECT/FROM/WHERE/ORDER BY subset is supported.")
selected = " ".join(match.group("select").split())
if selected != "*":
raise _unsupported_cmis_query(query, "Only SELECT * is supported in the release-stable subset.")
type_id = match.group("from")
if type_id not in {CMISBaseType.DOCUMENT.value, "kontextual:document"}:
raise _unsupported_cmis_query(query, "Only cmis:document and kontextual:document are queryable.")
conditions = _parse_cmis_query_conditions(query, match.group("where"))
order_by = match.group("order")
direction = (match.group("direction") or "ASC").upper()
if order_by and order_by not in CMIS_QUERY_ORDERABLE_FIELDS:
raise _unsupported_cmis_query(
query,
"ORDER BY is supported only for common CMIS document fields.",
field=order_by,
)
return {"type_id": type_id, "conditions": conditions, "order_by": order_by, "direction": direction}
def _parse_cmis_query_conditions(query: str, where_clause: str | None) -> list[dict[str, Any]]:
if not where_clause:
return []
conditions: list[dict[str, Any]] = []
for raw_condition in re.split(r"\s+AND\s+", where_clause, flags=re.IGNORECASE):
condition = raw_condition.strip()
if not condition:
raise _unsupported_cmis_query(query, "Empty WHERE predicates are not supported.")
in_match = _CMIS_QUERY_IN_RE.match(condition)
if in_match:
field = in_match.group("field")
_validate_cmis_query_field(query, field)
conditions.append(
{
"field": field,
"operator": "IN",
"values": _cmis_query_literal_list(query, in_match.group("values")),
}
)
continue
if re.search(r"\bOR\b|\(|\)", condition, re.IGNORECASE):
raise _unsupported_cmis_query(
query,
"Only AND-combined simple predicates are supported.",
predicate=condition,
)
match = _CMIS_QUERY_CONDITION_RE.match(condition)
if not match:
raise _unsupported_cmis_query(query, "Unsupported CMIS query predicate.", predicate=condition)
field = match.group("field")
operator = match.group("op").upper()
_validate_cmis_query_field(query, field)
if operator == "LIKE" and field not in CMIS_QUERY_LIKE_FIELDS:
raise _unsupported_cmis_query(
query,
"LIKE is supported only for text-like CMIS release fields.",
field=field,
)
conditions.append(
{
"field": field,
"operator": operator,
"value": _cmis_query_unquote(match.group("value")),
}
)
return conditions
def _validate_cmis_query_field(query: str, field: str) -> None:
if field not in CMIS_QUERY_FILTERABLE_FIELDS:
raise _unsupported_cmis_query(
query,
"WHERE predicates are supported only for release-stable filterable fields.",
field=field,
)
def _apply_cmis_query_spec(projections: list[dict[str, Any]], query_spec: dict[str, Any]) -> list[dict[str, Any]]:
filtered = [
projection
for projection in projections
if all(_cmis_query_condition_matches(projection, condition) for condition in query_spec["conditions"])
]
order_by = query_spec.get("order_by")
if not order_by:
return filtered
populated = [projection for projection in filtered if _cmis_query_values(projection, order_by)]
empty = [projection for projection in filtered if not _cmis_query_values(projection, order_by)]
return sorted(
populated,
key=lambda projection: _cmis_query_sort_key(projection, order_by),
reverse=query_spec.get("direction") == "DESC",
) + empty
def _cmis_query_condition_matches(projection: dict[str, Any], condition: dict[str, Any]) -> bool:
values = [str(value) for value in _cmis_query_values(projection, condition["field"])]
if not values:
return False
if condition["operator"] == "IN":
expected = {str(value) for value in condition["values"]}
return any(value in expected for value in values)
expected_value = str(condition["value"])
if condition["operator"] == "LIKE":
return any(_cmis_query_like_supported(value, expected_value) for value in values)
return any(value == expected_value for value in values)
def _cmis_query_values(projection: dict[str, Any], field: str) -> list[Any]:
if field == "cmis:objectId":
value = projection.get("object_id")
elif field == "cmis:name":
value = projection.get("name")
elif field == "cmis:baseTypeId":
value = projection.get("base_type_id")
elif field == "cmis:objectTypeId":
value = projection.get("type_id")
else:
value = dict(projection.get("properties", {})).get(field)
if value is None:
return []
if isinstance(value, (list, tuple, set)):
return [item for item in value if item is not None]
return [value]
def _cmis_query_sort_key(projection: dict[str, Any], field: str) -> tuple[str, str]:
values = _cmis_query_values(projection, field)
if not values:
return ("", "")
value = values[0]
return (type(value).__name__, str(value).lower())
def _cmis_query_like_supported(value: str, pattern: str) -> bool:
expression = "".join(".*" if char == "%" else "." if char == "_" else re.escape(char) for char in pattern)
return re.match(f"^{expression}$", value, flags=re.IGNORECASE) is not None
def _cmis_query_literal_list(query: str, value: str) -> list[str]:
values: list[str] = []
current: list[str] = []
quote: str | None = None
for char in value:
if char in {"'", '"'}:
if quote == char:
quote = None
elif quote is None:
quote = char
current.append(char)
elif char == "," and quote is None:
literal = "".join(current).strip()
if literal:
values.append(_cmis_query_unquote(literal))
current = []
else:
current.append(char)
if quote is not None:
raise _unsupported_cmis_query(query, "Unclosed quoted literal in IN predicate.")
literal = "".join(current).strip()
if literal:
values.append(_cmis_query_unquote(literal))
return values
def _cmis_query_unquote(value: str) -> str:
stripped = value.strip()
if len(stripped) >= 2 and stripped[0] == stripped[-1] and stripped[0] in {"'", '"'}:
return stripped[1:-1]
return stripped
def _unsupported_cmis_query(query: str, reason: str, **details: Any) -> ValidationError:
return ValidationError(
"Unsupported CMIS query subset",
details={
"code": "cmis.not_supported",
"cmis_exception": "notSupported",
"query": query,
"reason": reason,
"supported": CMIS_QUERY_SUPPORTED,
"filterable_fields": sorted(CMIS_QUERY_FILTERABLE_FIELDS),
"orderable_fields": sorted(CMIS_QUERY_ORDERABLE_FIELDS),
**details,
},
)
def _cmis_media_type(value: Any) -> str: def _cmis_media_type(value: Any) -> str:
media_type = str(value or "application/octet-stream").split(";", 1)[0].strip() media_type = str(value or "application/octet-stream").split(";", 1)[0].strip()
return media_type or "application/octet-stream" return media_type or "application/octet-stream"

View File

@@ -16,7 +16,7 @@ from .metadata import LifecycleState, Sensitivity
from .policy import PolicyDecision from .policy import PolicyDecision
from .provenance import AssetVersion from .provenance import AssetVersion
from .relationships import CoreRelationship, RelationshipTargetKind from .relationships import CoreRelationship, RelationshipTargetKind
from .primitives import compact_dict from .primitives import compact_dict, stable_json_dumps
class CMISBinding(str, Enum): class CMISBinding(str, Enum):
@@ -190,7 +190,11 @@ UNSUPPORTED_FEATURES: dict[str, dict[str, Any]] = {
"reason": "query_not_supported", "reason": "query_not_supported",
"standard_flag": "capability_join", "standard_flag": "capability_join",
}, },
"order_by": {"status": "unsupported", "reason": "query_not_supported", "standard_flag": "capability_order_by"}, "custom_order_by": {
"status": "unsupported",
"reason": "custom_order_by_not_supported",
"standard_flag": "capability_order_by",
},
"apply_acl": {"status": "unsupported", "reason": "operation_not_implemented", "standard_flag": "capability_acl"}, "apply_acl": {"status": "unsupported", "reason": "operation_not_implemented", "standard_flag": "capability_acl"},
"apply_policy": {"status": "unsupported", "reason": "capability_not_supported"}, "apply_policy": {"status": "unsupported", "reason": "capability_not_supported"},
"remove_policy": {"status": "unsupported", "reason": "capability_not_supported"}, "remove_policy": {"status": "unsupported", "reason": "capability_not_supported"},
@@ -636,7 +640,7 @@ class CMISDomainMapper:
"capability_renditions": "none", "capability_renditions": "none",
"capability_get_descendants": False, "capability_get_descendants": False,
"capability_get_folder_tree": False, "capability_get_folder_tree": False,
"capability_order_by": "none", "capability_order_by": "common",
"capability_multifiling": False, "capability_multifiling": False,
"capability_unfiling": False, "capability_unfiling": False,
"capability_version_specific_filing": False, "capability_version_specific_filing": False,
@@ -747,11 +751,19 @@ class CMISDomainMapper:
"cmis:name": relationship.predicate, "cmis:name": relationship.predicate,
"cmis:baseTypeId": CMISBaseType.RELATIONSHIP.value, "cmis:baseTypeId": CMISBaseType.RELATIONSHIP.value,
"cmis:objectTypeId": "kontextual:relationship", "cmis:objectTypeId": "kontextual:relationship",
"cmis:changeToken": f"relationship:{relationship.relationship_id}:{relationship.created_at}",
"cmis:sourceId": source_id, "cmis:sourceId": source_id,
"cmis:targetId": target_id, "cmis:targetId": target_id,
"kontextual:relationshipId": relationship.relationship_id,
"kontextual:predicate": relationship.predicate, "kontextual:predicate": relationship.predicate,
"kontextual:confidence": relationship.confidence, "kontextual:confidence": relationship.confidence,
"kontextual:targetKind": relationship.target_kind.value, "kontextual:targetKind": relationship.target_kind.value,
"kontextual:direction": relationship.direction,
"kontextual:validFrom": relationship.valid_from,
"kontextual:validTo": relationship.valid_to,
"kontextual:actorId": relationship.actor_id,
"kontextual:createdAt": relationship.created_at,
"kontextual:provenance": stable_json_dumps(relationship.provenance),
}, },
allowable_actions=(CMISAction.GET_OBJECT, CMISAction.GET_RELATIONSHIPS), allowable_actions=(CMISAction.GET_OBJECT, CMISAction.GET_RELATIONSHIPS),
) )
@@ -766,16 +778,22 @@ class CMISDomainMapper:
entries = [ entries = [
{ {
"principal_id": context.actor.id, "principal_id": context.actor.id,
"principal_kind": context.actor.actor_type.value,
"permissions": permissions, "permissions": permissions,
"direct": True, "direct": True,
"inherited": False,
"source": "request-actor-profile",
} }
] ]
if asset.classification.sensitivity == Sensitivity.PUBLIC: if asset.classification.sensitivity == Sensitivity.PUBLIC:
entries.append( entries.append(
{ {
"principal_id": "anyone", "principal_id": "anyone",
"principal_kind": "well_known",
"permissions": ["cmis:read"], "permissions": ["cmis:read"],
"direct": False, "direct": False,
"inherited": True,
"source": "public-sensitivity",
} }
) )
return { return {
@@ -783,6 +801,13 @@ class CMISDomainMapper:
"is_exact": True, "is_exact": True,
"aces": entries, "aces": entries,
"derived_from": "kontextual-profile-policy", "derived_from": "kontextual-profile-policy",
"visibility_reason": visibility.reason,
"permission_mapping": {
"cmis:read": "asset visible through profile policy",
"cmis:write": "profile allows governed mutations",
"cmis:delete": "profile allows governed delete requests",
},
"policy_authority": "kontextual-policy-gateway",
"profile": self.access_point.profile.name, "profile": self.access_point.profile.name,
} }
@@ -1500,8 +1525,8 @@ def _browser_type_display_name(type_definition: dict[str, Any]) -> str:
def _browser_standard_property_definitions(base_id: str) -> dict[str, dict[str, Any]]: def _browser_standard_property_definitions(base_id: str) -> dict[str, dict[str, Any]]:
common = { common = {
"cmis:objectId": _browser_propdef("id", required=False, updatability="readonly"), "cmis:objectId": _browser_propdef("id", required=False, updatability="readonly", orderable=True),
"cmis:name": _browser_propdef("string", required=True, updatability="readwrite"), "cmis:name": _browser_propdef("string", required=True, updatability="readwrite", orderable=True),
"cmis:baseTypeId": _browser_propdef("id", required=False, updatability="readonly"), "cmis:baseTypeId": _browser_propdef("id", required=False, updatability="readonly"),
"cmis:objectTypeId": _browser_propdef("id", required=True, updatability="oncreate"), "cmis:objectTypeId": _browser_propdef("id", required=True, updatability="oncreate"),
"cmis:createdBy": _browser_propdef( "cmis:createdBy": _browser_propdef(
@@ -1713,14 +1738,34 @@ def _type_definition(
def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any]]: def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any]]:
definitions = { definitions = {
"cmis:objectId": {"property_type": "id", "cardinality": "single", "required": True}, "cmis:objectId": {
"cmis:name": {"property_type": "string", "cardinality": "single", "required": True}, "property_type": "id",
"cardinality": "single",
"required": True,
"orderable": True,
},
"cmis:name": {
"property_type": "string",
"cardinality": "single",
"required": True,
"orderable": True,
},
"cmis:baseTypeId": {"property_type": "id", "cardinality": "single", "required": True}, "cmis:baseTypeId": {"property_type": "id", "cardinality": "single", "required": True},
"cmis:objectTypeId": {"property_type": "id", "cardinality": "single", "required": True}, "cmis:objectTypeId": {"property_type": "id", "cardinality": "single", "required": True},
"cmis:createdBy": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:createdBy": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:lastModifiedBy": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:lastModifiedBy": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:creationDate": {"property_type": "datetime", "cardinality": "single", "required": False}, "cmis:creationDate": {
"cmis:lastModificationDate": {"property_type": "datetime", "cardinality": "single", "required": False}, "property_type": "datetime",
"cardinality": "single",
"required": False,
"orderable": True,
},
"cmis:lastModificationDate": {
"property_type": "datetime",
"cardinality": "single",
"required": False,
"orderable": True,
},
"cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:changeToken": {"property_type": "string", "cardinality": "single", "required": False},
"cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False}, "cmis:secondaryObjectTypeIds": {"property_type": "id", "cardinality": "multi", "required": False},
"cmis:description": {"property_type": "string", "cardinality": "single", "required": False}, "cmis:description": {"property_type": "string", "cardinality": "single", "required": False},
@@ -1858,6 +1903,56 @@ def _property_definitions(base_type_id: CMISBaseType) -> dict[str, dict[str, Any
if base_type_id == CMISBaseType.RELATIONSHIP: if base_type_id == CMISBaseType.RELATIONSHIP:
definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True} definitions["cmis:sourceId"] = {"property_type": "id", "cardinality": "single", "required": True}
definitions["cmis:targetId"] = {"property_type": "id", "cardinality": "single", "required": True} definitions["cmis:targetId"] = {"property_type": "id", "cardinality": "single", "required": True}
definitions["kontextual:relationshipId"] = {
"property_type": "id",
"cardinality": "single",
"required": False,
}
definitions["kontextual:predicate"] = {
"property_type": "string",
"cardinality": "single",
"required": False,
}
definitions["kontextual:confidence"] = {
"property_type": "decimal",
"cardinality": "single",
"required": False,
}
definitions["kontextual:targetKind"] = {
"property_type": "string",
"cardinality": "single",
"required": False,
}
definitions["kontextual:direction"] = {
"property_type": "string",
"cardinality": "single",
"required": False,
}
definitions["kontextual:validFrom"] = {
"property_type": "datetime",
"cardinality": "single",
"required": False,
}
definitions["kontextual:validTo"] = {
"property_type": "datetime",
"cardinality": "single",
"required": False,
}
definitions["kontextual:actorId"] = {
"property_type": "string",
"cardinality": "single",
"required": False,
}
definitions["kontextual:createdAt"] = {
"property_type": "datetime",
"cardinality": "single",
"required": False,
}
definitions["kontextual:provenance"] = {
"property_type": "string",
"cardinality": "single",
"required": False,
}
return definitions return definitions

View File

@@ -38,7 +38,7 @@
"capability_group": "discovery-query", "capability_group": "discovery-query",
"tck_groups": ["QueryTestGroup"], "tck_groups": ["QueryTestGroup"],
"expected": "partial-pass", "expected": "partial-pass",
"known_gaps": ["full_cmis_sql_joins", "order_by"] "known_gaps": ["full_cmis_sql_joins", "custom_order_by"]
}, },
{ {
"capability_group": "relationships", "capability_group": "relationships",

View File

@@ -129,6 +129,7 @@ def test_cmis_repository_info_and_type_definitions(cmis_client) -> None:
assert repository["repositoryUrl"].endswith("/cmis/readonly-browser/browser") assert repository["repositoryUrl"].endswith("/cmis/readonly-browser/browser")
assert repository["rootFolderUrl"].endswith("/cmis/readonly-browser/browser/root") assert repository["rootFolderUrl"].endswith("/cmis/readonly-browser/browser/root")
assert repository["capabilities"]["capabilityQuery"] == "metadataonly" assert repository["capabilities"]["capabilityQuery"] == "metadataonly"
assert repository["capabilities"]["capabilityOrderBy"] == "common"
assert repository["capabilities"]["capabilityGetDescendants"] is False assert repository["capabilities"]["capabilityGetDescendants"] is False
assert browser_types["types"][0]["id"] == "cmis:document" assert browser_types["types"][0]["id"] == "cmis:document"
assert "propertyDefinitions" not in browser_types["types"][0] assert "propertyDefinitions" not in browser_types["types"][0]
@@ -166,10 +167,18 @@ def test_cmis_readonly_children_object_content_query_relationships_and_changes(c
"/cmis/readonly-browser/browser/query", "/cmis/readonly-browser/browser/query",
params={"q": "SELECT * FROM cmis:document"}, params={"q": "SELECT * FROM cmis:document"},
).json() ).json()
filtered_query = cmis_client.get(
"/cmis/readonly-browser/browser/query",
params={"q": "SELECT * FROM cmis:document WHERE kontextual:topics IN ('cmis') ORDER BY cmis:name ASC"},
).json()
relationships = cmis_client.get( relationships = cmis_client.get(
"/cmis/readonly-browser/browser/relationships", "/cmis/readonly-browser/browser/relationships",
params={"object_id": "cmis:asset:asset-source"}, params={"object_id": "cmis:asset:asset-source"},
).json() ).json()
target_relationships = cmis_client.get(
"/cmis/readonly-browser/browser/relationships",
params={"object_id": "cmis:asset:asset-public", "relationshipDirection": "target"},
).json()
changes = cmis_client.get("/cmis/readonly-browser/browser/changes").json() changes = cmis_client.get("/cmis/readonly-browser/browser/changes").json()
root_ids = {item["object_id"] for item in root_children["objects"]} root_ids = {item["object_id"] for item in root_children["objects"]}
@@ -182,8 +191,11 @@ def test_cmis_readonly_children_object_content_query_relationships_and_changes(c
assert "get_content_stream" in object_response["allowable_actions"] assert "get_content_stream" in object_response["allowable_actions"]
assert content["mime_type"] == "text/markdown" assert content["mime_type"] == "text/markdown"
assert query["total_num_items"] == children["total_num_items"] assert query["total_num_items"] == children["total_num_items"]
assert [item["object_id"] for item in filtered_query["results"]] == ["cmis:asset:asset-source"]
assert relationships["count"] == 1 assert relationships["count"] == 1
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-public" assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-public"
assert relationships["items"][0]["properties"]["kontextual:relationshipId"]
assert target_relationships["count"] == 1
assert changes["total_num_items"] >= 3 assert changes["total_num_items"] >= 3
@@ -213,13 +225,24 @@ def test_cmis_query_reports_unsupported_subset_diagnostics(cmis_client) -> None:
params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"}, params={"q": "SELECT * FROM cmis:document JOIN cmis:relationship"},
) )
assert response.status_code == 400 assert response.status_code == 405
assert response.json()["exception"] == "invalidArgument" assert response.json()["exception"] == "notSupported"
assert response.json()["details"]["supported"] == [ assert "SELECT * FROM cmis:document" in response.json()["details"]["supported"]
"SELECT * FROM cmis:document", assert response.json()["details"]["orderable_fields"] == [
"SELECT * FROM kontextual:document", "cmis:creationDate",
"cmis:lastModificationDate",
"cmis:name",
"cmis:objectId",
] ]
descendants = cmis_client.get(
"/cmis/readonly-browser/browser/root",
params={"cmisselector": "descendants"},
)
assert descendants.status_code == 405
assert descendants.json()["exception"] == "notSupported"
assert descendants.json()["details"]["unsupported_feature"] == "get_descendants"
def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) -> None: def test_cmis_governed_authoring_routes_allow_selected_mutations(cmis_client) -> None:
created = cmis_client.post( created = cmis_client.post(

View File

@@ -65,7 +65,7 @@ def test_repository_info_uses_complete_conservative_cmis_11_capability_flags() -
assert capabilities["capability_renditions"] == "none" assert capabilities["capability_renditions"] == "none"
assert capabilities["capability_get_descendants"] is False assert capabilities["capability_get_descendants"] is False
assert capabilities["capability_get_folder_tree"] is False assert capabilities["capability_get_folder_tree"] is False
assert capabilities["capability_order_by"] == "none" assert capabilities["capability_order_by"] == "common"
assert capabilities["capability_multifiling"] is False assert capabilities["capability_multifiling"] is False
assert capabilities["capability_unfiling"] is False assert capabilities["capability_unfiling"] is False
assert capabilities["capability_version_specific_filing"] is False assert capabilities["capability_version_specific_filing"] is False

View File

@@ -0,0 +1,72 @@
from __future__ import annotations
import os
from time import perf_counter
import pytest
from kontextual_engine import Classification, ServiceRuntime, Sensitivity
from kontextual_engine.adapters.memory import InMemoryAssetRegistryRepository
pytestmark = [pytest.mark.cmis, pytest.mark.capacity]
@pytest.mark.skipif(
os.getenv("KONTEXTUAL_RUN_CAPACITY") != "1",
reason="Set KONTEXTUAL_RUN_CAPACITY=1 to run CMIS read-side capacity probes.",
)
def test_cmis_read_side_query_and_relationship_capacity_probe() -> None:
runtime = ServiceRuntime(repository=InMemoryAssetRegistryRepository())
context = runtime.operation_context(actor_id="cmis-capacity", correlation_id="corr-cmis-capacity")
asset_count = 400
relationship_count = 250
for index in range(asset_count):
runtime.asset_service().create_asset(
f"Capacity Asset {index:04d}",
Classification(
asset_type="document",
sensitivity=Sensitivity.PUBLIC if index % 5 == 0 else Sensitivity.INTERNAL,
owner=f"capacity-owner-{index % 7}",
topics=("capacity", f"group-{index % 11}"),
),
context,
asset_id=f"asset-capacity-{index:04d}",
)
for index in range(relationship_count):
runtime.create_relationship(
{
"source_asset_id": f"asset-capacity-{index:04d}",
"target_id": f"asset-capacity-{asset_count - 1:04d}",
"predicate": "capacity-related-to",
"target_kind": "asset",
"confidence": 0.9,
},
context,
)
query_started = perf_counter()
query = runtime.cmis_query(
"readonly-browser",
"SELECT * FROM cmis:document WHERE kontextual:topics IN ('capacity') ORDER BY cmis:name DESC",
context,
max_items=25,
)
query_seconds = perf_counter() - query_started
relationships_started = perf_counter()
relationships = runtime.cmis_relationships(
"readonly-browser",
context,
object_id=f"cmis:asset:asset-capacity-{asset_count - 1:04d}",
relationship_direction="target",
)
relationships_seconds = perf_counter() - relationships_started
assert query["total_num_items"] == asset_count
assert query["num_items"] == 25
assert relationships["count"] == relationship_count
assert query_seconds < 5.0
assert relationships_seconds < 3.0

View File

@@ -110,17 +110,48 @@ def test_runtime_cmis_browser_content_query_relationships_and_changes(cmis_runti
content = runtime.cmis_content_stream("readonly-browser", "cmis:asset:asset-runtime-source", context) content = runtime.cmis_content_stream("readonly-browser", "cmis:asset:asset-runtime-source", context)
query = runtime.cmis_query("readonly-browser", "SELECT * FROM cmis:document", context) query = runtime.cmis_query("readonly-browser", "SELECT * FROM cmis:document", context)
filtered_query = runtime.cmis_query(
"readonly-browser",
"SELECT * FROM cmis:document WHERE kontextual:sensitivity = 'internal' "
"AND kontextual:topics IN ('integration') ORDER BY cmis:name DESC",
context,
)
like_query = runtime.cmis_query(
"readonly-browser",
"SELECT * FROM cmis:document WHERE cmis:name LIKE 'Runtime %' ORDER BY cmis:name DESC",
context,
)
relationships = runtime.cmis_relationships( relationships = runtime.cmis_relationships(
"readonly-browser", "readonly-browser",
context, context,
object_id="cmis:asset:asset-runtime-source", object_id="cmis:asset:asset-runtime-source",
) )
target_relationships = runtime.cmis_relationships(
"readonly-browser",
context,
object_id="cmis:asset:asset-runtime-public",
relationship_direction="target",
)
either_relationships = runtime.cmis_relationships(
"readonly-browser",
context,
object_id="cmis:asset:asset-runtime-public",
relationship_direction="either",
)
changes = runtime.cmis_change_log("readonly-browser", context) changes = runtime.cmis_change_log("readonly-browser", context)
assert content["mime_type"] in {"text/plain", "text/markdown"} assert content["mime_type"] in {"text/plain", "text/markdown"}
assert query["total_num_items"] == 2 assert query["total_num_items"] == 2
assert [item["object_id"] for item in filtered_query["results"]] == [
"cmis:asset:asset-runtime-source"
]
assert [item["name"] for item in like_query["results"]] == ["Runtime Source", "Runtime Public"]
assert relationships["count"] == 1 assert relationships["count"] == 1
assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-runtime-public" assert relationships["items"][0]["properties"]["cmis:targetId"] == "cmis:asset:asset-runtime-public"
assert relationships["items"][0]["properties"]["cmis:changeToken"].startswith("relationship:")
assert relationships["items"][0]["properties"]["kontextual:direction"] == "outbound"
assert target_relationships["count"] == 1
assert either_relationships["count"] == 1
assert changes["total_num_items"] >= 3 assert changes["total_num_items"] >= 3
assert all(change["object_id"] != "cmis:asset:asset-runtime-confidential" for change in changes["changes"]) assert all(change["object_id"] != "cmis:asset:asset-runtime-confidential" for change in changes["changes"])
@@ -137,6 +168,16 @@ def test_runtime_cmis_browser_rejects_unsupported_query_subset(cmis_runtime) ->
assert "Unsupported CMIS query subset" in str(exc_info.value) assert "Unsupported CMIS query subset" in str(exc_info.value)
with pytest.raises(Exception) as direction_exc:
runtime.cmis_relationships(
"readonly-browser",
context,
object_id="cmis:asset:asset-runtime-source",
relationship_direction="both",
)
assert "Unsupported CMIS relationship direction" in str(direction_exc.value)
def test_runtime_cmis_governed_authoring_allows_selected_mutations(cmis_runtime) -> None: def test_runtime_cmis_governed_authoring_allows_selected_mutations(cmis_runtime) -> None:
runtime, context = cmis_runtime runtime, context = cmis_runtime
@@ -335,6 +376,10 @@ def test_runtime_cmis_acl_projection_and_redaction(cmis_runtime) -> None:
assert public_acl["is_exact"] is True assert public_acl["is_exact"] is True
assert {entry["principal_id"] for entry in public_acl["aces"]} == {"cmis-runtime", "anyone"} assert {entry["principal_id"] for entry in public_acl["aces"]} == {"cmis-runtime", "anyone"}
assert public_acl["policy_authority"] == "kontextual-policy-gateway"
assert public_acl["permission_mapping"]["cmis:read"] == "asset visible through profile policy"
assert {entry["principal_kind"] for entry in public_acl["aces"]} == {"human", "well_known"}
assert {entry["inherited"] for entry in public_acl["aces"]} == {False, True}
assert ["cmis:read", "cmis:write", "cmis:delete"] in [ assert ["cmis:read", "cmis:write", "cmis:delete"] in [
entry["permissions"] for entry in internal_acl["aces"] entry["permissions"] for entry in internal_acl["aces"]
] ]

View File

@@ -4,7 +4,7 @@ type: workplan
title: "CMIS Read-Side Contract Maturity" title: "CMIS Read-Side Contract Maturity"
domain: markitect domain: markitect
repo: kontextual-engine repo: kontextual-engine
status: active status: completed
owner: codex owner: codex
topic_slug: markitect topic_slug: markitect
planning_priority: high planning_priority: high
@@ -100,7 +100,7 @@ remain intentionally unsupported.
```task ```task
id: KONT-WP-0016-T001 id: KONT-WP-0016-T001
status: todo status: done
priority: high priority: high
state_hub_task_id: "961bb720-0825-44d9-bb30-b6aed0f3f2cc" state_hub_task_id: "961bb720-0825-44d9-bb30-b6aed0f3f2cc"
``` ```
@@ -117,7 +117,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T002 id: KONT-WP-0016-T002
status: todo status: done
priority: high priority: high
state_hub_task_id: "f393271e-4e0a-4a60-8570-2f2bc1d84c0f" state_hub_task_id: "f393271e-4e0a-4a60-8570-2f2bc1d84c0f"
``` ```
@@ -138,7 +138,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T003 id: KONT-WP-0016-T003
status: todo status: done
priority: high priority: high
state_hub_task_id: "468221a0-111c-4a68-b0b2-392f83e5a70b" state_hub_task_id: "468221a0-111c-4a68-b0b2-392f83e5a70b"
``` ```
@@ -158,7 +158,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T004 id: KONT-WP-0016-T004
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "86d117a8-f569-4708-8687-5a53e6210813" state_hub_task_id: "86d117a8-f569-4708-8687-5a53e6210813"
``` ```
@@ -178,7 +178,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T005 id: KONT-WP-0016-T005
status: todo status: done
priority: high priority: high
state_hub_task_id: "6214aac1-4b3a-41f8-9e61-ab6844171dd1" state_hub_task_id: "6214aac1-4b3a-41f8-9e61-ab6844171dd1"
``` ```
@@ -197,7 +197,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T006 id: KONT-WP-0016-T006
status: todo status: done
priority: high priority: high
state_hub_task_id: "1f492036-0ba0-4a85-addb-b73397e9e966" state_hub_task_id: "1f492036-0ba0-4a85-addb-b73397e9e966"
``` ```
@@ -215,7 +215,7 @@ Acceptance:
```task ```task
id: KONT-WP-0016-T007 id: KONT-WP-0016-T007
status: todo status: done
priority: high priority: high
state_hub_task_id: "73d9b377-1f03-4e12-8028-af43496457b6" state_hub_task_id: "73d9b377-1f03-4e12-8028-af43496457b6"
``` ```
@@ -231,6 +231,64 @@ Acceptance:
- Update the CMIS scorecard and first-release readiness notes from the final - Update the CMIS scorecard and first-release readiness notes from the final
evidence. evidence.
## Implementation Evidence
Implemented on 2026-05-14:
- Added `docs/cmis-read-side-contract.md` as the release-stable read-side
contract.
- Replaced the query allowlist with a bounded parser for `SELECT *` document
queries, `AND` predicates, equality, `LIKE`, `IN`, deterministic paging, and
common CMIS `ORDER BY`.
- Kept `descendants` and `folderTree` explicitly unsupported with CMIS
`notSupported` diagnostics and false capability flags.
- Added relationship target and either-direction filters, relationship change
tokens, provenance, direction, actor, and creation metadata.
- Enriched ACL discovery with principal kind, direct/inherited markers,
source, permission mapping, and policy authority metadata.
- Updated examples, the OpenCMIS subset map, scorecard, compliance assessment,
and release readiness notes.
- Added opt-in capacity coverage in
`tests/cmis/test_cmis_read_side_capacity.py`.
Focused verification:
```text
python3 -m pytest \
tests/cmis/test_cmis_runtime_browser_binding.py \
tests/cmis/test_cmis_browser_binding_api.py \
tests/cmis/test_cmis_compliance_flags.py \
tests/cmis/test_cmis_contract_examples.py
Result: 21 passed, 16 skipped in 5.19s
```
The skipped tests require optional FastAPI/HTTPX test extras in this local
environment. They remain part of the release verification gate when the service
extras are installed. No new OpenCMIS run was required for the selected
object/content baseline because this workplan changes read-side query,
relationship, ACL, and diagnostics contracts outside that selected baseline.
Browser Binding API verification with service extras:
```text
.venv/bin/python -m pytest tests/cmis/test_cmis_browser_binding_api.py -q
Result: 16 passed in 39.09s
```
Full suite verification with service extras:
```text
.venv/bin/python -m pytest -q
Result: 166 passed, 15 skipped in 55.94s
```
The run emitted advisory performance-drift warnings for several API tests. They
do not indicate functional failures and should be watched through the existing
compact performance-history monitor.
## Release Advice ## Release Advice
This workplan should run before `KONT-WP-0015` unless the first release is This workplan should run before `KONT-WP-0015` unless the first release is