Repository: datalad/datalad-next

Issue:
Structured error reporting for `EnsureCommandParameterization`
ATM the encoding of validation errors and correction hints can be overwhelming for more complex constraint setups. There should be an option (or default) to get structured reports that could be rendered in different (environment-optimized ways), e.g. as an input form annotation in a GUI.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) Report
Base: **51.68**% // Head: **51.47**% // Decreases project coverage by **`-0.21%`** :warning:
> Coverage data is based on head [(`557521f`)](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) compared to base [(`796a639`)](https://codecov.io/gh/datalad/datalad-next/commit/796a639925186325bd6f941e4a1c367393f24e45?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).
> Patch coverage: 44.73% of modified lines in pull request are covered.

> :exclamation: Current head 557521f differs from pull request most recent head 7ca2c7f. Consider uploading reports for the commit 7ca2c7f to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #234      +/-   ##
==========================================
- Coverage   51.68%   51.47%   -0.21%     
==========================================
  Files          42       43       +1     
  Lines        3326     3419      +93     
==========================================
+ Hits         1719     1760      +41     
- Misses       1607     1659      +52     
```


| [Impacted Files](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) | Coverage Δ | |
|---|---|---|
| [datalad\_next/constraints/basic.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2Jhc2ljLnB5) | `46.84% <0.00%> (ø)` | |
| [datalad\_next/patches/create\_sibling\_ghlike.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L3BhdGNoZXMvY3JlYXRlX3NpYmxpbmdfZ2hsaWtlLnB5) | `24.44% <0.00%> (-0.56%)` | :arrow_down: |
| [datalad\_next/constraints/parameter.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL3BhcmFtZXRlci5weQ==) | `26.72% <18.18%> (-7.40%)` | :arrow_down: |
| [datalad\_next/constraints/base.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2Jhc2UucHk=) | `54.16% <33.33%> (-1.06%)` | :arrow_down: |
| [datalad\_next/patches/interface\_utils.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L3BhdGNoZXMvaW50ZXJmYWNlX3V0aWxzLnB5) | `75.00% <50.00%> (-1.20%)` | :arrow_down: |
| [datalad\_next/constraints/exceptions.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL2V4Y2VwdGlvbnMucHk=) | `65.95% <65.95%> (ø)` | |
| [datalad\_next/commands/\_\_init\_\_.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbW1hbmRzL19faW5pdF9fLnB5) | `82.35% <75.00%> (-2.27%)` | :arrow_down: |
| [datalad\_next/commands/create\_sibling\_webdav.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbW1hbmRzL2NyZWF0ZV9zaWJsaW5nX3dlYmRhdi5weQ==) | `31.00% <100.00%> (ø)` | |
| [datalad\_next/commands/credentials.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbW1hbmRzL2NyZWRlbnRpYWxzLnB5) | `31.00% <100.00%> (ø)` | |
| [datalad\_next/constraints/\_\_init\_\_.py](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad#diff-ZGF0YWxhZF9uZXh0L2NvbnN0cmFpbnRzL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |
| ... and [1 more](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/datalad/datalad-next/pull/234?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=datalad).


Patch:
diff --git a/datalad_next/commands/__init__.py b/datalad_next/commands/__init__.py
index a6d1b4a..4f8f5a1 100644
--- a/datalad_next/commands/__init__.py
+++ b/datalad_next/commands/__init__.py
@@ -1,4 +1,6 @@
 """Components for basic functions of commands and their results"""
+from __future__ import annotations
+
 from typing import Dict
 
 from datalad.interface.base import (
@@ -15,7 +17,10 @@ except ImportError:
     from datalad.interface.utils import eval_results
 from datalad.support.param import Parameter
 
-from datalad_next.constraints.parameter import EnsureCommandParameterization
+from datalad_next.constraints.parameter import (
+    EnsureCommandParameterization,
+    ParameterConstraintContext,
+)
 from datalad_next.datasets import datasetmethod
 
 
@@ -32,38 +37,14 @@ class ValidatedInterface(Interface):
     validation, which often leads to complex boilerplate code that is largely
     unrelated to the purpose of a particular command.
 
-    This class provides the framework for uniform parameter validation,
+    This class is part of a framework for uniform parameter validation,
     regardless of the target API (Python, CLI, GUI). The implementation of
     a command's ``__call__`` method can focus on the core purpose of the
     command, while validation and error handling can be delegated elsewhere.
 
     A validator for all individual parameters and the joint-set of all
-    parameters is declared in a ``_validator_`` class member.
-    This should be an instance of ``EnsureCommandParameterization``. This
-    default implementation can be subclassed if validation of inter-parameter
-    depenencies is desired (implement
-    ``EnsureCommandParameterization.joint_validation()`` with the necessary
-    checks.
-    Only the output of this validator (`Constraint` implementation) will be
-    passed to the underlying command's ``__call__`` method. Consequently,
-    a command only needs to support the output values of the validators
-    declared by itself.
-
-    When ``EnsureCommandParameterization`` or a subclass is used for
-    validation, there are two cases for which no (full) validation is
-    performed: 1) when no validator for a particular parameter is declared,
-    any input value is passed on as-is. 2) When a parameter value is identical
-    to its default value, the default is passed as-is.
-
-    An important consequence of the second condition is that validators need
-    not cover a default value. For example, a parameter ``path=None``, where
-    ``None`` is a special value used to indiciate an optional and unset value,
-    but actually only paths are acceptable input values, can be described as::
-
-        _validator_ = EnsureCommandParameterization({'path': EnsurePath()})
-
-    and it is not necessary to do something like
-    ``EnsurePath() | EnsureNone()``.
+    parameters can be provided through the :meth:`get_parameter_validator`
+    method.
 
     To transition a command from ``Interface`` to ``ValidatedInterface``,
     replace the base class declaration and declare a ``_validator_`` class
@@ -72,3 +53,27 @@ class ValidatedInterface(Interface):
     ``_validator_``.
     """
     _validator_ = None
+
+    @classmethod
+    def get_parameter_validator(cls) -> EnsureCommandParameterization | None:
+        """Returns a validator for the entire parameter set of a command
+
+        If parameter validation shall be performed, this method must return an
+        instance of
+        :class:`~datalad_next.constraints.parameter.EnsureCommandParameterization`.
+        All parameters will be passed through this validator, and only the its
+        output will be passed to the underlying command's ``__call__`` method.
+
+        Consequently, the core implementation of a command only needs to
+        support the output values of the validators declared by itself.
+
+        Factoring out input validation, normalization, type coercion etc. into
+        a dedicated component also makes it accessible for upfront validation
+        and improved error reporting via the different DataLad APIs.
+
+        If a command does not implement parameter validation in this fashion,
+        this method must return ``None``.
+
+        The default implementation returns the ``_validator_`` class member.
+        """
+        return cls._validator_
diff --git a/datalad_next/commands/create_sibling_webdav.py b/datalad_next/commands/create_sibling_webdav.py
index 3ec4be7..914ed9f 100644
--- a/datalad_next/commands/create_sibling_webdav.py
+++ b/datalad_next/commands/create_sibling_webdav.py
@@ -56,7 +56,7 @@ lgr = logging.getLogger('datalad.distributed.create_sibling_webdav')
 
 
 class CreateSiblingWebDAVParamValidator(EnsureCommandParameterization):
-    def joint_validation(self, params: Dict) -> Dict:
+    def joint_validation(self, params: Dict, on_error: str) -> Dict:
         p = ParamDictator(params)
         if p.url.scheme == "http":
             lgr.warning(
diff --git a/datalad_next/commands/credentials.py b/datalad_next/commands/credentials.py
index 6157322..18eb9b9 100644
--- a/datalad_next/commands/credentials.py
+++ b/datalad_next/commands/credentials.py
@@ -25,6 +25,7 @@ from datalad_next.commands import (
     EnsureCommandParameterization,
     ValidatedInterface,
     Parameter,
+    ParameterConstraintContext,
     build_doc,
     eval_results,
     generic_result_renderer,
@@ -47,32 +48,64 @@ credential_actions = ('query', 'get', 'set', 'remove')
 
 
 class CredentialsParamValidator(EnsureCommandParameterization):
-    def joint_validation(self, params: Dict) -> Dict:
-        p = ParamDictator(params)
+    def __init__(self):
+        super().__init__(
+            param_constraints=dict(
+                action=EnsureChoice(*credential_actions),
+                dataset=EnsureDataset(
+                    # we do not actually require it
+                    installed=None,
+                    purpose='manage credentials',
+                ),
+                name=EnsureStr(),
+                prompt=EnsureStr(),
+            ),
+            validate_defaults=('dataset',),
+            # order in joint_constraints is relevant!
+            joint_constraints={
+                ParameterConstraintContext(('action', 'name', 'spec'),
+                                           'CLI normalization'):
+                self._normalize_cli_params,
+                ParameterConstraintContext(('spec',),
+                                           'credential spec normalization'):
+                self._normalize_spec,
+                # check parameter requirements for particular actions
+                ParameterConstraintContext(('action', 'name'),
+                                           'remove-action requirements'):
+                self._check_remove_requirements,
+                ParameterConstraintContext(('action', 'name', 'spec'),
+                                           'get-action requirements'):
+                self._check_get_requirements,
+            },
+        )
 
-        if p.action in ('get', 'set', 'remove') and not p.name and p.spec \
-                and isinstance(p.spec, list):
+    def _normalize_cli_params(self, action, name, spec):
+        if action in ('get', 'set', 'remove') and not name and spec \
+                and isinstance(spec, list):
             # spec came in like from the CLI (but doesn't have to be from
             # there) and we have no name set
-            if p.spec[0][0] != ':' and '=' not in p.spec[0]:
-                p.name = p.spec[0]
-                p.spec = p.spec[1:]
+            if spec[0][0] != ':' and '=' not in spec[0]:
+                name = spec[0]
+                spec = spec[1:]
+        return dict(action=action, name=name, spec=spec)
 
+    def _normalize_spec(self, spec):
         # `spec` could be many things, make uniform dict
-        p.spec = normalize_specs(p.spec)
-
-        if p.action == 'remove' and not p.name:
-            raise ValueError(
-                f"Credential name must be provided for action {p.action!r}")
-        if p.action == 'get' and not p.name and not p.spec:
-            raise ValueError(
-                "Cannot get credential properties when no name and no "
-                "property specification is provided")
+        return dict(spec=normalize_specs(spec))
 
-        params['name'] = p.name
-        params['spec'] = p.spec
+    def _check_remove_requirements(self, action, name):
+        if action == 'remove' and not name:
+            self.raise_for(
+                dict(action=action, name=name),
+                'no credential name provided',
+            )
 
-        return params
+    def _check_get_requirements(self, action, name, spec):
+        if action == 'get' and not name and not spec:
+            self.raise_for(
+                dict(action=action, name=name, spec=spec),
+                'no name or credential property specified',
+            )
 
 
 @build_doc
@@ -255,16 +288,7 @@ class Credentials(ValidatedInterface):
              code_cmd="datalad credentials --prompt 'can I haz info plz?' get newcred :newproperty"),
     ]
 
-    _validator_ = CredentialsParamValidator(dict(
-        action=EnsureChoice(*credential_actions),
-        dataset=EnsureDataset(
-            # we do not actually require it
-            installed=None,
-            purpose='manage credentials',
-        ),
-        name=EnsureStr(),
-        prompt=EnsureStr(),
-    ))
+    _validator_ = CredentialsParamValidator()
 
     @staticmethod
     @datasetmethod(name='credentials')
diff --git a/datalad_next/constraints/__init__.py b/datalad_next/constraints/__init__.py
index 674e852..1791ded 100644
--- a/datalad_next/constraints/__init__.py
+++ b/datalad_next/constraints/__init__.py
@@ -11,6 +11,7 @@
    parameter
    git
    dataset
+   exceptions
 """
 
 # expose constraints with direct applicability, but not
@@ -36,6 +37,9 @@ from .compound import (
     EnsureMapping,
     EnsureGeneratorFromFileLike,
 )
+# this is the key type, almost all consuming code will want to
+# have this for `except` clauses
+from .exceptions import ConstraintError
 from .formats import (
     EnsureJSON,
     EnsureURL,
diff --git a/datalad_next/constraints/base.py b/datalad_next/constraints/base.py
index 43602c3..dd729a6 100644
--- a/datalad_next/constraints/base.py
+++ b/datalad_next/constraints/base.py
@@ -6,6 +6,8 @@ from typing import (
     TypeVar,
 )
 
+from .exceptions import ConstraintError
+
 if TYPE_CHECKING:  # pragma: no cover
     from datalad_next.datasets import Dataset
 
@@ -26,6 +28,18 @@ class Constraint:
         """Rudimentary repr to avoid default scary to the user Python repr"""
         return "constraint:%s" % self.short_description()
 
+    def raise_for(self, value, msg, **ctx):
+        """Convenience method for raising a ``ConstraintError``
+
+        The parameters are identical to those of ``ConstraintError``. This
+        method merely passes the ``Constraint`` instance as ``self`` to the
+        constructor.
+        """
+        if ctx:
+            raise ConstraintError(self, value, msg, ctx)
+        else:
+            raise ConstraintError(self, value, msg)
+
     def __and__(self, other):
         return Constraints(self, other)
 
@@ -111,8 +125,10 @@ class AltConstraints(_MultiConstraint):
                 return c(value)
             except Exception as e:
                 e_list.append(e)
-        raise ValueError(
-            f"{value!r} violated all possible constraints {self.constraints}")
+        self.raise_for(
+            value, 'not any of {constraints}',
+            constraints=self.constraints,
+        )
 
     def long_description(self):
         return self._get_description('long_description', 'or')
diff --git a/datalad_next/constraints/basic.py b/datalad_next/constraints/basic.py
index 86e2ca0..3e57f15 100644
--- a/datalad_next/constraints/basic.py
+++ b/datalad_next/constraints/basic.py
@@ -230,7 +230,11 @@ class EnsureChoice(Constraint):
 
     def __call__(self, value):
         if value not in self._allowed:
-            raise ValueError(f"value {value!r} is not one of {self._allowed}")
+            self.raise_for(
+                value,
+                "is not one of {allowed}",
+                allowed=self._allowed,
+            )
         return value
 
     def long_description(self):
@@ -288,17 +292,24 @@ class EnsureRange(Constraint):
         """
         self._min = min
         self._max = max
-        if self._min == self._max == None:
+        if self._min is None and self._max is None:
             raise ValueError('No range given, min == max == None')
         super(EnsureRange, self).__init__()
 
     def __call__(self, value):
         if self._min is not None:
-            if value < self._min:
-                raise ValueError("value must be at least %s" % (self._min,))
+            if self._max is not None:
+                if value < self._min or value > self._max:
+                    self.raise_for(
+                        value,
+                        f"must be in range from {self._min!r} to {self._max!r}"
+                    )
+            else:
+                if value < self._min:
+                    self.raise_for(value, f"must be at least {self._min!r}")
         if self._max is not None:
             if value > self._max:
-                raise ValueError("value must be at most %s" % (self._max,))
+                self.raise_for(value, f"must be at most {self._max!r}")
         return value
 
     def long_description(self):
@@ -306,12 +317,12 @@ class EnsureRange(Constraint):
 
     def short_description(self):
         if self._max is None:
-            return f'not less than {self._min}'
+            return f'not less than {self._min!r}'
         elif self._min is None:
-            return f'not greater than {self._max}'
+            return f'not greater than {self._max!r}'
         else:
             # it is inclusive, but spelling it out would be wordy
-            return f'between {self._min} and {self._max}'
+            return f'in range from {self._min!r} to {self._max!r}'
 
 
 class EnsurePath(Constraint):
diff --git a/datalad_next/constraints/dataset.py b/datalad_next/constraints/dataset.py
index d53b405..8997a09 100644
--- a/datalad_next/constraints/dataset.py
+++ b/datalad_next/constraints/dataset.py
@@ -6,9 +6,9 @@ from pathlib import (
 )
 
 from datalad_next.datasets import Dataset
-from datalad_next.exceptions import NoDatasetFound
 
 from .base import Constraint
+from .exceptions import NoDatasetFound
 
 
 class DatasetParameter:
diff --git a/datalad_next/constraints/exceptions.py b/datalad_next/constraints/exceptions.py
new file mode 100644
index 0000000..301831c
--- /dev/null
+++ b/datalad_next/constraints/exceptions.py
@@ -0,0 +1,277 @@
+"""Custom exceptions raised by ``Constraint`` implementations"""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import (
+    Any,
+    Dict,
+    Tuple,
+)
+
+# needed for imports in other pieced of the ``constraints`` module
+from datalad_next.exceptions import NoDatasetFound
+
+
+class ConstraintError(ValueError):
+    # we derive from ValueError, because it provides the seemingly best fit
+    # of any built-in exception. It is defined as:
+    #
+    #   Raised when an operation or function receives an argument that has
+    #   the right type but an inappropriate value, and the situation is not
+    #   described by a more precise exception such as IndexError.
+    #
+    # In general a validation error can also occur because of a TypeError, but
+    # ultimately what also matters here is an ability to coerce a given value
+    # to a target type/value, but such an exception is not among the built-ins.
+    # Moreover, many pieces of existing code do raise ValueError in practice,
+    # and we aim to be widely applicable with this specialized class
+    """Exception type raised by constraints when their conditions are violated
+
+    A primary purpose of this class is to provide uniform means for
+    communicating information on violated constraints.
+    """
+    def __init__(self,
+                 constraint,
+                 value: Any,
+                 msg: str,
+                 ctx: Dict[str, Any] | None = None):
+        """
+        Parameters
+        ----------
+        constraint: Constraint
+          Instance of the ``Constraint`` class that determined a violation.
+        value:
+          The value that is in violation of a constraint.
+        msg: str
+          A message describing the violation. If ``ctx`` is given too, the
+          message can contain keyword placeholders in Python's ``format()``
+          syntax that will be applied on-access.
+        ctx: dict, optional
+          Mapping with context information on the violation. This information
+          is used to interpolate a message, but may also contain additional
+          key-value mappings.
+        """
+        # the msg/ctx setup is inspired by pydantic
+        # we put `msg` in the `.args` container first to match where
+        # `ValueError` would have it. Everthing else goes after it.
+        super().__init__(msg, constraint, value, ctx)
+
+    @property
+    def msg(self):
+        """Obtain an (interpolated) message on the contraint violation"""
+        if self.args[3]:
+            return self.args[0].format(**self.args[3])
+        else:
+            return self.args[0]
+
+    @property
+    def constraint(self):
+        """Get the instance of the constraint that was violated"""
+        return self.args[1]
+
+    @property
+    def value(self):
+        """Get the value that violated the constraint"""
+        return self.args[2]
+
+    def __repr__(self):
+        # rematch constructor arg-order, because we put `msg` first into
+        # `.args`
+        return '{0}({2!r}, {3!r}, {1!r}, {4!r})'.format(
+            self.__class__.__name__,
+            *self.args,
+        )
+
+
+class ConstraintErrors(ConstraintError):
+    """Exception representing context-specific ConstraintError instances
+
+    This class enables the association of a context in which any particular
+    contraint was violated. This is done by passing a mapping, of a context
+    identifier (e.g., a label) to the particular ``ConstraintError`` that
+    occurred in this context, to the constructor.
+
+    This is a generic implementation with no requirements regarding the
+    nature of the context identifiers (expect for being hashable). See
+    ``CommandParametrizationError`` for a specialization.
+    """
+    def __init__(self, exceptions: Dict[Any, ConstraintError]):
+        super().__init__(
+            # this is the main payload, the base class expects a Constraint
+            # but only stores it
+            constraint=exceptions,
+            # all values are already on record in the respective exceptions
+            # no need to pass again
+            value=None,
+            # no support for a dedicated message here (yet?), pass an empty
+            # string to match assumptions
+            msg='',
+            # and no context
+            ctx=None,
+        )
+
+    @property
+    def errors(self) -> MappingProxyType[Any, ConstraintError]:
+        # read-only access
+        return MappingProxyType(self.args[1])
+
+    def __repr__(self):
+        return '{0}({{{1}}})'.format(
+            self.__class__.__name__,
+            ', '.join(f'{k!r}={v!r}' for k, v in self.errors.items()),
+        )
+
+
+class ParameterContextErrors(Mapping):
+    """Read-only convenience that wraps a ``ConstraintErrors`` error mapping
+    """
+    # TODO extend this class with any query functionality that a command
+    # API would want to use in order to get streamlined information on what
+    # went wrong (in general, for a specific parameter, etc...)
+    def __init__(
+        self,
+        errors: Dict[ParameterConstraintContext, ConstraintError],
+    ):
+        self._errors = errors
+
+    def __repr__(self):
+        return self._errors.__repr__()
+
+    def __len__(self):
+        return len(self._errors)
+
+    def __iter__(self):
+        return self._errors.__iter__()
+
+    def __getitem__(self, key):
+        return self._errors[key]
+
+    def items(self):
+        return self._errors.items()
+
+    @property
+    def messages(self):
+        return [e.msg for e in self._errors.values()]
+
+    @property
+    def context_labels(self):
+        return [e.label for e in self._errors.keys()]
+
+    # TODO return all errors related to some parameter
+
+
+@dataclass(frozen=True)
+class ParameterConstraintContext:
+    """Representation of a parameter constraint context
+
+    This type is used for the keys in the error map of.
+    ``ParametrizationErrors``. Its purpose is to clearly identify which
+    parameter combination (and its nature) led to a `ConstraintError`.
+
+    An error context comprises to components: 1) the names of the parameters
+    that were considered, and 2) a description of how the parameters were
+    linked or combined. In the simple case of an error occurring in the context
+    of a single parameter, the second component is superfluous. Otherwise,
+    it can be thought of as an operation label, describing what aspect of
+    the set of parameters is being relevant in a particular context.
+
+    Example:
+
+    A command has two parameters `p1` and `p2`. The may also have respective
+    individual constraints, but importantly they 1) must not have identical
+    values, and 2) their sum must be larger than 3. If the command is called
+    with ``cmd(p1=1, p2=1)``, both conditions are violated. The reporting may
+    be implemented using the following ``ParameterConstraintContext`` and
+    ``ConstraintError`` instances::
+
+      ParameterConstraintContext(('p1', 'p2'), 'inequality):
+        ConstraintError(EnsureValue(True), False, <EnsureValue error>)
+
+      ParameterConstraintContext(('p1', 'p2'), 'sum):
+        ConstraintError(EnsureRange(min=3), False, <EnsureRange error>)
+
+    where the ``ConstraintError`` instances are generated by standard
+    ``Constraint`` implementation. For the second error, this could look like::
+
+      EnsureRange(min=3)(params['p1'] + params['p2'])
+    """
+    parameters: Tuple[str]
+    description: str | None = None
+
+    def __str__(self):
+        return f'Context<{self.label}>'
+
+    @property
+    def label(self) -> str:
+        """A concise summary of the context
+
+        This label will be a compact as possible.
+        """
+        # XXX this could be __str__ but its intended usage for rendering
+        # a text description of all errors would seemingly forbid adding
+        # type information -- which OTOH seems to be desirable for __str__
+        return '{param}{descr}'.format(
+            param=", ".join(self.parameters),
+            descr=f" ({self.description})" if self.description else '',
+        )
+
+
+class ParametrizationErrors(ConstraintErrors):
+    """Exception type raised on violating parameter constraints
+
+    This is a ``ConstraintErrors`` variant that uses parameter names (i.e,
+    ``str`` labels) as context identifiers. In addition to individal
+    parameter names an additional ``__all__`` identifier is recognized. It
+    can be used to record a ``ConstraintError`` arising from high-order
+    constraints, such as the violation of "mutually exclusive" requirements
+    across more than one parameter.
+    """
+    def __init__(
+            self,
+            exceptions: Dict[str, ConstraintError] |
+                        Dict[ParameterConstraintContext, ConstraintError]):
+        super().__init__(
+            {k if isinstance(k, ParameterConstraintContext)
+             else ParameterConstraintContext((k,)):
+             v
+             for k, v in exceptions.items()}
+        )
+
+    @property
+    def errors(self) -> ParameterContextErrors:
+        # read-only access
+        return ParameterContextErrors(self.args[1])
+
+    def __str__(self):
+        return self._render_violations_as_indented_text_list(
+            'parameter')
+
+    def _render_violations_as_indented_text_list(self, violation_subject):
+        violations = len(self.errors)
+        return '{ne} {vs}constraint violation{p}\n{el}'.format(
+            ne=violations,
+            vs=f'{violation_subject} ' if violation_subject else '',
+            p='s' if violations > 1 else '',
+            el='\n'.join(
+                '{ctx}\n  {msg}'.format(
+                    ctx=ctx.label,
+                    msg=c.msg,
+                )
+                for ctx, c in self.errors.items()
+            ),
+        )
+
+
+class CommandParametrizationError(ParametrizationErrors):
+    """Exception type raised on violating any command parameter constraints
+
+    .. seealso::
+
+       :mod:`~datalad_next.constraints.parameter.EnsureCommandParameterization`
+    """
+    def __str__(self):
+        return self._render_violations_as_indented_text_list(
+            'command parameter')
diff --git a/datalad_next/constraints/parameter.py b/datalad_next/constraints/parameter.py
index 91c49b6..7334533 100644
--- a/datalad_next/constraints/parameter.py
+++ b/datalad_next/constraints/parameter.py
@@ -3,22 +3,20 @@ from __future__ import annotations
 from collections.abc import Container
 from typing import (
     Any,
+    Callable,
     Dict,
     TYPE_CHECKING,
+    Type,
     TypeVar,
 )
 
-from .base import (
-    Constraint,
-    ConstraintDerived,
-)
+from .base import Constraint
 from .basic import (
     EnsureBool,
     EnsureChoice,
     EnsureFloat,
     EnsureInt,
     EnsureStr,
-    EnsureValue,
     NoConstraint,
 )
 from .compound import (
@@ -26,14 +24,20 @@ from .compound import (
     EnsureMapping,
 )
 
+from .exceptions import (
+    ConstraintError,
+    ParametrizationErrors,
+    CommandParametrizationError,
+    ParameterConstraintContext,
+)
+
 if TYPE_CHECKING:  # pragma: no cover
     from datalad_next.commands import Parameter
 
-aEnsureParameterConstraint = TypeVar(
-    'aEnsureParameterConstraint',
-    bound='aEnsureParameterConstraint',
+EnsureParameterConstraint_T = TypeVar(
+    'EnsureParameterConstraint_T',
+    bound='EnsureParameterConstraint',
 )
-aParameter = TypeVar('aParameter', bound='Parameter')
 
 
 class NoValue:
@@ -63,7 +67,7 @@ class EnsureParameterConstraint(EnsureMapping):
     valid_param_name_regex = r'[a-z]{1}[a-z0-9_]*'
 
     def __init__(self,
-                 constraint: ConstraintDerived,
+                 constraint: Constraint,
                  passthrough: Any = NoValue):
         """
         Parameters
@@ -103,11 +107,11 @@ class EnsureParameterConstraint(EnsureMapping):
 
     @classmethod
     def from_parameter(
-            cls,
-            spec: aParameter,
+            cls: Type[EnsureParameterConstraint_T],
+            spec: Parameter,
             default: Any,
-            item_constraint: ConstraintDerived = None,
-            nargs: str or int = None) -> aEnsureParameterConstraint:
+            item_constraint: Constraint | None = None,
+            nargs: str | int | None = None) -> EnsureParameterConstraint_T:
         """
         Parameters
         ----------
@@ -147,9 +151,10 @@ _constraint_spec_map = {
 
 
 def _get_comprehensive_constraint(
-        param_spec: aParameter,
-        item_constraint_override: ConstraintDerived = None,
-        nargs_override: str or int = None):
+        param_spec: Parameter,
+        # TODO remove `str` when literal constraint support is removed
+        item_constraint_override: Constraint | str | None = None,
+        nargs_override: str | int | None = None):
     action = param_spec.cmd_kwargs.get('action')
     # definitive per-item constraint, consider override
     # otherwise fall back on Parameter.constraints
@@ -222,12 +227,65 @@ def _get_comprehensive_constraint(
 
 
 class EnsureCommandParameterization(Constraint):
+    """Base class for `ValidatedInterface` parameter validators
+
+    This class can be used as-is, by declaring individual constraints
+    in the constructor, or it can be subclassed to consolidate all
+    custom validation-related code for a command in a single place.
+
+    Commonly this constraint is used by declaring particular value constraints
+    for individual parameters as a mapping. Declaring that the ``path``
+    parameter should receive something that is or can be coerced to
+    a valid ``Path`` object looks like this::
+
+      EnsureCommandParameterization({'path': EnsurePath()})
+
+    This class differs from a standard ``Constraint`` implementation,
+    because its ``__call__()`` method support additional arguments
+    that are used by the internal ``Interface`` handling code to
+    control how parameters are validated.
+
+    During validation, when no validator for a particular parameter is
+    declared, any input value is passed on as-is, and otherwise an input is
+    passed through the validator.
+
+    There is one exception to this rule: When a parameter value is identical to
+    its default value (as declared in the command signature, and communicated
+    via the ``at_default`` argument of ``__call__()``), this default
+    value is also passed as-is, unless the respective parameter name is
+    included in the ``validate_defaults`` constructor argument.
+
+    An important consequence of this behavior is that validators need
+    not cover a default value. For example, a parameter constraint for
+    ``path=None``, where ``None`` is a special value used to indicate an
+    optional and unset value, but actually only paths are acceptable input
+    values. can simply use ``EnsurePath()`` and it is not necessary to do
+    something like ``EnsurePath() | EnsureNone()``.
+
+    However, `EnsureCommandParameterization` can also be specifically
+    instructed to perform validation of defaults for individual parameters, as
+    described above.  A common use case is the auto-discovery of datasets,
+    where often `None` is the default value of a `dataset` parameter (to make
+    it optional), and an `EnsureDataset` constraint is used. This constraint
+    can perform the auto-discovery (with the `None` value indicating that), but
+    validation of defaults must be turned on for the `dataset` parameter in
+    order to do that.
+
+    A second difference to a common ``Constraint`` implementation is the
+    ability to perform an "exhaustive validation" on request (via
+    ``__call__(on_error=...)``). In this case, validation is not stopped at the
+    first discovered violation, but all violations are collected and
+    communicated by raising a ``CommandParametrizationError`` exception, which
+    can be inspected by a caller for details on number and nature of all
+    discovered violations.
     """
-    """
-    def __init__(self,
-                 param_constraints: dict[ConstraintDerived],
-                 *,
-                 validate_defaults: Container[str] | None = None,
+    def __init__(
+        self,
+        param_constraints: Dict[str, Constraint],
+        *,
+        validate_defaults: Container[str] | None = None,
+        joint_constraints:
+            Dict[ParameterConstraintContext, Callable] | None = None,
     ):
         """
         Parameters
@@ -236,38 +294,142 @@ class EnsureCommandParameterization(Constraint):
           Mapping of parameter names to parameter constraints. On validation
           an ``EnsureParameterConstraint`` instance will be created for
           each item in this dict.
-        validate_defaults: container(str)
+        validate_defaults: container(str), optional
           If given, this is a set of parameter names for which the default
           rule, to not validate default values, does not apply and
           default values shall be passed through a given validator.
+        joint_constraints: dict, optional
+          Specification of higher-order constraints considering multiple
+          parameters together. See the ``joint_validation()`` method for
+          details. Constraints will be processed in the order in which
+          they are declared in the mapping. Earlier validators can modify
+          the parameter values that are eventually passed to validators
+          executed later.
         """
         super().__init__()
         self._param_constraints = param_constraints
+        self._joint_constraints = joint_constraints
         self._validate_defaults = validate_defaults or set()
 
-    def joint_validation(self, params: Dict) -> Dict:
-        """Implement for joint validation of the full parameterization
+    def joint_validation(self, params: Dict, on_error: str) -> Dict:
+        """Higher-order validation considering multiple parameters at a time
 
         This method is called with all, individually validated, command
         parameters in keyword-argument form in the ``params`` dict argument.
 
-        Arbritrary additional validation steps can be performed on the full
+        Arbitrary additional validation steps can be performed on the full
         set of parameters that may involve raising exceptions on validation
         errors, but also value transformation or replacements of individual
         parameters based on the setting of others.
 
-        The parameter values return by the method are passed on to the
+        The parameter values returned by the method are passed on to the
         respective command implementation.
 
+        The default implementation iterates over the ``joint_validators``
+        specification given to the constructor, in order to perform
+        any number of validations. This is a mapping of a
+        ``ParameterConstraintContext`` instance to a callable implementing a
+        validation for a particular parameter set.
+
+        Example::
+
+          _joint_validators_ = {
+              ParameterConstraintContext(('p1', 'p2'), 'sum'):
+                  MyValidator._check_sum,
+          }
+
+          def _checksum(self, p1, p2):
+              EnsureRange(min=3)(p1 + p2)
+
+        The callable will be passed the arguments named in the
+        ``ParameterConstraintContext`` as keyword arguments, using the same
+        names as originally given to ``EnsureCommandParameterization``. Any
+        raised ``ConstraintError`` is caught and reported together with the
+        respective ``ParameterConstraintContext``. If the callable anyhow
+        modifies the passed arguments, it must return them as a kwargs-like
+        mapping.  If nothing is modified, it is OK to return ``None``.
+
         Returns
         -------
         dict
-          The returned dict must have a value for each item pass in via
+          The returned dict must have a value for each item passed in via
           ``params``.
+        on_error: {'raise-early', 'raise-at-end'}
+          Flag how to handle constraint violation. By default, validation is
+          stopped at the first error and an exception is raised. When an
+          exhaustive validation is performed, an eventual exception contains
+          information on all constraint violations.
+
+        Raises
+        ------
+        ConstraintErrors
+          With `on_error='raise-at-end'` an implementation can choose to
+          collect more than one higher-order violation and raise them
+          as a `ConstraintErrors` exception.
         """
-        return params
+        # if we have nothing, do nothing
+        if not self._joint_constraints:
+            return params
+
+        exceptions = {}
+        validated = params.copy()
+
+        for ctx, validator in self._joint_constraints.items():
+            # what the validator will produce
+            res = None
+            try:
+                # call the validator with the parameters given in the context
+                # and only with those, to make sure the context is valid
+                # and not an underspecification.
+                # pull the values form `validated` to be able to benefit
+                # from incremental coercing done in individual checks
+                res = validator(**{p: validated[p] for p in ctx.parameters})
+            except ConstraintError as e:
+                exceptions[ctx] = e
+                if on_error == 'raise-early':
+                    raise CommandParametrizationError(exceptions)
+            if res is not None:
+                validated.update(**res)
+
+        if exceptions:
+            raise CommandParametrizationError(exceptions)
 
-    def __call__(self, kwargs, at_default=None) -> Dict:
+        return validated
+
+    def __call__(
+        self,
+        kwargs,
+        at_default=None,
+        on_error='raise-early',
+    ) -> Dict:
+        """
+        Parameters
+        ----------
+        kwargs: dict
+          Parameter name (``str``)) to value (any) mapping of the parameter
+          set.
+        at_default: set or None
+          Set of parameter names where the respective values in ``kwargs``
+          match their respective defaults. This is used for deciding whether
+          or not to process them with an associated value constraint (see the
+          ``validate_defaults`` constructor argument).
+        on_error: {'raise-early', 'raise-at-end'}
+          Flag how to handle constraint violation. By default, validation is
+          stopped at the first error and an exception is raised. When an
+          exhaustive validation is performed, an eventual exception contains
+          information on all constraint violations. Regardless of this mode
+          more than one error can be reported (in case (future) implementation
+          perform independent validations in parallel).
+
+        Raises
+        ------
+        CommandParametrizationError
+          Raised whenever one (or more) ``ConstraintError`` exceptions are
+          caught during validation. Other exception types are not caught and
+          pass through.
+        """
+        assert on_error in ('raise-early', 'raise-at-end')
+        exceptions = {}
         validated = {}
         for argname, arg in kwargs.items():
             if at_default \
@@ -290,17 +452,46 @@ class EnsureCommandParameterization(Constraint):
                 validated[argname] = arg
                 continue
             validator = self._param_constraints.get(argname, lambda x: x)
-            # TODO option to validate all args despite failure
             try:
                 validated[argname] = validator(arg)
-            except Exception as e:
-                raise ValueError(
-                    f'Validation of parameter {argname!r} failed') from e
+            # we catch only ConstraintError -- only these exceptions have what
+            # we need for reporting. If any validator chooses to raise
+            # something else, we do not handle it here, but let it bubble up.
+            # it may be an indication of something being wrong with validation
+            # itself
+            except ConstraintError as e:
+                exceptions[ParameterConstraintContext((argname,))] = e
+                if on_error == 'raise-early':
+                    raise CommandParametrizationError(exceptions)
+
         try:
             # call (subclass) method to perform holistic, cross-parameter
             # validation of the full parameterization
-            validated = self.joint_validation(validated)
-        except Exception as e:
-            raise ValueError('Invalid command parameterization') from e
-
-        return validated
+            final = self.joint_validation(validated, on_error)
+            # check requirements of .joint_validation(), a particular
+            # implementation could be faulty, and we want to report this
+            # problem in the right context
+            try:
+                final.keys() == validated.keys()
+            except Exception as e:
+                raise RuntimeError(
+                    f"{self.__class__.__name__}.joint_validation() "
+                    "did not return items for all passed parameters. "
+                    "Invalid implementation.") from e
+        # we catch the good stuff first. the underlying implementation is
+        # providing an exception with detailed context info on possibly
+        # multiple errors
+        except ParametrizationErrors as e:
+            # we can simply suck in the reports, the context keys do not
+            # overlap, unless the provided validators want that for some
+            # reason
+            exceptions.update(e.errors)
+
+        if exceptions:
+            raise CommandParametrizationError(exceptions)
+
+        return final
+
+    def short_description(self):
+        # TODO Constraint.__repr__ is shit!
+        return "I have to be here, but I do not want to"
diff --git a/datalad_next/patches/interface_utils.py b/datalad_next/patches/interface_utils.py
index 8c06388..9f56d26 100644
--- a/datalad_next/patches/interface_utils.py
+++ b/datalad_next/patches/interface_utils.py
@@ -123,11 +123,22 @@ def _execute_command_(
     )
 
     # validate the complete parameterization
-    if hasattr(interface, '_validator_') and interface._validator_ is not None:
-        allkwargs = interface._validator_(
+    param_validator = interface.get_parameter_validator() \
+        if hasattr(interface, 'get_parameter_validator') else None
+    if param_validator is None:
+        lgr.debug(
+            'Command parameter validation skipped. %s declares no validator',
+            interface)
+    else:
+        lgr.debug('Command parameter validation for %s', interface)
+        allkwargs = param_validator(
             allkwargs,
             at_default=at_default,
+            # TODO make immediate vs exhaustive parameter validation
+            # configurable here
+            #on_error='raise-at-end',
         )
+        lgr.debug('Command parameter validation ended for %s', interface)
 
     # look for potential override of logging behavior
     result_log_level = dlcfg.get('datalad.log.result-level', 'debug')
diff --git a/docs/source/_templates/autosummary/class.rst b/docs/source/_templates/autosummary/class.rst
new file mode 100644
index 0000000..f4db82a
--- /dev/null
+++ b/docs/source/_templates/autosummary/class.rst
@@ -0,0 +1,7 @@
+{{ fullname }}
+{{ underline }}
+
+.. autoclass:: {{ fullname }}
+   :members:
+   :undoc-members:
+   :show-inheritance:
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 0ac9d46..a34a435 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -66,6 +66,7 @@ Python utilities
 
    commands.ValidatedInterface
    constraints
+   credman.manager
    exceptions
    url_operations
    url_operations.any
@@ -73,7 +74,6 @@ Python utilities
    url_operations.http
    url_operations.ssh
    utils
-   utils.credman
    utils.http_helpers
    utils.requests_auth
 


Test patch:
diff --git a/datalad_next/commands/tests/test_create_sibling_webdav.py b/datalad_next/commands/tests/test_create_sibling_webdav.py
index cf1260e..52f583d 100644
--- a/datalad_next/commands/tests/test_create_sibling_webdav.py
+++ b/datalad_next/commands/tests/test_create_sibling_webdav.py
@@ -172,7 +172,7 @@ def test_bad_url_catching(path=None):
     for bad_url, expected_message in check_pairs:
         with pytest.raises(ValueError) as e:
             create_sibling_webdav(dataset=ds, url=bad_url)
-        assert expected_message.format(url=bad_url) in str(e.value.__cause__)
+        assert expected_message.format(url=bad_url) in str(e.value)
 
 
 @with_tempfile
@@ -209,7 +209,7 @@ def test_constraints_checking(path=None):
         with pytest.raises(ValueError) as e:
             create_sibling_webdav(
                 dataset=ds, url=url, **{key: "illegal-value"})
-        assert "is not one of" in str(e.value.__cause__)
+        assert "is not one of" in str(e.value)
 
 
 @with_tempfile
@@ -222,7 +222,7 @@ def test_name_clash_detection(path=None):
         with pytest.raises(ValueError) as e:
             create_sibling_webdav(
                 dataset=ds, url=url, name="abc", storage_name="abc", mode=mode)
-        assert "sibling names must not be equal" in str(e.value.__cause__)
+        assert "sibling names must not be equal" in str(e.value)
 
 
 @with_tempfile
diff --git a/datalad_next/commands/tests/test_credentials.py b/datalad_next/commands/tests/test_credentials.py
index 3418197..c49b7ae 100644
--- a/datalad_next/commands/tests/test_credentials.py
+++ b/datalad_next/commands/tests/test_credentials.py
@@ -109,7 +109,7 @@ def check_credentials_cli():
         if external_versions['datalad'] > '0.17.9':
             # depends on (yet unreleased)
             # https://github.com/datalad/datalad/pull/7210
-            cml.assert_logged('.*name must be provided')
+            assert '  no credential name provided' in cml.lines
     # catch missing `name` via Python call too
     assert_raises(IncompleteResultsError, cred, 'set', spec=[':mike'])
     # no name and no property
diff --git a/datalad_next/constraints/tests/test_basic.py b/datalad_next/constraints/tests/test_basic.py
index 0d942a3..ad74ce1 100644
--- a/datalad_next/constraints/tests/test_basic.py
+++ b/datalad_next/constraints/tests/test_basic.py
@@ -241,6 +241,7 @@ def test_range():
 
     # Range doesn't have to be numeric
     c = EnsureRange(min="e", max="qqq")
+    assert c.short_description() == "in range from 'e' to 'qqq'"
     assert c('e') == 'e'
     assert c('fa') == 'fa'
     assert c('qq') == 'qq'
diff --git a/datalad_next/constraints/tests/test_cmdarg_validation.py b/datalad_next/constraints/tests/test_cmdarg_validation.py
index 8a4b41e..8efda6e 100644
--- a/datalad_next/constraints/tests/test_cmdarg_validation.py
+++ b/datalad_next/constraints/tests/test_cmdarg_validation.py
@@ -1,5 +1,4 @@
 import pytest
-from typing import Dict
 
 from io import StringIO
 from pathlib import Path
@@ -12,22 +11,35 @@ from datalad_next.commands import (
     eval_results,
 )
 from datalad_next.utils import on_windows
-from datalad_next.constraints.base import AltConstraints
 from datalad_next.constraints import (
+    ConstraintError,
     EnsureGeneratorFromFileLike,
     EnsureJSON,
     EnsureListOf,
     EnsureMapping,
     EnsurePath,
+    EnsureRange,
     EnsureURL,
 )
+from datalad_next.constraints.base import (
+    AltConstraints,
+    Constraint,
+)
 from datalad_next.constraints.parameter import EnsureCommandParameterization
+from datalad_next.constraints.exceptions import ParameterConstraintContext
 
 
-class CmdWithValidation(ValidatedInterface):
-    # this is of little relevance, no validation configured here
-    _params_ = dict(spec=Parameter(args=('spec',), nargs='+'))
+class EnsureAllUnique(Constraint):
+    def short_description(self):
+        return 'all values must be unique'
+
+    def __call__(self, value):
+        if len(set(value)) < len(value):
+            self.raise_for(value, 'not all values are unique')
+        return value
+
 
+class BasicCmdValidator(EnsureCommandParameterization):
     url_constraint = EnsureURL(required=['scheme'])
     url2path_constraint = EnsureMapping(
         key=url_constraint, value=EnsurePath(),
@@ -36,21 +48,53 @@ class CmdWithValidation(ValidatedInterface):
     spec_item_constraint = url2path_constraint | url_constraint \
         | (EnsureJSON() & url2path_constraint)
 
-    # this is the key bit: a mapping of parameter names to validators
-    _validator_ = EnsureCommandParameterization(dict(
-        # Must not OR: https://github.com/datalad/datalad/issues/7164
-        #spec=spec_item_constraint | EnsureListOf(spec_item_constraint)# \
-        spec=AltConstraints(
-            EnsureListOf(spec_item_constraint),
-            EnsureGeneratorFromFileLike(spec_item_constraint),
-            spec_item_constraint,
-        ),
-    ))
-
-    # command implementation that only validated and returns the outcome
+    # Must not OR: https://github.com/datalad/datalad/issues/7164
+    #spec_constraint = \
+    #    spec_item_constraint | EnsureListOf(spec_item_constraint)
+    spec_constraint = AltConstraints(
+        EnsureListOf(spec_item_constraint),
+        EnsureGeneratorFromFileLike(spec_item_constraint),
+        spec_item_constraint,
+    )
+
+    def __init__(self, **kwargs):
+        # this is the key bit: a mapping of parameter names to validators
+        super().__init__(
+            dict(spec=self.spec_constraint),
+            **kwargs
+        )
+
+
+class SophisticatedCmdValidator(BasicCmdValidator):
+    def _check_unique_values(self, **kwargs):
+        EnsureAllUnique()(kwargs.values())
+
+    def _check_sum_range(self, p1, p2):
+        EnsureRange(min=3)(p1 + p2)
+
+    def __init__(self):
+        # this is the key bit: a mapping of parameter names to validators
+        super().__init__(
+            # implementation example of a higher-order constraint
+            joint_constraints={
+                ParameterConstraintContext(('p1', 'p2'), 'identity'):
+                    self._check_unique_values,
+                ParameterConstraintContext(('p1', 'p2'), 'sum'):
+                    self._check_sum_range,
+            },
+        )
+
+
+class CmdWithValidation(ValidatedInterface):
+    # this is of little relevance, no validation configured here
+    _params_ = dict(spec=Parameter(args=('spec',), nargs='+'))
+
+    _validator_ = BasicCmdValidator()
+
+    # command implementation that only validates and returns the outcome
     @staticmethod
     @eval_results
-    def __call__(spec):
+    def __call__(spec, p1='one', p2='two'):
         yield dict(
             action='cmd_with_validation',
             # list() consumes any potential generator
@@ -59,6 +103,52 @@ class CmdWithValidation(ValidatedInterface):
         )
 
 
+def test_multi_validation():
+    val = BasicCmdValidator()
+    # we break the parameter specification, and get a ConstraintError
+    with pytest.raises(ConstraintError) as e:
+        val(dict(spec='5'))
+    # but actually, it is a ConstraintErrors instance, and we get the
+    # violation exceptions within the context in which they occurred.
+    # here this is a parameter name
+    errors = e.value.errors
+    assert len(errors) == 1
+    ctx = ParameterConstraintContext(('spec',))
+    assert ctx in errors
+    assert errors[ctx].constraint == BasicCmdValidator.spec_constraint
+    assert errors[ctx].value == '5'
+
+    # now we trigger a higher-order error, and receive multiple reports
+    val = SophisticatedCmdValidator()
+    with pytest.raises(ConstraintError) as e:
+        val(dict(spec='5', p1=1, p2=1), on_error='raise-at-end')
+    errors = e.value.errors
+    assert len(errors) == 3
+    # the spec-param-only error
+    assert errors.messages[0].startswith('not any of')
+    # higher-order issue traces (their order is deterministic)
+    assert 'not all values are unique' == errors.messages[1]
+    assert 'p1, p2 (identity)' == errors.context_labels[1]
+    assert 'p1, p2 (sum)' in errors.context_labels
+    # and now against, but with stop-on-first-error
+    with pytest.raises(ConstraintError) as e:
+        val(dict(spec='5', p1=1, p2=1), on_error='raise-early')
+    errors = e.value.errors
+    # and we only get one!
+    assert len(errors) == 1
+    # the spec-param-only error
+    assert errors.messages[0].startswith('not any of')
+    assert 'not all values are unique' not in errors.messages
+    # now we do it again, but with a valid spec, such that the first
+    # and only error is a higher order error
+    with pytest.raises(ConstraintError) as e:
+        val(dict(spec=5, p1=1, p2=1), on_error='raise-early')
+    errors = e.value.errors
+    assert len(errors) == 1
+    assert 'not all values are unique' == errors.messages[0]
+    assert 'p1, p2 (identity)' == errors.context_labels[0]
+
+
 def test_cmd_with_validation():
     target_urls = ['http://example.com', 'file:///dev/null']
     target_url_path_maps = [
diff --git a/datalad_next/constraints/tests/test_special_purpose.py b/datalad_next/constraints/tests/test_special_purpose.py
index 860cafe..a6fbf08 100644
--- a/datalad_next/constraints/tests/test_special_purpose.py
+++ b/datalad_next/constraints/tests/test_special_purpose.py
@@ -2,7 +2,6 @@ from io import StringIO
 import pytest
 
 from datalad_next.commands import Parameter
-from datalad_next.exceptions import NoDatasetFound
 from datalad_next.utils import chpwd
 
 from ..basic import (
@@ -12,6 +11,7 @@ from ..basic import (
 )
 from ..compound import EnsureGeneratorFromFileLike
 from ..dataset import EnsureDataset
+from ..exceptions import NoDatasetFound
 from ..formats import (
     EnsureJSON,
     EnsureURL,