Skip to content

Validation

etcion.validation.permissions

ArchiMate 3.2 Appendix B relationship permission table.

Encodes the normative relationship permission matrix and exposes a lookup function: given a relationship type, source element type, and target element type, returns whether the relationship is permitted by the specification.

Reference: ADR-028; ArchiMate 3.2 Specification, Appendix B.

PermissionRule

Bases: NamedTuple

A single entry in the declarative permission table.

source_type and target_type may be abstract base classes (ABCs) as well as concrete types; the table intentionally encodes rules at the ABC level for hierarchical matching. type[Any] is used so mypy does not raise type-abstract on ABC entries.

warm_cache()

Eagerly build the permission lookup cache.

By default the cache is built lazily on the first is_permitted() call. Call this function during application startup to pay the cost upfront and ensure deterministic latency on the first permission check.

This is a no-op if the cache is already built.

Source code in src/etcion/validation/permissions.py
369
370
371
372
373
374
375
376
377
378
379
380
def warm_cache() -> None:
    """Eagerly build the permission lookup cache.

    By default the cache is built lazily on the first ``is_permitted()``
    call. Call this function during application startup to pay the cost
    upfront and ensure deterministic latency on the first permission check.

    This is a no-op if the cache is already built.
    """
    global _cache
    if _cache is None:
        _cache = _build_cache()

is_permitted(rel_type, source_type, target_type)

Check whether a relationship is permitted per Appendix B.

Universal rules are checked first (ADR-017 ss7):

  • Composition, Aggregation: permitted between same-type elements.
  • Specialization: permitted between same concrete type only.
  • Association: always permitted between any two concepts.

The Realization(WorkPackage, Deliverable) deprecation special case (ADR-028 Decision 8) is evaluated after universal short-circuits and before the cache lookup.

All other rules are resolved via the _PERMISSION_TABLE expanded into the concrete-type cache.

:param rel_type: The concrete relationship type to check. :param source_type: The type of the source element. :param target_type: The type of the target element. :returns: True if the relationship is permitted; False otherwise.

Source code in src/etcion/validation/permissions.py
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
def is_permitted(
    rel_type: type[Relationship],
    source_type: type[Element],
    target_type: type[Element],
) -> bool:
    """Check whether a relationship is permitted per Appendix B.

    Universal rules are checked first (ADR-017 ss7):

    - ``Composition``, ``Aggregation``: permitted between same-type elements.
    - ``Specialization``: permitted between same concrete type only.
    - ``Association``: always permitted between any two concepts.

    The ``Realization(WorkPackage, Deliverable)`` deprecation special case
    (ADR-028 Decision 8) is evaluated after universal short-circuits and
    before the cache lookup.

    All other rules are resolved via the ``_PERMISSION_TABLE`` expanded into
    the concrete-type cache.

    :param rel_type: The concrete relationship type to check.
    :param source_type: The type of the source element.
    :param target_type: The type of the target element.
    :returns: ``True`` if the relationship is permitted; ``False`` otherwise.
    """
    global _cache

    # 1. Universal: Composition/Aggregation same-type + CompositeElement->Relationship
    if rel_type in _UNIVERSAL_SAME_TYPE:
        if source_type == target_type:
            return True
        if issubclass(target_type, Relationship) and issubclass(  # type: ignore[unreachable]
            source_type, CompositeElement
        ):
            return True  # type: ignore[unreachable]
        return False

    # 2. Universal: Specialization same-type
    if rel_type is Specialization:
        return source_type == target_type

    # 3. Universal: Association always permitted
    if rel_type is Association:
        return True

    # 4. Deprecation special case (ADR-028 Decision 8)
    if rel_type is Realization:
        if issubclass(source_type, WorkPackage) and issubclass(target_type, Deliverable):
            warnings.warn(
                "Realization from WorkPackage to Deliverable is deprecated in "
                "ArchiMate 3.2. Use Assignment instead.",
                DeprecationWarning,
                stacklevel=2,
            )
            return True

    # 5. Cache lookup
    if _cache is None:
        _cache = _build_cache()
    return _cache.get((rel_type, source_type, target_type), False)

register_permission_rule(rule)

Append a custom permission rule to the permission table.

.. warning:: Custom permission rules are not portable. Models validated with custom rules may not conform to the ArchiMate 3.2 spec.

The rule is appended to the end of _PERMISSION_TABLE. Ordering matters: prohibitions (permitted=False) should precede permissions (permitted=True) within each rel_type group for correct first-match-wins semantics during cache build.

:param rule: A :class:PermissionRule namedtuple. :raises TypeError: If rule is not a PermissionRule.

Source code in src/etcion/validation/permissions.py
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
def register_permission_rule(rule: PermissionRule) -> None:
    """Append a custom permission rule to the permission table.

    .. warning::
        Custom permission rules are **not portable**. Models validated
        with custom rules may not conform to the ArchiMate 3.2 spec.

    The rule is appended to the **end** of ``_PERMISSION_TABLE``.
    Ordering matters: prohibitions (``permitted=False``) should precede
    permissions (``permitted=True``) within each ``rel_type`` group for
    correct first-match-wins semantics during cache build.

    :param rule: A :class:`PermissionRule` namedtuple.
    :raises TypeError: If *rule* is not a PermissionRule.
    """
    import warnings

    global _cache

    if not isinstance(rule, PermissionRule):
        raise TypeError(f"Expected PermissionRule, got {type(rule).__name__}")

    _PERMISSION_TABLE.append(rule)
    _cache = None

    warnings.warn(
        f"Custom permission rule registered: "
        f"{rule.rel_type.__name__}({rule.source_type.__name__} -> "
        f"{rule.target_type.__name__}, permitted={rule.permitted}). "
        f"Models using this rule are NOT portable.",
        UserWarning,
        stacklevel=2,
    )

etcion.validation.rules

Custom validation rule protocol.

Reference: ADR-038.

ValidationRule

Bases: Protocol

Protocol for user-defined model validation rules.

Implement this protocol and register instances via :meth:Model.add_validation_rule.

Example::

class NoEmptyDocs:
    def validate(self, model: Model) -> list[ValidationError]:
        from etcion.exceptions import ValidationError
        return [
            ValidationError(f"Element '{e.id}' has no documentation")
            for e in model.elements
            if not e.description
        ]

model.add_validation_rule(NoEmptyDocs())

validate(model)

Return a list of validation errors found in model.

Return an empty list if the model passes this rule.

Source code in src/etcion/validation/rules.py
36
37
38
39
40
41
def validate(self, model: Model) -> list[ValidationError]:
    """Return a list of validation errors found in *model*.

    Return an empty list if the model passes this rule.
    """
    ...