Add governed form handoffs

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 9dc49fe27b
commit b4388b1e1e
15 changed files with 2236 additions and 89 deletions
+214 -29
View File
@@ -21,6 +21,7 @@ from govoplan_core.core.events import (
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
EvidenceReference,
FormConditionExpression,
FormDefinition,
FormDefinitionProvider,
FormFieldDefinition,
@@ -107,14 +108,17 @@ class FormRuntimeService:
effective_at=recorded_at,
)
supplied_values = _mapping_copy(values, "Form values")
clean_values = {
**{
field.key: field.default_value
for field in definition.fields
if field.default_value is not None
clean_values = normalize_form_values(
definition,
{
**{
field.key: field.default_value
for field in definition.fields
if field.default_value is not None
},
**supplied_values,
},
**supplied_values,
}
)
attachments = tuple(attachment_refs)
signatures = tuple(signature_refs)
diagnostics = validate_form_values(
@@ -402,6 +406,10 @@ class FormRuntimeService:
)
if target_ref.tenant_id != current.tenant_id:
raise FormRuntimeError("Form handoff cannot cross tenants.")
if target_ref.kind in {"case", "workflow"}:
raise FormRuntimeError(
"Case and Workflow handoffs must use the governed native handoff endpoint."
)
if target_ref.kind not in definition.handoff_kinds:
raise FormRuntimeError(
f"Form definition does not permit a {target_ref.kind!r} handoff."
@@ -413,9 +421,7 @@ class FormRuntimeService:
action="handoff",
instance=current,
)
handoffs = tuple(
dict.fromkeys((*current.handoff_refs, target_ref))
)
handoffs = tuple(dict.fromkeys((*current.handoff_refs, target_ref)))
return self._revise(
session,
principal,
@@ -528,7 +534,9 @@ class FormRuntimeService:
FormInstanceIdentity.created_by == _principal_actor(principal)
)
if statuses:
statement = statement.filter(FormInstanceRevision.status.in_(tuple(statuses)))
statement = statement.filter(
FormInstanceRevision.status.in_(tuple(statuses))
)
if definition_id:
statement = statement.filter(
FormInstanceIdentity.definition_id == definition_id
@@ -635,7 +643,10 @@ class FormRuntimeService:
) -> FormInstance:
_require_aware(recorded_at, "Form instance recorded_at")
clean_reason = _text(change_reason, "Form instance change reason", 1000)
clean_values = _mapping_copy(values, "Form values")
clean_values = normalize_form_values(
definition,
_mapping_copy(values, "Form values"),
)
attachments = tuple(attachment_refs)
signatures = tuple(signature_refs)
handoffs = tuple(handoff_refs)
@@ -753,7 +764,9 @@ class FormRuntimeService:
effective_at=effective_at,
)
if definition is None:
raise FormRuntimeError("The exact Form definition was not found or effective.")
raise FormRuntimeError(
"The exact Form definition was not found or effective."
)
if not _same_exact_form_reference(definition.reference, reference):
raise FormRuntimeError(
"The Forms provider returned a different definition or revision."
@@ -818,9 +831,7 @@ class FormsServiceLauncher:
raise FormRuntimeError(
"Form Service launch requires the exact published Service and binding."
)
form_id, form_revision = parse_form_binding_reference(
request.binding.reference
)
form_id, form_revision = parse_form_binding_reference(request.binding.reference)
definition_ref = InstitutionalReference(
kind="form",
owner_module="forms",
@@ -883,9 +894,17 @@ def validate_form_values(
unknown = sorted(set(values) - set(fields))
for key in unknown:
diagnostics.append(
_diagnostic(key, "error", "field.unknown", "This field is not part of the exact Form revision.")
_diagnostic(
key,
"error",
"field.unknown",
"This field is not part of the exact Form revision.",
)
)
visible_fields = visible_form_field_keys(definition, values)
for field in definition.fields:
if field.key not in visible_fields:
continue
present = field.key in values and values[field.key] not in (None, "")
if field.required and not present:
diagnostics.append(
@@ -923,6 +942,117 @@ def validate_form_values(
return tuple(diagnostics)
def normalize_form_values(
definition: FormDefinition,
values: Mapping[str, object],
) -> dict[str, object]:
"""Drop declared fields hidden by authoritative conditions.
Unknown fields remain in the payload so regular validation reports them
instead of silently accepting misspelled or retired keys.
"""
result = _mapping_copy(values, "Form values")
declared = {item.key for item in definition.fields}
for _ in range(len(definition.fields) + 1):
visible = visible_form_field_keys(definition, result)
next_result = {
key: value
for key, value in result.items()
if key not in declared or key in visible
}
if next_result == result:
return next_result
result = next_result
raise FormRuntimeError(
"Form visibility conditions did not converge; verify the published definition."
)
def visible_form_field_keys(
definition: FormDefinition,
values: Mapping[str, object],
) -> frozenset[str]:
visible = {
item.key
for item in definition.fields
if item.visibility_condition is None
or evaluate_form_condition(item.visibility_condition, values)
}
if not definition.pages:
return frozenset(visible)
placed_visible: set[str] = set()
for page in definition.pages:
if page.visibility_condition is not None and not evaluate_form_condition(
page.visibility_condition,
values,
):
continue
for section in page.sections:
if section.visibility_condition is not None and not evaluate_form_condition(
section.visibility_condition, values
):
continue
placed_visible.update(section.field_keys)
return frozenset(visible & placed_visible)
def evaluate_form_condition(
condition: FormConditionExpression,
values: Mapping[str, object],
) -> bool:
if condition.kind == "all":
return all(
evaluate_form_condition(item, values) for item in condition.conditions
)
if condition.kind == "any":
return any(
evaluate_form_condition(item, values) for item in condition.conditions
)
if condition.kind == "not":
return not evaluate_form_condition(condition.conditions[0], values)
actual = values.get(str(condition.field_key))
expected = condition.value
operator = condition.operator
if operator == "eq":
return actual == expected
if operator == "neq":
return actual != expected
if operator == "is_empty":
return _empty_value(actual)
if operator == "is_not_empty":
return not _empty_value(actual)
if operator == "in":
return actual in expected # type: ignore[operator]
if operator == "not_in":
return actual not in expected # type: ignore[operator]
if operator == "contains":
try:
return expected in actual # type: ignore[operator]
except TypeError:
return False
try:
if operator == "lt":
return actual < expected # type: ignore[operator]
if operator == "lte":
return actual <= expected # type: ignore[operator]
if operator == "gt":
return actual > expected # type: ignore[operator]
if operator == "gte":
return actual >= expected # type: ignore[operator]
except TypeError:
return False
raise FormRuntimeError(f"Unsupported Form condition operator: {operator!r}.")
def _empty_value(value: object) -> bool:
if value is None or value == "":
return True
if isinstance(value, (Mapping, Sequence)) and not isinstance(value, (str, bytes)):
return len(value) == 0
return False
def _validate_field_value(
field: FormFieldDefinition,
value: object,
@@ -956,17 +1086,30 @@ def _validate_field_value(
diagnostics: list[Mapping[str, object]] = []
if field.value_type == "email" and not _EMAIL_RE.fullmatch(str(value)):
diagnostics.append(
_diagnostic(field.key, "error", "field.email", "Enter a valid email address.")
_diagnostic(
field.key, "error", "field.email", "Enter a valid email address."
)
)
if field.value_type == "choice" and value not in field.options:
diagnostics.append(
_diagnostic(field.key, "error", "field.option", "Select one of the declared options.")
_diagnostic(
field.key,
"error",
"field.option",
"Select one of the declared options.",
)
)
if field.value_type == "multi_choice" and any(
item not in field.options for item in value # type: ignore[union-attr]
item not in field.options
for item in value # type: ignore[union-attr]
):
diagnostics.append(
_diagnostic(field.key, "error", "field.option", "Every selected value must be a declared option.")
_diagnostic(
field.key,
"error",
"field.option",
"Every selected value must be a declared option.",
)
)
constraints = field.constraints
if isinstance(value, str):
@@ -974,9 +1117,23 @@ def _validate_field_value(
maximum = constraints.get("max_length")
pattern = constraints.get("pattern")
if isinstance(minimum, int) and len(value) < minimum:
diagnostics.append(_diagnostic(field.key, "error", "field.min_length", f"Enter at least {minimum} characters."))
diagnostics.append(
_diagnostic(
field.key,
"error",
"field.min_length",
f"Enter at least {minimum} characters.",
)
)
if isinstance(maximum, int) and len(value) > maximum:
diagnostics.append(_diagnostic(field.key, "error", "field.max_length", f"Enter at most {maximum} characters."))
diagnostics.append(
_diagnostic(
field.key,
"error",
"field.max_length",
f"Enter at most {maximum} characters.",
)
)
if isinstance(pattern, str):
try:
matches = re.fullmatch(pattern, value) is not None
@@ -985,14 +1142,35 @@ def _validate_field_value(
f"Form definition field {field.key!r} has an invalid pattern."
) from exc
if not matches:
diagnostics.append(_diagnostic(field.key, "error", "field.pattern", "The value does not match the required format."))
diagnostics.append(
_diagnostic(
field.key,
"error",
"field.pattern",
"The value does not match the required format.",
)
)
if isinstance(value, (int, float)) and not isinstance(value, bool):
minimum = constraints.get("minimum")
maximum = constraints.get("maximum")
if isinstance(minimum, (int, float)) and value < minimum:
diagnostics.append(_diagnostic(field.key, "error", "field.minimum", f"Enter a value of at least {minimum}."))
diagnostics.append(
_diagnostic(
field.key,
"error",
"field.minimum",
f"Enter a value of at least {minimum}.",
)
)
if isinstance(maximum, (int, float)) and value > maximum:
diagnostics.append(_diagnostic(field.key, "error", "field.maximum", f"Enter a value no greater than {maximum}."))
diagnostics.append(
_diagnostic(
field.key,
"error",
"field.maximum",
f"Enter a value no greater than {maximum}.",
)
)
return tuple(diagnostics)
@@ -1332,8 +1510,10 @@ def _required_mapping(value: Mapping[str, object], key: str) -> Mapping[str, obj
def _identifier(value: str, label: str) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > 255 or not re.fullmatch(
r"[A-Za-z0-9][A-Za-z0-9_.:/-]*", clean
if (
not clean
or len(clean) > 255
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:/-]*", clean)
):
raise FormRuntimeError(f"{label} is invalid.")
return clean
@@ -1342,7 +1522,9 @@ def _identifier(value: str, label: str) -> str:
def _text(value: str, label: str, maximum: int) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > maximum:
raise FormRuntimeError(f"{label} is required and limited to {maximum} characters.")
raise FormRuntimeError(
f"{label} is required and limited to {maximum} characters."
)
return clean
@@ -1381,6 +1563,9 @@ __all__ = [
"FormRuntimePolicyEvaluator",
"FormRuntimeService",
"FormsServiceLauncher",
"evaluate_form_condition",
"normalize_form_values",
"parse_form_binding_reference",
"validate_form_values",
"visible_form_field_keys",
]