diff --git a/docs/BPMN_INTEROPERABILITY.md b/docs/BPMN_INTEROPERABILITY.md
new file mode 100644
index 0000000..78f3d5f
--- /dev/null
+++ b/docs/BPMN_INTEROPERABILITY.md
@@ -0,0 +1,37 @@
+# BPMN Interoperability
+
+GovOPlaN distinguishes BPMN notation and XML interchange from executable
+workflow semantics.
+
+## Current Contract
+
+- `GET /api/v1/workflow/bpmn/profile` publishes the exact native support
+ profile.
+- `POST /api/v1/workflow/bpmn/inspect` safely parses bounded BPMN 2.0 XML,
+ inventories every BPMN model element, detects duplicate IDs and selected
+ dangling references, and classifies elements as interchange-only, natively
+ mappable, or natively executable.
+- XML entities, DTD-based expansion, oversized documents, and malformed roots
+ are rejected.
+- The native GovOPlaN graph remains the authoritative executable definition.
+
+Inspection is not XML Schema validation and does not claim that every BPMN
+semantic construct can be executed. A future `bpmn-js`/`bpmn-moddle` adapter
+can provide complete visual notation and XML round-tripping without forcing
+unsupported elements into the native runner.
+
+## Execution Boundary
+
+Adding a BPMN shape is not equivalent to implementing its token semantics,
+event subscriptions, compensation, transactions, choreography, or conformance
+behavior. Each executable mapping therefore needs:
+
+1. an explicit native semantic mapping;
+2. validation rules and lifecycle behavior;
+3. resumability and idempotency tests;
+4. migration and round-trip fixtures;
+5. a declared fallback when the installed runtime cannot execute it.
+
+Unsupported constructs remain visible and preserved by the future interchange
+adapter, but activation must remain blocked until an execution adapter declares
+support.
diff --git a/docs/VISUAL_MODEL.md b/docs/VISUAL_MODEL.md
new file mode 100644
index 0000000..76db566
--- /dev/null
+++ b/docs/VISUAL_MODEL.md
@@ -0,0 +1,24 @@
+# Workflow Visual Model
+
+The Campaign review flow is the reference for runtime workflow progress:
+
+- a compact stage rail communicates order, current state, completion, warning,
+ failure, partial progress, and locks;
+- the active handoff owns the detailed controls;
+- evidence remains visible without turning every stage into a permanent card;
+- unavailable stages stay visibly unavailable while non-blocking optional
+ stages do not interrupt the connector state.
+
+Workflow now applies that language to instance progress without importing
+Campaign code. Once the state vocabulary has stabilized, the rail should move
+to Core as a generic process-stage component and Campaign should consume it.
+
+Navigation has three distinct layers:
+
+1. the platform siderail selects a module or focused View;
+2. the module workspace selects an object or definition;
+3. the workflow stage rail describes progress inside that object.
+
+A focused View or active Workflow may suppress unrelated platform and module
+navigation, but must always provide a visible escape back to the normal View.
+Modules should not add another persistent navigation tier for workflow stages.
diff --git a/pyproject.toml b/pyproject.toml
index 6b7cca0..9570a62 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,7 @@ readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
-dependencies = ["govoplan-core>=0.1.14"]
+dependencies = ["defusedxml>=0.7,<1", "govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/govoplan_workflow/backend/bpmn.py b/src/govoplan_workflow/backend/bpmn.py
new file mode 100644
index 0000000..1361d31
--- /dev/null
+++ b/src/govoplan_workflow/backend/bpmn.py
@@ -0,0 +1,309 @@
+from __future__ import annotations
+
+from collections import Counter
+from dataclasses import dataclass
+from typing import Literal
+from xml.etree.ElementTree import ParseError
+
+from defusedxml.ElementTree import fromstring
+from defusedxml.common import DefusedXmlException
+
+
+BPMN_MODEL_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/MODEL"
+BPMN_DI_NAMESPACE = "http://www.omg.org/spec/BPMN/20100524/DI"
+OMG_DI_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DI"
+OMG_DC_NAMESPACE = "http://www.omg.org/spec/DD/20100524/DC"
+MAX_BPMN_XML_BYTES = 1_048_576
+MAX_BPMN_ELEMENTS = 20_000
+
+SupportLevel = Literal[
+ "interchange_only",
+ "native_mapping",
+ "native_execution",
+]
+
+
+NATIVE_EXECUTION_ELEMENTS = frozenset(
+ {
+ "startEvent",
+ "endEvent",
+ "manualTask",
+ "userTask",
+ "serviceTask",
+ "businessRuleTask",
+ "receiveTask",
+ "sendTask",
+ "exclusiveGateway",
+ "parallelGateway",
+ "sequenceFlow",
+ "intermediateCatchEvent",
+ "intermediateThrowEvent",
+ }
+)
+
+NATIVE_MAPPING_ELEMENTS = NATIVE_EXECUTION_ELEMENTS | frozenset(
+ {
+ "task",
+ "scriptTask",
+ "callActivity",
+ "subProcess",
+ "transaction",
+ "adHocSubProcess",
+ "inclusiveGateway",
+ "eventBasedGateway",
+ "complexGateway",
+ "boundaryEvent",
+ "eventSubProcess",
+ "dataObject",
+ "dataObjectReference",
+ "dataStoreReference",
+ "messageFlow",
+ "participant",
+ "lane",
+ "laneSet",
+ "textAnnotation",
+ "association",
+ "group",
+ }
+)
+
+
+@dataclass(frozen=True, slots=True)
+class BpmnDiagnostic:
+ severity: Literal["error", "warning", "info"]
+ code: str
+ message: str
+ element_id: str | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class BpmnElementInventoryItem:
+ element_type: str
+ element_id: str | None
+ name: str | None
+ parent_type: str | None
+ parent_id: str | None
+ support_level: SupportLevel
+
+
+@dataclass(frozen=True, slots=True)
+class BpmnInspection:
+ definitions_id: str | None
+ target_namespace: str | None
+ process_count: int
+ executable_process_count: int
+ collaboration_count: int
+ choreography_count: int
+ element_counts: dict[str, int]
+ support_counts: dict[str, int]
+ elements: tuple[BpmnElementInventoryItem, ...]
+ diagnostics: tuple[BpmnDiagnostic, ...]
+
+ @property
+ def valid_xml(self) -> bool:
+ return not any(item.severity == "error" for item in self.diagnostics)
+
+
+class BpmnInspectionError(ValueError):
+ pass
+
+
+def bpmn_support_level(element_type: str) -> SupportLevel:
+ if element_type in NATIVE_EXECUTION_ELEMENTS:
+ return "native_execution"
+ if element_type in NATIVE_MAPPING_ELEMENTS:
+ return "native_mapping"
+ return "interchange_only"
+
+
+def inspect_bpmn_xml(xml: str) -> BpmnInspection:
+ encoded = xml.encode("utf-8")
+ if not encoded:
+ raise BpmnInspectionError("BPMN XML is empty")
+ if len(encoded) > MAX_BPMN_XML_BYTES:
+ raise BpmnInspectionError(
+ f"BPMN XML exceeds the {MAX_BPMN_XML_BYTES}-byte inspection limit"
+ )
+ try:
+ root = fromstring(encoded)
+ except (DefusedXmlException, ParseError, ValueError) as exc:
+ raise BpmnInspectionError(f"BPMN XML is not safe and well formed: {exc}") from exc
+
+ namespace, local_name = _qualified_name(root.tag)
+ if namespace != BPMN_MODEL_NAMESPACE or local_name != "definitions":
+ raise BpmnInspectionError(
+ "BPMN document root must be bpmn:definitions in the BPMN 2.0 model namespace"
+ )
+
+ diagnostics: list[BpmnDiagnostic] = []
+ elements: list[BpmnElementInventoryItem] = []
+ ids: dict[str, str] = {}
+ references: list[tuple[str, str | None, str]] = []
+ process_count = 0
+ executable_process_count = 0
+ collaboration_count = 0
+ choreography_count = 0
+
+ stack = [(root, None, None)]
+ visited = 0
+ while stack:
+ element, parent_type, parent_id = stack.pop()
+ visited += 1
+ if visited > MAX_BPMN_ELEMENTS:
+ raise BpmnInspectionError(
+ f"BPMN document exceeds the {MAX_BPMN_ELEMENTS}-element inspection limit"
+ )
+ element_namespace, element_type = _qualified_name(element.tag)
+ element_id = _bounded_attribute(element.attrib.get("id"), 255)
+ if element_namespace == BPMN_MODEL_NAMESPACE:
+ name = _bounded_attribute(element.attrib.get("name"), 300)
+ support = bpmn_support_level(element_type)
+ elements.append(
+ BpmnElementInventoryItem(
+ element_type=element_type,
+ element_id=element_id,
+ name=name,
+ parent_type=parent_type,
+ parent_id=parent_id,
+ support_level=support,
+ )
+ )
+ if element_id:
+ previous = ids.get(element_id)
+ if previous is not None:
+ diagnostics.append(
+ BpmnDiagnostic(
+ severity="error",
+ code="duplicate_bpmn_id",
+ message=(
+ f"BPMN id {element_id!r} is used by both "
+ f"{previous} and {element_type}"
+ ),
+ element_id=element_id,
+ )
+ )
+ else:
+ ids[element_id] = element_type
+ if element_type == "process":
+ process_count += 1
+ if element.attrib.get("isExecutable", "").strip().lower() == "true":
+ executable_process_count += 1
+ elif element_type == "collaboration":
+ collaboration_count += 1
+ elif element_type in {"choreography", "globalChoreographyTask"}:
+ choreography_count += 1
+ _collect_references(element_type, element_id, element.attrib, references)
+ next_parent_type = element_type
+ next_parent_id = element_id
+ else:
+ next_parent_type = parent_type
+ next_parent_id = parent_id
+ for child in reversed(list(element)):
+ stack.append((child, next_parent_type, next_parent_id))
+
+ if process_count == 0 and collaboration_count == 0 and choreography_count == 0:
+ diagnostics.append(
+ BpmnDiagnostic(
+ severity="warning",
+ code="no_bpmn_process_or_collaboration",
+ message="BPMN definitions contain no process, collaboration, or choreography",
+ )
+ )
+ for reference, element_id, field in references:
+ if reference not in ids:
+ diagnostics.append(
+ BpmnDiagnostic(
+ severity="error",
+ code="dangling_bpmn_reference",
+ message=(
+ f"{field} references unknown BPMN id {reference!r}"
+ ),
+ element_id=element_id,
+ )
+ )
+ unsupported = [
+ item
+ for item in elements
+ if item.support_level == "interchange_only"
+ and item.element_type not in {"definitions", "process"}
+ ]
+ if unsupported:
+ diagnostics.append(
+ BpmnDiagnostic(
+ severity="info",
+ code="interchange_only_elements",
+ message=(
+ f"{len(unsupported)} BPMN element(s) can be inventoried and "
+ "round-tripped by a BPMN modeler but are not executable by "
+ "the native GovOPlaN workflow runtime"
+ ),
+ )
+ )
+ element_counts = dict(
+ sorted(Counter(item.element_type for item in elements).items())
+ )
+ support_counts = dict(
+ sorted(Counter(item.support_level for item in elements).items())
+ )
+ return BpmnInspection(
+ definitions_id=_bounded_attribute(root.attrib.get("id"), 255),
+ target_namespace=_bounded_attribute(
+ root.attrib.get("targetNamespace"),
+ 1000,
+ ),
+ process_count=process_count,
+ executable_process_count=executable_process_count,
+ collaboration_count=collaboration_count,
+ choreography_count=choreography_count,
+ element_counts=element_counts,
+ support_counts=support_counts,
+ elements=tuple(elements),
+ diagnostics=tuple(diagnostics),
+ )
+
+
+def _collect_references(
+ element_type: str,
+ element_id: str | None,
+ attributes: dict[str, str],
+ references: list[tuple[str, str | None, str]],
+) -> None:
+ fields: tuple[str, ...]
+ if element_type == "sequenceFlow":
+ fields = ("sourceRef", "targetRef")
+ elif element_type == "messageFlow":
+ fields = ("sourceRef", "targetRef", "messageRef")
+ elif element_type == "participant":
+ fields = ("processRef",)
+ elif element_type == "lane":
+ fields = ("partitionElementRef",)
+ else:
+ fields = ()
+ for field in fields:
+ value = attributes.get(field)
+ if value:
+ references.append((value, element_id, field))
+
+
+def _qualified_name(tag: str) -> tuple[str | None, str]:
+ if tag.startswith("{") and "}" in tag:
+ namespace, local_name = tag[1:].split("}", 1)
+ return namespace, local_name
+ return None, tag
+
+
+def _bounded_attribute(value: str | None, limit: int) -> str | None:
+ if value is None:
+ return None
+ text = value.strip()
+ return text[:limit] if text else None
+
+
+__all__ = [
+ "BPMN_MODEL_NAMESPACE",
+ "BpmnInspectionError",
+ "NATIVE_EXECUTION_ELEMENTS",
+ "NATIVE_MAPPING_ELEMENTS",
+ "bpmn_support_level",
+ "inspect_bpmn_xml",
+]
diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py
index f5a4ee8..44f941c 100644
--- a/src/govoplan_workflow/backend/router.py
+++ b/src/govoplan_workflow/backend/router.py
@@ -29,7 +29,19 @@ from govoplan_workflow.backend.manifest import (
INSTANCE_TRANSITION_SCOPE,
)
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
+from govoplan_workflow.backend.bpmn import (
+ BPMN_MODEL_NAMESPACE,
+ BpmnInspectionError,
+ NATIVE_EXECUTION_ELEMENTS,
+ NATIVE_MAPPING_ELEMENTS,
+ inspect_bpmn_xml,
+)
from govoplan_workflow.backend.schemas import (
+ BpmnDiagnosticResponse,
+ BpmnElementSupportResponse,
+ BpmnInspectionRequest,
+ BpmnInspectionResponse,
+ BpmnSupportProfileResponse,
WorkflowConfigFieldResponse,
WorkflowDefinitionActivateRequest,
WorkflowDefinitionCreateRequest,
@@ -204,6 +216,85 @@ def _require_instance_view(instance, principal: ApiPrincipal) -> None:
)
+@router.get("/bpmn/profile", response_model=BpmnSupportProfileResponse)
+def api_bpmn_support_profile(
+ principal: ApiPrincipal = Depends(get_api_principal),
+) -> BpmnSupportProfileResponse:
+ _require_any_scope(
+ principal,
+ DEFINITION_READ_SCOPE,
+ DEFINITION_WRITE_SCOPE,
+ ADMIN_SCOPE,
+ )
+ return BpmnSupportProfileResponse(
+ specification="BPMN 2.0.2",
+ model_namespace=BPMN_MODEL_NAMESPACE,
+ interchange=(
+ "Secure XML inventory and diagnostics are available. Visual "
+ "round-trip modeling requires the planned bpmn-js adapter."
+ ),
+ native_runtime=(
+ "Only the explicitly listed executable subset maps to current "
+ "native runtime semantics; all other elements are interchange-only."
+ ),
+ native_execution_elements=sorted(NATIVE_EXECUTION_ELEMENTS),
+ native_mapping_elements=sorted(
+ NATIVE_MAPPING_ELEMENTS - NATIVE_EXECUTION_ELEMENTS
+ ),
+ )
+
+
+@router.post("/bpmn/inspect", response_model=BpmnInspectionResponse)
+def api_inspect_bpmn(
+ payload: BpmnInspectionRequest,
+ principal: ApiPrincipal = Depends(get_api_principal),
+) -> BpmnInspectionResponse:
+ _require_any_scope(
+ principal,
+ DEFINITION_READ_SCOPE,
+ DEFINITION_WRITE_SCOPE,
+ ADMIN_SCOPE,
+ )
+ try:
+ result = inspect_bpmn_xml(payload.xml)
+ except BpmnInspectionError as exc:
+ raise HTTPException(
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
+ detail=str(exc),
+ ) from exc
+ return BpmnInspectionResponse(
+ valid_xml=result.valid_xml,
+ definitions_id=result.definitions_id,
+ target_namespace=result.target_namespace,
+ process_count=result.process_count,
+ executable_process_count=result.executable_process_count,
+ collaboration_count=result.collaboration_count,
+ choreography_count=result.choreography_count,
+ element_counts=result.element_counts,
+ support_counts=result.support_counts,
+ elements=[
+ BpmnElementSupportResponse(
+ element_type=item.element_type,
+ element_id=item.element_id,
+ name=item.name,
+ parent_type=item.parent_type,
+ parent_id=item.parent_id,
+ support_level=item.support_level,
+ )
+ for item in result.elements
+ ],
+ diagnostics=[
+ BpmnDiagnosticResponse(
+ severity=item.severity,
+ code=item.code,
+ message=item.message,
+ element_id=item.element_id,
+ )
+ for item in result.diagnostics
+ ],
+ )
+
+
@router.get("/node-types", response_model=WorkflowNodeLibraryResponse)
def api_node_types(
principal: ApiPrincipal = Depends(get_api_principal),
diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py
index 6b5b20d..c693d25 100644
--- a/src/govoplan_workflow/backend/schemas.py
+++ b/src/govoplan_workflow/backend/schemas.py
@@ -10,6 +10,11 @@ from pydantic import BaseModel, Field, field_validator
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
+BpmnSupportLevel = Literal[
+ "interchange_only",
+ "native_mapping",
+ "native_execution",
+]
class WorkflowPosition(BaseModel):
@@ -100,6 +105,49 @@ class WorkflowNodeLibraryResponse(BaseModel):
nodes: list[WorkflowNodeTypeResponse]
+class BpmnInspectionRequest(BaseModel):
+ xml: str = Field(min_length=1, max_length=1_048_576)
+
+
+class BpmnElementSupportResponse(BaseModel):
+ element_type: str
+ element_id: str | None = None
+ name: str | None = None
+ parent_type: str | None = None
+ parent_id: str | None = None
+ support_level: BpmnSupportLevel
+
+
+class BpmnDiagnosticResponse(BaseModel):
+ severity: Literal["error", "warning", "info"]
+ code: str
+ message: str
+ element_id: str | None = None
+
+
+class BpmnInspectionResponse(BaseModel):
+ valid_xml: bool
+ definitions_id: str | None = None
+ target_namespace: str | None = None
+ process_count: int
+ executable_process_count: int
+ collaboration_count: int
+ choreography_count: int
+ element_counts: dict[str, int]
+ support_counts: dict[str, int]
+ elements: list[BpmnElementSupportResponse]
+ diagnostics: list[BpmnDiagnosticResponse]
+
+
+class BpmnSupportProfileResponse(BaseModel):
+ specification: str
+ model_namespace: str
+ interchange: str
+ native_runtime: str
+ native_execution_elements: list[str]
+ native_mapping_elements: list[str]
+
+
class WorkflowDefinitionRevisionResponse(BaseModel):
id: str
revision: int
diff --git a/tests/test_bpmn.py b/tests/test_bpmn.py
new file mode 100644
index 0000000..7221bab
--- /dev/null
+++ b/tests/test_bpmn.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+import unittest
+
+from govoplan_workflow.backend.bpmn import (
+ BpmnInspectionError,
+ inspect_bpmn_xml,
+)
+
+
+BPMN = """
+