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
+6
View File
@@ -28,6 +28,12 @@ validates launch values, and retains both Service and binding provenance.
- signature state
- handoff status
Submitted instances can create a native Case or start a Workflow through the
owning module capability. Runtime persists the exact handoff intent before the
external action, commits that intent, uses a stable provider idempotency key,
and stores `succeeded`, `rejected`, or `outcome_unknown` evidence. Unknown
outcomes are reconciled against the owner rather than retried as a new action.
## Boundaries
This module does not own:
+8 -3
View File
@@ -48,6 +48,9 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
- bounded list/detail/history/event APIs and accessible definition-driven WebUI
- `forms_runtime.service_launcher` retaining exact Service and binding
provenance
- native Case and Workflow handoffs that commit a durable effect intent before
invoking the owner capability, use stable provider idempotency keys, and
reconcile outcome-unknown execution
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
tenant/replay/stale-write/validation/handoff tests
@@ -81,6 +84,8 @@ Destructive retirement is blocked while state exists and requires a verified
database snapshot plus an export or retention decision for referenced evidence.
No local generated files are required, so API and worker nodes remain stateless.
Anonymous public intake, concrete file/signature upload adapters, conditional
multi-page layout, and automated target handoff execution remain product depth;
the owner and security boundaries no longer depend on those additions.
Anonymous public intake and concrete file/signature upload adapters remain
product depth. Conditional multi-page definitions are resolved from Forms, and
native Case/Workflow handoffs execute automatically when the exact owner
capability is installed. Additional target kinds remain adapter depth; the
owner and security boundaries no longer depend on those additions.
@@ -4,7 +4,15 @@ from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, UniqueConstraint
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
@@ -125,8 +133,65 @@ class FormInstanceEvent(Base, TimestampMixin):
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
class FormHandoffEffect(Base, TimestampMixin):
__tablename__ = "form_handoff_effects"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"effect_id",
name="uq_form_handoff_effect",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_handoff_idempotency",
),
UniqueConstraint(
"tenant_id",
"provider_key",
name="uq_form_handoff_provider_key",
),
Index(
"ix_form_handoff_instance_state",
"tenant_id",
"instance_id",
"state",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
effect_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
provider_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
binding_kind: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
binding_reference: Mapped[str] = mapped_column(String(500), nullable=False)
provider_capability: Mapped[str] = mapped_column(String(120), nullable=False)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
requested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
target_ref: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
href: Mapped[str | None] = mapped_column(String(2000), nullable=True)
evidence: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, default=list, nullable=False
)
last_error: Mapped[str | None] = mapped_column(String(2000), nullable=True)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
__all__ = [
"FormInstanceEvent",
"FormHandoffEffect",
"FormInstanceIdentity",
"FormInstanceRevision",
]
+86 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Mapping
from typing import Literal, Mapping
from govoplan_core.core.institutional import (
EvidenceReference,
@@ -25,6 +25,84 @@ FORM_INSTANCE_STATUSES = frozenset(
"archived",
}
)
FormHandoffState = Literal[
"requested",
"accepted",
"rejected",
"outcome_unknown",
"reconciled",
"compensated",
]
FORM_HANDOFF_STATES = frozenset(
{
"requested",
"accepted",
"rejected",
"outcome_unknown",
"reconciled",
"compensated",
}
)
@dataclass(frozen=True, slots=True)
class FormHandoff:
tenant_id: str
instance_id: str
effect_id: str
instance_revision: int
idempotency_key: str
provider_key: str
request_sha256: str
binding_kind: str
binding_reference: str
provider_capability: str
state: FormHandoffState
attempt_count: int
requested_at: datetime
resolved_at: datetime | None = None
target_ref: InstitutionalReference | None = None
href: str | None = None
evidence: tuple[EvidenceReference, ...] = ()
last_error: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.state not in FORM_HANDOFF_STATES:
raise InstitutionalContextError(
f"Unsupported Form handoff state: {self.state!r}."
)
if self.attempt_count < 0:
raise InstitutionalContextError(
"Form handoff attempt count cannot be negative."
)
if self.target_ref is not None and self.target_ref.tenant_id != self.tenant_id:
raise InstitutionalContextError("Form handoff target cannot cross tenants.")
if any(item.tenant_id != self.tenant_id for item in self.evidence):
raise InstitutionalContextError(
"Form handoff evidence cannot cross tenants."
)
def to_dict(self) -> dict[str, object]:
return {
"effect_id": self.effect_id,
"instance_id": self.instance_id,
"instance_revision": self.instance_revision,
"binding_kind": self.binding_kind,
"binding_reference": self.binding_reference,
"provider_capability": self.provider_capability,
"state": self.state,
"attempt_count": self.attempt_count,
"requested_at": self.requested_at.isoformat(),
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"target_ref": self.target_ref.to_dict() if self.target_ref else None,
"href": self.href,
"evidence": [
item.to_dict(include_inspection=False) for item in self.evidence
],
"last_error": self.last_error,
"metadata": dict(self.metadata),
}
@dataclass(frozen=True, slots=True)
@@ -139,4 +217,10 @@ class FormInstance:
}
__all__ = ["FORM_INSTANCE_STATUSES", "FormInstance"]
__all__ = [
"FORM_HANDOFF_STATES",
"FORM_INSTANCE_STATUSES",
"FormHandoff",
"FormHandoffState",
"FormInstance",
]
@@ -0,0 +1,782 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
import hashlib
import uuid
from sqlalchemy.orm import Session
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.institutional import (
CAPABILITY_SERVICE_DEFINITIONS,
InstitutionalContextError,
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceDefinitionProvider,
ServiceLaunchRequest,
ServiceLaunchResult,
ServiceLauncher,
service_launch_capability,
)
from govoplan_forms_runtime.backend.db.models import (
FormHandoffEffect,
FormInstanceEvent,
)
from govoplan_forms_runtime.backend.domain import FormHandoff, FormHandoffState
from govoplan_forms_runtime.backend.service import (
FormRuntimeError,
FormRuntimeService,
_aware,
_capability,
_current_instance,
_principal_actor,
_principal_tenant,
_request_hash,
_text,
)
_HANDOFF_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://govoplan.add-ideas.de/contracts/forms-runtime/handoff/v1",
)
_NATIVE_HANDOFF_KINDS = frozenset({"case", "workflow"})
_TERMINAL_STATES = frozenset({"accepted", "reconciled", "compensated"})
class FormHandoffService:
def __init__(self, registry: object | None) -> None:
self._registry = registry
self._runtime = FormRuntimeService(registry)
def prepare(
self,
session: Session,
principal: object,
*,
instance_id: str,
expected_revision: int,
binding_kind: str,
binding_reference: str | None,
idempotency_key: str,
requested_at: datetime,
allow_all: bool,
) -> FormHandoff:
tenant_id = _principal_tenant(principal)
actor_id = _principal_actor(principal)
clean_kind = _text(binding_kind, "Form handoff kind", 30)
if clean_kind not in _NATIVE_HANDOFF_KINDS:
raise FormRuntimeError(
"Native Form handoffs currently support Case and Workflow targets."
)
clean_key = _text(idempotency_key, "Form handoff idempotency key", 255)
if requested_at.tzinfo is None or requested_at.utcoffset() is None:
raise FormRuntimeError("Form handoff requested_at must include a timezone.")
replay = (
session.query(FormHandoffEffect)
.filter(
FormHandoffEffect.tenant_id == tenant_id,
FormHandoffEffect.idempotency_key == clean_key,
)
.one_or_none()
)
if replay is not None:
if (
replay.instance_id != instance_id
or replay.instance_revision != expected_revision
or replay.binding_kind != clean_kind
or _aware(replay.requested_at) != requested_at
or (
binding_reference is not None
and replay.binding_reference != binding_reference
)
or str(replay.details.get("requested_by") or "") != actor_id
):
raise FormRuntimeError(
"Form handoff idempotency conflict: this key was used for another request."
)
return _handoff_from_row(replay)
current, _ = _current_instance(
session,
principal,
instance_id=instance_id,
lock=True,
allow_all=allow_all,
)
if current.revision != expected_revision:
raise FormRuntimeError(
"Form instance revision conflict: the expected revision is stale."
)
if current.status not in {"submitted", "validated", "needs_review", "accepted"}:
raise FormRuntimeError(
f"Form status {current.status!r} does not permit a native handoff."
)
definition = self._runtime._definition(
session,
principal,
reference=current.definition_ref,
effective_at=requested_at,
)
if clean_kind not in definition.handoff_kinds:
raise FormRuntimeError(
f"Form definition does not permit a {clean_kind!r} handoff."
)
service, binding, provider_capability = self._resolve_target(
session,
principal,
current=current,
binding_kind=clean_kind,
binding_reference=binding_reference,
effective_at=requested_at,
)
self._runtime._evaluate_policy(
session,
principal,
definition=definition,
action=f"handoff:{clean_kind}:request",
instance=current,
)
self._launcher(provider_capability)
request = {
"instance_id": current.instance_id,
"instance_revision": current.revision,
"definition_ref": current.definition_ref.to_dict(),
"service_ref": service.reference.to_dict(),
"binding": binding.to_dict(),
"actor_id": actor_id,
"requested_at": requested_at.isoformat(),
}
request_sha256 = _request_hash(request)
effect_id = str(uuid.uuid4())
provider_key = str(
uuid.uuid5(
_HANDOFF_NAMESPACE,
":".join((tenant_id, current.instance_id, clean_key)),
)
)
row = FormHandoffEffect(
tenant_id=tenant_id,
instance_id=current.instance_id,
effect_id=effect_id,
instance_revision=current.revision,
idempotency_key=clean_key,
provider_key=provider_key,
request_sha256=request_sha256,
binding_kind=clean_kind,
binding_reference=binding.reference,
provider_capability=provider_capability,
state="requested",
attempt_count=0,
requested_at=requested_at,
evidence=[],
details={
"requested_by": actor_id,
"definition_ref": current.definition_ref.to_dict(),
"service_ref": service.reference.to_dict(),
"binding": binding.to_dict(),
},
)
session.add(row)
session.flush()
_record_transition(
session,
principal,
row=row,
instance_status=current.status,
state="requested",
occurred_at=requested_at,
)
return _handoff_from_row(row)
def execute(
self,
session: Session,
principal: object,
*,
effect_id: str,
executed_at: datetime,
allow_all: bool,
reconcile: bool = False,
) -> tuple[FormHandoff, object | None]:
row = self._effect_row(
session,
principal,
effect_id=effect_id,
lock=True,
allow_all=allow_all,
)
if row.state in _TERMINAL_STATES:
return _handoff_from_row(row), None
if reconcile:
if row.state != "outcome_unknown":
raise FormRuntimeError(
"Only an outcome-unknown Form handoff can be reconciled."
)
elif row.state != "requested":
if row.state == "outcome_unknown":
raise FormRuntimeError(
"Outcome-unknown Form handoffs must be reconciled with the same provider key."
)
raise FormRuntimeError(
f"Form handoff state {row.state!r} cannot be executed."
)
current, _ = _current_instance(
session,
principal,
instance_id=row.instance_id,
lock=True,
allow_all=allow_all,
)
if current.revision != row.instance_revision:
return self._fail_before_effect(
session,
principal,
row=row,
instance_status=current.status,
state="rejected",
occurred_at=executed_at,
message="The Form instance changed after this handoff was requested.",
), None
definition = self._runtime._definition(
session,
principal,
reference=current.definition_ref,
effective_at=executed_at,
)
try:
self._runtime._evaluate_policy(
session,
principal,
definition=definition,
action=f"handoff:{row.binding_kind}:confirm",
instance=current,
)
service, binding, provider_capability = self._resolve_target(
session,
principal,
current=current,
binding_kind=row.binding_kind,
binding_reference=row.binding_reference,
effective_at=executed_at,
)
if provider_capability != row.provider_capability:
raise FormRuntimeError(
"The configured Form handoff provider changed after request."
)
launcher = self._launcher(row.provider_capability)
except (
FormRuntimeError,
InstitutionalContextError,
PermissionError,
LookupError,
) as exc:
return self._fail_before_effect(
session,
principal,
row=row,
instance_status=current.status,
state="rejected",
occurred_at=executed_at,
message=str(exc),
), None
row.attempt_count += 1
launch_request = ServiceLaunchRequest(
service_ref=service.reference,
binding=binding,
idempotency_key=row.provider_key,
requested_at=_aware(row.requested_at),
parameters=_handoff_parameters(current),
)
try:
with session.begin_nested():
result = launcher.launch_service(
session,
principal,
definition=service,
request=launch_request,
)
_validate_result(
result,
tenant_id=row.tenant_id,
service=service,
binding=binding,
)
except (
InstitutionalContextError,
FormRuntimeError,
PermissionError,
LookupError,
ValueError,
) as exc:
row.state = "rejected"
row.last_error = _bounded_error(exc)
row.resolved_at = executed_at
_record_transition(
session,
principal,
row=row,
instance_status=current.status,
state="rejected",
occurred_at=executed_at,
)
return _handoff_from_row(row), None
except Exception as exc:
# The provider may have accepted the effect before connectivity was
# lost. Retain the exact provider key and require reconciliation.
row.state = "outcome_unknown"
row.last_error = _bounded_error(exc)
row.resolved_at = executed_at
_record_transition(
session,
principal,
row=row,
instance_status=current.status,
state="outcome_unknown",
occurred_at=executed_at,
)
return _handoff_from_row(row), None
target_refs = current.handoff_refs
if result.target_ref is not None:
target_refs = tuple(dict.fromkeys((*target_refs, result.target_ref)))
final_state: FormHandoffState = "reconciled" if reconcile else "accepted"
revised = self._runtime._revise(
session,
principal,
current=current,
identity=_current_instance(
session,
principal,
instance_id=row.instance_id,
lock=True,
allow_all=allow_all,
)[1],
expected_revision=current.revision,
status="handed_off",
values=current.values,
attachment_refs=current.attachment_refs,
signature_refs=current.signature_refs,
handoff_refs=target_refs,
idempotency_key=f"handoff:{row.effect_id}:{final_state}",
recorded_at=executed_at,
change_reason=(
f"Reconciled {row.binding_kind} handoff."
if reconcile
else f"Completed {row.binding_kind} handoff."
),
operation=f"handoff_{final_state}",
final_validation=True,
definition=definition,
allowed_current_statuses=(
"submitted",
"validated",
"needs_review",
"accepted",
),
)
row.state = final_state
row.resolved_at = executed_at
row.target_ref = result.target_ref.to_dict() if result.target_ref else None
row.href = result.href
row.evidence = [
item.to_dict(include_inspection=False) for item in result.evidence
]
row.last_error = None
row.details = {
**dict(row.details),
"provider_result": dict(result.metadata),
"provider_replayed": result.replayed,
}
_record_transition(
session,
principal,
row=row,
instance_status=revised.status,
state=final_state,
occurred_at=executed_at,
)
return _handoff_from_row(row), revised
def retry_rejected(
self,
session: Session,
principal: object,
*,
effect_id: str,
requested_at: datetime,
allow_all: bool,
) -> FormHandoff:
row = self._effect_row(
session,
principal,
effect_id=effect_id,
lock=True,
allow_all=allow_all,
)
if row.state != "rejected":
raise FormRuntimeError(
"Only a rejected Form handoff can be retried. Outcome-unknown effects require reconciliation."
)
current, _ = _current_instance(
session,
principal,
instance_id=row.instance_id,
lock=True,
allow_all=allow_all,
)
if current.revision != row.instance_revision:
raise FormRuntimeError(
"The Form instance changed after this handoff failed; create a new handoff request."
)
row.state = "requested"
row.last_error = None
row.resolved_at = None
row.requested_at = requested_at
_record_transition(
session,
principal,
row=row,
instance_status=current.status,
state="requested",
occurred_at=requested_at,
)
return _handoff_from_row(row)
def compensate(
self,
session: Session,
principal: object,
*,
effect_id: str,
compensated_at: datetime,
confirmed_absent: bool,
change_reason: str,
allow_all: bool,
) -> FormHandoff:
if not confirmed_absent:
raise FormRuntimeError(
"Compensation requires confirmation that no target effect exists."
)
row = self._effect_row(
session,
principal,
effect_id=effect_id,
lock=True,
allow_all=allow_all,
)
if row.state not in {"rejected", "outcome_unknown"}:
raise FormRuntimeError(
"Only rejected or outcome-unknown Form handoffs can be compensated."
)
current, _ = _current_instance(
session,
principal,
instance_id=row.instance_id,
lock=True,
allow_all=allow_all,
)
definition = self._runtime._definition(
session,
principal,
reference=current.definition_ref,
effective_at=compensated_at,
)
self._runtime._evaluate_policy(
session,
principal,
definition=definition,
action=f"handoff:{row.binding_kind}:compensate",
instance=current,
)
row.state = "compensated"
row.resolved_at = compensated_at
row.last_error = None
row.details = {
**dict(row.details),
"compensation": {
"confirmed_absent": True,
"change_reason": _text(change_reason, "Compensation reason", 1000),
"actor_id": _principal_actor(principal),
},
}
_record_transition(
session,
principal,
row=row,
instance_status=current.status,
state="compensated",
occurred_at=compensated_at,
)
return _handoff_from_row(row)
def list(
self,
session: Session,
principal: object,
*,
instance_id: str,
allow_all: bool,
) -> tuple[FormHandoff, ...]:
self._runtime.get_instance(
session,
principal,
instance_id=instance_id,
allow_all=allow_all,
)
rows = (
session.query(FormHandoffEffect)
.filter(
FormHandoffEffect.tenant_id == _principal_tenant(principal),
FormHandoffEffect.instance_id == instance_id,
)
.order_by(FormHandoffEffect.requested_at.asc())
.all()
)
return tuple(_handoff_from_row(row) for row in rows)
def _resolve_target(
self,
session: Session,
principal: object,
*,
current: object,
binding_kind: str,
binding_reference: str | None,
effective_at: datetime,
) -> tuple[ServiceDefinition, ServiceBinding, str]:
service_ref = getattr(current, "service_ref", None)
if not isinstance(service_ref, InstitutionalReference):
raise FormRuntimeError(
"A native Form handoff requires an exact Service launch provenance."
)
provider = _capability(self._registry, CAPABILITY_SERVICE_DEFINITIONS)
if not isinstance(provider, ServiceDefinitionProvider):
raise FormRuntimeError(
"The configured Service definition provider is unavailable or invalid."
)
service = provider.get_service_definition(
session,
principal,
reference=service_ref,
effective_at=effective_at,
)
if service is None or service.reference != service_ref:
raise FormRuntimeError(
"The exact Service definition for this Form handoff is unavailable."
)
candidates = tuple(
item
for item in service.bindings
if item.kind == binding_kind
and (binding_reference is None or item.reference == binding_reference)
)
if len(candidates) != 1:
raise FormRuntimeError(
"Select exactly one matching Service handoff binding."
)
return service, candidates[0], service_launch_capability(binding_kind)
def _launcher(self, capability: str) -> ServiceLauncher:
launcher = _capability(self._registry, capability)
if not isinstance(launcher, ServiceLauncher):
raise FormRuntimeError(
f"The configured handoff provider is unavailable or invalid: {capability}."
)
return launcher
def _effect_row(
self,
session: Session,
principal: object,
*,
effect_id: str,
lock: bool,
allow_all: bool,
) -> FormHandoffEffect:
query = session.query(FormHandoffEffect).filter(
FormHandoffEffect.tenant_id == _principal_tenant(principal),
FormHandoffEffect.effect_id == effect_id,
)
if lock:
query = query.with_for_update()
row = query.one_or_none()
if row is None:
raise LookupError("Form handoff not found.")
instance = self._runtime.get_instance(
session,
principal,
instance_id=row.instance_id,
allow_all=allow_all,
)
if instance is None:
raise LookupError("Form instance not found.")
return row
def _fail_before_effect(
self,
session: Session,
principal: object,
*,
row: FormHandoffEffect,
instance_status: str,
state: FormHandoffState,
occurred_at: datetime,
message: str,
) -> FormHandoff:
row.state = state
row.last_error = message[:2000]
row.resolved_at = occurred_at
_record_transition(
session,
principal,
row=row,
instance_status=instance_status,
state=state,
occurred_at=occurred_at,
)
return _handoff_from_row(row)
def _handoff_parameters(instance: object) -> Mapping[str, object]:
return {
"title": f"Form submission {getattr(instance, 'instance_id')}",
"form_submission_id": getattr(instance, "instance_id"),
"form_submission_revision": getattr(instance, "revision"),
"form_definition_ref": getattr(instance, "definition_ref").to_dict(),
"form_values": dict(getattr(instance, "values")),
"attachment_refs": [
item.to_dict(include_inspection=False)
for item in getattr(instance, "attachment_refs")
],
"signature_refs": [
item.to_dict(include_inspection=False)
for item in getattr(instance, "signature_refs")
],
"actor_id": getattr(instance, "changed_by"),
"institutional_context": dict(getattr(instance, "metadata")),
}
def _validate_result(
result: ServiceLaunchResult,
*,
tenant_id: str,
service: ServiceDefinition,
binding: ServiceBinding,
) -> None:
if result.service_ref != service.reference or result.binding != binding:
raise FormRuntimeError(
"The handoff provider returned evidence for another Service binding."
)
if result.target_ref is not None and result.target_ref.tenant_id != tenant_id:
raise FormRuntimeError("The handoff provider returned a cross-tenant target.")
if any(item.tenant_id != tenant_id for item in result.evidence):
raise FormRuntimeError("The handoff provider returned cross-tenant evidence.")
def _record_transition(
session: Session,
principal: object,
*,
row: FormHandoffEffect,
instance_status: str,
state: FormHandoffState,
occurred_at: datetime,
) -> None:
event_id = str(uuid.uuid4())
event = FormInstanceEvent(
tenant_id=row.tenant_id,
instance_id=row.instance_id,
instance_revision=row.instance_revision,
event_id=event_id,
event_type=f"forms_runtime.handoff.{state}",
status=instance_status,
occurred_at=occurred_at,
actor_id=_principal_actor(principal),
idempotency_key=f"handoff:{row.effect_id}:{state}:{row.attempt_count}",
request_sha256=row.request_sha256,
payload={
"effect_id": row.effect_id,
"binding_kind": row.binding_kind,
"binding_reference": row.binding_reference,
"provider_capability": row.provider_capability,
"provider_key_sha256": hashlib.sha256(
row.provider_key.encode("utf-8")
).hexdigest(),
"state": state,
"attempt_count": row.attempt_count,
"target_ref": row.target_ref,
"href": row.href,
"last_error": row.last_error,
},
)
session.add(event)
session.flush()
emit_platform_event(
session,
PlatformEvent(
event_id=event_id,
type=event.event_type,
module_id="forms_runtime",
payload=dict(event.payload),
occurred_at=occurred_at,
actor=EventActorRef(type="account", id=_principal_actor(principal)),
tenant=EventTenantRef(id=row.tenant_id),
resource=EventObjectRef(
type="form_submission",
id=row.instance_id,
),
classification="confidential",
),
)
def _handoff_from_row(row: FormHandoffEffect) -> FormHandoff:
return FormHandoff(
tenant_id=row.tenant_id,
instance_id=row.instance_id,
effect_id=row.effect_id,
instance_revision=row.instance_revision,
idempotency_key=row.idempotency_key,
provider_key=row.provider_key,
request_sha256=row.request_sha256,
binding_kind=row.binding_kind,
binding_reference=row.binding_reference,
provider_capability=row.provider_capability,
state=row.state, # type: ignore[arg-type]
attempt_count=row.attempt_count,
requested_at=_aware(row.requested_at),
resolved_at=_aware(row.resolved_at) if row.resolved_at else None,
target_ref=(
InstitutionalReference.from_mapping(row.target_ref)
if isinstance(row.target_ref, Mapping)
else None
),
href=row.href,
evidence=tuple(_evidence_from_mapping(item) for item in (row.evidence or [])),
last_error=row.last_error,
metadata=dict(row.details),
)
def _evidence_from_mapping(value: Mapping[str, object]):
from govoplan_core.core.institutional import EvidenceReference
return EvidenceReference.from_mapping(value)
def _bounded_error(exc: Exception) -> str:
message = str(exc).strip() or type(exc).__name__
return message[:2000]
__all__ = ["FormHandoffService"]
+32 -4
View File
@@ -6,7 +6,10 @@ from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
CAPABILITY_SERVICE_DEFINITIONS,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
@@ -157,7 +160,12 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_FORM_DEFINITIONS,
),
optional_capabilities=(CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,),
optional_capabilities=(
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
CAPABILITY_SERVICE_DEFINITIONS,
"cases.service_launcher",
"workflow_engine.service_launcher",
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
route_factory=_router,
@@ -230,6 +238,24 @@ manifest = ModuleManifest(
version_min="0.1.0",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name="services.definitions",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="cases.service_launcher",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name="workflow_engine.service_launcher",
version_min="0.1.0",
version_max_exclusive="0.2.0",
optional=True,
),
),
capability_factories={
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
@@ -254,6 +280,7 @@ manifest = ModuleManifest(
migration_after=("forms",),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
runtime_models.FormHandoffEffect,
runtime_models.FormInstanceEvent,
runtime_models.FormInstanceRevision,
runtime_models.FormInstanceIdentity,
@@ -263,6 +290,7 @@ manifest = ModuleManifest(
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
runtime_models.FormHandoffEffect,
runtime_models.FormInstanceIdentity,
runtime_models.FormInstanceRevision,
runtime_models.FormInstanceEvent,
@@ -277,7 +305,7 @@ manifest = ModuleManifest(
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
body=(
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
"Service launches retain the exact Service and binding. History, receipts, and handoffs are append-only, replay-safe, and optimistic-concurrency guarded."
"Service launches retain the exact Service and binding. Native Case and Workflow handoffs persist intent before execution, use owner capabilities with stable provider keys, and require reconciliation after unknown outcomes. History, receipts, and handoffs are replay-safe and optimistic-concurrency guarded."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -298,7 +326,7 @@ manifest = ModuleManifest(
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
test_ref="tests/test_forms_runtime.py",
known_limits=(
"Anonymous public intake, concrete Files/signature adapters, and automatic target handoff execution remain adapter depth; authenticated Portal entry is supported.",
"Anonymous public intake, concrete Files/signature adapters, and target kinds beyond the native Case/Workflow handoffs remain adapter depth; authenticated Portal entry is supported.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
@@ -0,0 +1,77 @@
"""Persist governed Forms Runtime handoff effects.
Revision ID: a3d5f7b9c1e2
Revises: f2a3b4c5d6e7
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a3d5f7b9c1e2"
down_revision = "f2a3b4c5d6e7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"form_handoff_effects",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("effect_id", sa.String(length=36), nullable=False),
sa.Column("instance_revision", sa.Integer(), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("provider_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("binding_kind", sa.String(length=30), nullable=False),
sa.Column("binding_reference", sa.String(length=500), nullable=False),
sa.Column("provider_capability", sa.String(length=120), nullable=False),
sa.Column("state", sa.String(length=30), nullable=False),
sa.Column("attempt_count", sa.Integer(), nullable=False),
sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("target_ref", sa.JSON(), nullable=True),
sa.Column("href", sa.String(length=2000), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("last_error", sa.String(length=2000), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_handoff_effects")),
sa.UniqueConstraint("tenant_id", "effect_id", name="uq_form_handoff_effect"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_form_handoff_idempotency"
),
sa.UniqueConstraint(
"tenant_id", "provider_key", name="uq_form_handoff_provider_key"
),
)
for column in (
"tenant_id",
"instance_id",
"effect_id",
"binding_kind",
"state",
"requested_at",
"resolved_at",
):
op.create_index(
op.f(f"ix_form_handoff_effects_{column}"),
"form_handoff_effects",
[column],
unique=False,
)
op.create_index(
"ix_form_handoff_instance_state",
"form_handoff_effects",
["tenant_id", "instance_id", "state"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_handoff_effects")
+213 -7
View File
@@ -19,19 +19,24 @@ from govoplan_forms_runtime.backend.manifest import (
from govoplan_forms_runtime.backend.schemas import (
FormDraftUpdateRequest,
FormHandoffRequest,
FormHandoffActionRequest,
FormHandoffCompensateRequest,
FormInstanceCreateRequest,
FormInstanceEventsResponse,
FormInstanceHistoryResponse,
FormInstanceListResponse,
FormSubmitRequest,
FormTransitionRequest,
FormNativeHandoffRequest,
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
runtime = FormRuntimeService(registry)
handoffs = FormHandoffService(registry)
@router.get("/instances", response_model=FormInstanceListResponse)
def api_list_instances(
@@ -162,7 +167,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -192,7 +202,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -221,7 +236,12 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@@ -250,11 +270,199 @@ def create_router(registry: object | None) -> APIRouter:
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get(
"/instances/{instance_id}/handoffs",
response_model=dict[str, object],
)
def api_list_handoffs(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
items = handoffs.list(
session,
principal,
instance_id=instance_id,
allow_all=has_scope(principal, READ_SCOPE),
)
except (FormRuntimeError, LookupError, PermissionError) as exc:
raise _error(exc) from exc
return {"handoffs": [item.to_dict() for item in items]}
@router.post(
"/instances/{instance_id}/handoffs/native",
response_model=dict[str, object],
)
def api_native_handoff(
instance_id: str,
payload: FormNativeHandoffRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
prepared = handoffs.prepare(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
binding_kind=payload.binding_kind,
binding_reference=payload.binding_reference,
idempotency_key=payload.idempotency_key,
requested_at=payload.requested_at,
allow_all=True,
)
# The intent is durable before owner capability execution starts.
session.commit()
item, instance = handoffs.execute(
session,
principal,
effect_id=prepared.effect_id,
executed_at=payload.requested_at,
allow_all=True,
)
session.commit()
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return {
"handoff": item.to_dict(),
"instance": instance.to_dict() if instance is not None else None,
}
@router.post(
"/instances/{instance_id}/handoffs/{effect_id}/retry",
response_model=dict[str, object],
)
def api_retry_handoff(
instance_id: str,
effect_id: str,
payload: FormHandoffActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
prepared = handoffs.retry_rejected(
session,
principal,
effect_id=effect_id,
requested_at=payload.recorded_at,
allow_all=True,
)
if prepared.instance_id != instance_id:
raise LookupError("Form handoff does not belong to this instance.")
session.commit()
item, instance = handoffs.execute(
session,
principal,
effect_id=effect_id,
executed_at=payload.recorded_at,
allow_all=True,
)
session.commit()
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return {
"handoff": item.to_dict(),
"instance": instance.to_dict() if instance is not None else None,
}
@router.post(
"/instances/{instance_id}/handoffs/{effect_id}/reconcile",
response_model=dict[str, object],
)
def api_reconcile_handoff(
instance_id: str,
effect_id: str,
payload: FormHandoffActionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item, instance = handoffs.execute(
session,
principal,
effect_id=effect_id,
executed_at=payload.recorded_at,
allow_all=True,
reconcile=True,
)
if item.instance_id != instance_id:
raise LookupError("Form handoff does not belong to this instance.")
session.commit()
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return {
"handoff": item.to_dict(),
"instance": instance.to_dict() if instance is not None else None,
}
@router.post(
"/instances/{instance_id}/handoffs/{effect_id}/compensate",
response_model=dict[str, object],
)
def api_compensate_handoff(
instance_id: str,
effect_id: str,
payload: FormHandoffCompensateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ADMIN_SCOPE)
try:
item = handoffs.compensate(
session,
principal,
effect_id=effect_id,
compensated_at=payload.recorded_at,
confirmed_absent=payload.confirmed_absent,
change_reason=payload.change_reason,
allow_all=True,
)
if item.instance_id != instance_id:
raise LookupError("Form handoff does not belong to this instance.")
session.commit()
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return {"handoff": item.to_dict()}
@router.get(
"/instances/{instance_id}/history",
response_model=FormInstanceHistoryResponse,
@@ -278,9 +486,7 @@ def create_router(registry: object | None) -> APIRouter:
raise _error(exc) from exc
if not items:
raise HTTPException(status_code=404, detail="Form instance not found")
return FormInstanceHistoryResponse(
revisions=[item.to_dict() for item in items]
)
return FormInstanceHistoryResponse(revisions=[item.to_dict() for item in items])
@router.get(
"/instances/{instance_id}/events",
@@ -68,6 +68,27 @@ class FormHandoffRequest(BaseModel):
change_reason: str = Field(min_length=1, max_length=1000)
class FormNativeHandoffRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
binding_kind: Literal["case", "workflow"]
binding_reference: str | None = Field(default=None, min_length=1, max_length=500)
idempotency_key: str = Field(min_length=1, max_length=255)
requested_at: datetime
class FormHandoffActionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
recorded_at: datetime
class FormHandoffCompensateRequest(FormHandoffActionRequest):
confirmed_absent: bool
change_reason: str = Field(min_length=1, max_length=1000)
class FormInstanceListResponse(BaseModel):
instances: list[dict[str, Any]]
total: int
@@ -86,6 +107,9 @@ class FormInstanceEventsResponse(BaseModel):
__all__ = [
"FormDraftUpdateRequest",
"FormHandoffRequest",
"FormHandoffActionRequest",
"FormHandoffCompensateRequest",
"FormNativeHandoffRequest",
"FormInstanceCreateRequest",
"FormInstanceEventsResponse",
"FormInstanceHistoryResponse",
+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",
]
+283 -13
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
@@ -9,17 +9,24 @@ from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
CAPABILITY_SERVICE_DEFINITIONS,
FormConditionExpression,
FormDefinition,
FormFieldDefinition,
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
TemporalRevision,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import SqlFormDefinitionProvider, record_form_definition
from govoplan_forms.backend.service import (
SqlFormDefinitionProvider,
record_form_definition,
)
from govoplan_forms_runtime.backend.db.models import (
FormHandoffEffect,
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
@@ -29,6 +36,7 @@ from govoplan_forms_runtime.backend.service import (
FormRuntimeService,
FormsServiceLauncher,
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@@ -41,16 +49,17 @@ class Principal:
class Registry:
def __init__(self, provider: object) -> None:
self.provider = provider
def __init__(self, provider: object, **capabilities: object) -> None:
self.capabilities = {
CAPABILITY_FORM_DEFINITIONS: provider,
**capabilities,
}
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_FORM_DEFINITIONS
return name in self.capabilities
def require_capability(self, name: str) -> object:
if name != CAPABILITY_FORM_DEFINITIONS:
raise KeyError(name)
return self.provider
return self.capabilities[name]
def form_definition(
@@ -71,9 +80,7 @@ def form_definition(
temporal=TemporalRevision(
revision=revision,
recorded_at=NOW + timedelta(minutes=int(revision) - 2),
change_reason=(
"Initial schema." if revision == "1" else "Revise schema."
),
change_reason=("Initial schema." if revision == "1" else "Revise schema."),
),
title="Permit form",
fields=(
@@ -105,6 +112,7 @@ class FormsRuntimeTests(unittest.TestCase):
FormInstanceIdentity.__table__,
FormInstanceRevision.__table__,
FormInstanceEvent.__table__,
FormHandoffEffect.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
@@ -316,12 +324,16 @@ class FormsRuntimeTests(unittest.TestCase):
request=request,
)
self.assertEqual("form_submission", first.target_ref.kind if first.target_ref else None)
self.assertEqual(
"form_submission", first.target_ref.kind if first.target_ref else None
)
self.assertEqual("1", first.metadata["form_definition_revision"])
self.assertTrue(replay.replayed)
self.assertEqual(first.target_ref.object_id, replay.target_ref.object_id)
def test_create_replay_is_actor_bound_and_survives_schema_supersession(self) -> None:
def test_create_replay_is_actor_bound_and_survives_schema_supersession(
self,
) -> None:
first = self.runtime.create_instance(
self.session,
self.principal,
@@ -413,6 +425,264 @@ class FormsRuntimeTests(unittest.TestCase):
instance_id="fixed-instance",
)
def test_hidden_required_fields_are_not_required_or_persisted(self) -> None:
conditional = replace(
form_definition(form_id="conditional-form"),
fields=(
FormFieldDefinition(
key="include_details",
label="Include details",
value_type="boolean",
),
FormFieldDefinition(
key="details",
label="Details",
required=True,
visibility_condition=FormConditionExpression(
kind="predicate",
field_key="include_details",
operator="eq",
value=True,
),
),
),
)
stored = record_form_definition(
self.session,
self.principal,
definition=conditional,
)
hidden = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=stored.reference,
values={"include_details": False, "details": "stale secret"},
idempotency_key="conditional-hidden",
recorded_at=NOW,
)
self.assertNotIn("details", hidden.values)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=hidden.instance_id,
expected_revision=1,
values=hidden.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="conditional-submit",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertEqual("submitted", submitted.status)
shown = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=stored.reference,
values={"include_details": True},
idempotency_key="conditional-shown",
recorded_at=NOW,
)
with self.assertRaisesRegex(FormRuntimeError, "required"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=shown.instance_id,
expected_revision=1,
values=shown.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="conditional-shown-submit",
recorded_at=NOW + timedelta(minutes=1),
)
def test_native_handoff_is_durable_replay_safe_and_reconciles_unknown(self) -> None:
form_binding = ServiceBinding(kind="form", reference="permit-form/1")
case_binding = ServiceBinding(kind="case", reference="permit-case")
service = ServiceDefinition(
reference=InstitutionalReference(
kind="service",
owner_module="services",
object_id="permit-service",
tenant_id="tenant-1",
version="4",
),
key="permit-service",
temporal=TemporalRevision(
revision="4",
recorded_at=NOW - timedelta(minutes=2),
change_reason="Publish intake targets.",
),
title="Apply for permit",
audience=("resident",),
bindings=(form_binding, case_binding),
publication_state="published",
)
class Services:
def get_service_definition(
self, session, principal, *, reference, effective_at=None
):
return service if reference == service.reference else None
def list_service_definitions(
self, session, principal, *, tenant_id, query="", limit=100
):
return (service,)
class Cases:
fail_unknown = False
def launch_service(self, session, principal, *, definition, request):
if self.fail_unknown:
raise ConnectionError("Provider response was lost.")
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=InstitutionalReference(
kind="case",
owner_module="cases",
object_id="case-1",
tenant_id="tenant-1",
version="1",
),
href="/cases/case-1",
replayed=request.idempotency_key.startswith("known"),
metadata={"case_number": "CASE-1"},
)
cases = Cases()
registry = Registry(
SqlFormDefinitionProvider(),
**{
CAPABILITY_SERVICE_DEFINITIONS: Services(),
"cases.service_launcher": cases,
},
)
runtime = FormRuntimeService(registry)
draft = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="native-create",
recorded_at=NOW,
service_ref=service.reference,
service_binding=form_binding,
)
submitted = runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values=draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="native-submit",
recorded_at=NOW + timedelta(minutes=1),
)
handoffs = FormHandoffService(registry)
prepared = handoffs.prepare(
self.session,
self.principal,
instance_id=submitted.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference="permit-case",
idempotency_key="native-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.session.commit()
self.assertEqual("requested", prepared.state)
self.assertEqual(
"requested",
self.session.query(FormHandoffEffect).one().state,
)
accepted, revised = handoffs.execute(
self.session,
self.principal,
effect_id=prepared.effect_id,
executed_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("accepted", accepted.state)
self.assertEqual("handed_off", revised.status)
replay = handoffs.prepare(
self.session,
self.principal,
instance_id=submitted.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference="permit-case",
idempotency_key="native-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("accepted", replay.state)
second = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Grace"},
idempotency_key="unknown-create",
recorded_at=NOW,
service_ref=service.reference,
service_binding=form_binding,
)
second = runtime.submit_instance(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=1,
values=second.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="unknown-submit",
recorded_at=NOW + timedelta(minutes=1),
)
unknown = handoffs.prepare(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference=None,
idempotency_key="unknown-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.session.commit()
cases.fail_unknown = True
unknown, _ = handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("outcome_unknown", unknown.state)
with self.assertRaisesRegex(FormRuntimeError, "must be reconciled"):
handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=3),
allow_all=True,
)
cases.fail_unknown = False
reconciled, revised = handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=4),
allow_all=True,
reconcile=True,
)
self.assertEqual("reconciled", reconciled.state)
self.assertEqual("handed_off", revised.status)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -14,7 +14,9 @@ from govoplan_forms_runtime.backend.manifest import get_manifest as get_runtime_
class FormsRuntimeMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_runtime_store_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-forms-runtime-migration-") as directory:
with tempfile.TemporaryDirectory(
prefix="govoplan-forms-runtime-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'forms-runtime.db'}"
migrate_database(
database_url=url,
@@ -29,11 +31,12 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
"form_instance_identities",
"form_instance_revisions",
"form_instance_events",
"form_handoff_effects",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"f2a3b4c5d6e7",
"a3d5f7b9c1e2",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+101
View File
@@ -19,6 +19,10 @@ export type EvidenceReference = {
version?: string | null;
};
export type FormCondition =
| { kind: "predicate"; field_key: string; operator: string; value?: unknown }
| { kind: "all" | "any" | "not"; conditions: FormCondition[] };
export type FormFieldDefinition = {
key: string;
label: string;
@@ -28,6 +32,7 @@ export type FormFieldDefinition = {
options: string[];
constraints: Record<string, unknown>;
default_value?: unknown;
visibility_condition?: FormCondition | null;
};
export type FormDefinition = {
@@ -43,6 +48,30 @@ export type FormDefinition = {
signature_requirement: "none" | "optional" | "required";
policy_refs: string[];
handoff_kinds: string[];
pages?: Array<{
key: string;
title: string;
description?: string | null;
visibility_condition?: FormCondition | null;
sections: Array<{
key: string;
title: string;
description?: string | null;
field_keys: string[];
visibility_condition?: FormCondition | null;
}>;
}>;
fallback_locale?: string | null;
localizations?: Array<{
locale: string;
title?: string | null;
description?: string | null;
field_labels: Record<string, string>;
field_help_texts: Record<string, string>;
option_labels: Record<string, Record<string, string>>;
page_titles: Record<string, string>;
section_titles: Record<string, string>;
}>;
};
export type ValidationResult = {
@@ -83,6 +112,24 @@ export type FormInstanceEvent = {
payload: Record<string, unknown>;
};
export type FormHandoff = {
effect_id: string;
instance_id: string;
instance_revision: number;
binding_kind: "case" | "workflow";
binding_reference: string;
provider_capability: string;
state: "requested" | "accepted" | "rejected" | "outcome_unknown" | "reconciled" | "compensated";
attempt_count: number;
requested_at: string;
resolved_at?: string | null;
target_ref?: InstitutionalReference | null;
href?: string | null;
evidence: EvidenceReference[];
last_error?: string | null;
metadata: Record<string, unknown>;
};
export function listFormInstances(
settings: ApiSettings,
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
@@ -169,3 +216,57 @@ export function submitFormInstance(
})
});
}
export function listFormHandoffs(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ handoffs: FormHandoff[] }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs`, { signal });
}
export function startFormHandoff(
settings: ApiSettings,
instance: FormInstance,
bindingKind: "case" | "workflow",
bindingReference?: string
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/handoffs/native`, {
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
binding_kind: bindingKind,
binding_reference: bindingReference?.trim() || null,
idempotency_key: crypto.randomUUID(),
requested_at: new Date().toISOString()
})
});
}
export function actOnFormHandoff(
settings: ApiSettings,
instanceId: string,
effectId: string,
action: "retry" | "reconcile"
): Promise<{ handoff: FormHandoff; instance?: FormInstance | null }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/${action}`, {
method: "POST",
body: JSON.stringify({ recorded_at: new Date().toISOString() })
});
}
export function compensateFormHandoff(
settings: ApiSettings,
instanceId: string,
effectId: string,
changeReason: string
): Promise<{ handoff: FormHandoff }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/handoffs/${encodeURIComponent(effectId)}/compensate`, {
method: "POST",
body: JSON.stringify({
recorded_at: new Date().toISOString(),
confirmed_absent: true,
change_reason: changeReason
})
});
}
+242 -28
View File
@@ -1,13 +1,15 @@
import { ArrowLeft, Save, Send } from "lucide-react";
import { ArrowLeft, ExternalLink, RefreshCw, Save, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
ConfirmDialog,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
hasScope,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
@@ -16,43 +18,55 @@ import {
getFormInstance,
getFormInstanceEvents,
getFormInstanceHistory,
listFormHandoffs,
startFormHandoff,
actOnFormHandoff,
compensateFormHandoff,
saveFormDraft,
submitFormInstance,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
type FormInstanceEvent,
type FormHandoff,
type ValidationResult
} from "../../api/formsRuntime";
export default function FormInstancePage({ settings }: PlatformRouteContext) {
export default function FormInstancePage({ settings, auth }: PlatformRouteContext) {
const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate();
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]);
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
const [handoffs, setHandoffs] = useState<FormHandoff[]>([]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [handoffBusy, setHandoffBusy] = useState(false);
const [handoffKind, setHandoffKind] = useState<"case" | "workflow">("case");
const [handoffBinding, setHandoffBinding] = useState("");
const [compensating, setCompensating] = useState<FormHandoff | null>(null);
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const nextInstance = await getFormInstance(settings, instanceId, signal);
const [nextDefinition, nextHistory, nextEvents] = await Promise.all([
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
getFormDefinition(settings, instanceId, signal),
getFormInstanceHistory(settings, instanceId, signal),
getFormInstanceEvents(settings, instanceId, signal)
getFormInstanceEvents(settings, instanceId, signal),
listFormHandoffs(settings, instanceId, signal)
]);
setInstance(nextInstance);
setDefinition(nextDefinition);
setHistory(nextHistory.revisions);
setEvents(nextEvents.events);
setHandoffs(nextHandoffs.handoffs);
setValues(nextInstance.values);
setChangeReason("");
} finally {
@@ -70,6 +84,13 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
return () => controller.abort();
}, [load]);
useEffect(() => {
if (!definition) return;
if (!definition.handoff_kinds.includes(handoffKind)) {
setHandoffKind(definition.handoff_kinds.includes("case") ? "case" : "workflow");
}
}, [definition, handoffKind]);
const editable = instance?.status === "started" || instance?.status === "draft";
const canSave = instance?.status === "draft" && definition?.allow_drafts;
const changed = useMemo(
@@ -84,6 +105,16 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
}
return grouped;
}, [instance]);
const localized = useMemo(
() => localizeDefinition(definition),
[definition]
);
const groups = useMemo(
() => definition ? visibleGroups(definition, values) : [],
[definition, values]
);
const mayHandoff = Boolean(instance && ["submitted", "validated", "needs_review", "accepted"].includes(instance.status) && instance.service_ref);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
async function save() {
if (!instance || !canSave || !changed || !changeReason.trim()) return;
@@ -113,6 +144,48 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
}
}
async function startHandoff() {
if (!instance || !mayHandoff) return;
setHandoffBusy(true);
setError("");
try {
await startFormHandoff(settings, instance, handoffKind, handoffBinding);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The handoff could not be started.");
} finally {
setHandoffBusy(false);
}
}
async function handoffAction(item: FormHandoff, action: "retry" | "reconcile") {
setHandoffBusy(true);
setError("");
try {
await actOnFormHandoff(settings, item.instance_id, item.effect_id, action);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The handoff could not be updated.");
} finally {
setHandoffBusy(false);
}
}
async function compensate() {
if (!compensating) return;
setHandoffBusy(true);
setError("");
try {
await compensateFormHandoff(settings, compensating.instance_id, compensating.effect_id, "Operator confirmed that no target effect exists.");
setCompensating(null);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The handoff could not be compensated.");
} finally {
setHandoffBusy(false);
}
}
return (
<main className="forms-runtime-page">
<div className="form-instance-shell">
@@ -121,7 +194,7 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
<ArrowLeft size={16} aria-hidden="true" />
Forms
</Button>
{definition && <strong>{definition.title}</strong>}
{definition && <strong>{localized.title}</strong>}
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
</div>
<PageScrollViewport className="form-instance-viewport">
@@ -135,26 +208,37 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
<div className="form-instance-content">
<section className="form-instance-main">
<header>
<h1>{definition.title}</h1>
{definition.description && <p>{definition.description}</p>}
<h1>{localized.title}</h1>
{localized.description && <p>{localized.description}</p>}
</header>
<div className="form-fields">
{definition.fields.map((field) =>
<FormField
key={field.key}
field={field}
value={values[field.key]}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
/>
)}
</div>
{groups.map((group) =>
<section className="form-runtime-section" key={`${group.pageKey}:${group.sectionKey}`}>
{(groups.length > 1 || definition.pages?.length) && <header><h2>{localized.sectionTitles[group.sectionKey] ?? group.sectionTitle}</h2>{group.description && <p>{group.description}</p>}</header>}
<div className="form-fields">
{group.fields.map((field) =>
<FormField
key={field.key}
field={{
...field,
label: localized.fieldLabels[field.key] ?? field.label,
help_text: localized.fieldHelpTexts[field.key] ?? field.help_text,
options: field.options
}}
optionLabels={localized.optionLabels[field.key] ?? {}}
value={values[field.key]}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
/>
)}
</div>
</section>
)}
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
<div className="form-evidence-summary">
<span>{instance.attachment_refs.length} attachments</span>
@@ -193,6 +277,39 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
<code>{instance.receipt_id}</code>
</div>
}
{!editable && definition.handoff_kinds.length > 0 &&
<section className="form-handoffs">
<div className="form-handoff-heading">
<h2>Case and workflow handoffs</h2>
{mayHandoff &&
<div className="form-handoff-create">
<select value={handoffKind} disabled={handoffBusy} onChange={(event) => setHandoffKind(event.target.value as "case" | "workflow")}>
{definition.handoff_kinds.includes("case") && <option value="case">Create Case</option>}
{definition.handoff_kinds.includes("workflow") && <option value="workflow">Start Workflow</option>}
</select>
<input value={handoffBinding} disabled={handoffBusy} onChange={(event) => setHandoffBinding(event.target.value)} placeholder="Target binding (optional)" aria-label="Exact target binding" />
<Button variant="primary" disabled={handoffBusy} onClick={() => void startHandoff()}><Send size={15} aria-hidden="true" />Start</Button>
</div>
}
</div>
{handoffs.length === 0 && <p className="form-handoff-empty">No handoff has been requested.</p>}
<div className="form-handoff-list">
{handoffs.map((item) =>
<div className="form-handoff-row" key={item.effect_id}>
<span><strong>{humanize(item.binding_kind)}</strong><small>{item.binding_reference}</small></span>
<StatusBadge status={item.state === "accepted" || item.state === "reconciled" ? "active" : "inactive"} label={humanize(item.state)} />
{item.last_error && <span className="form-handoff-error">{item.last_error}</span>}
<span className="form-handoff-actions">
{item.href && <Button onClick={() => navigate(item.href!)}><ExternalLink size={15} aria-hidden="true" />Open</Button>}
{item.state === "rejected" && <Button disabled={handoffBusy} onClick={() => void handoffAction(item, "retry")}><RefreshCw size={15} aria-hidden="true" />Retry</Button>}
{item.state === "outcome_unknown" && <Button disabled={handoffBusy} onClick={() => void handoffAction(item, "reconcile")}><RefreshCw size={15} aria-hidden="true" />Reconcile</Button>}
{canAdmin && (item.state === "rejected" || item.state === "outcome_unknown") && <Button variant="danger" disabled={handoffBusy} onClick={() => setCompensating(item)}>Compensate</Button>}
</span>
</div>
)}
</div>
</section>
}
</section>
<aside className="form-instance-aside">
<section>
@@ -224,6 +341,16 @@ export default function FormInstancePage({ settings }: PlatformRouteContext) {
}
</PageScrollViewport>
</div>
<ConfirmDialog
open={Boolean(compensating)}
title="Compensate handoff"
message="Confirm only after verifying that no Case or Workflow target exists. This records an administrative recovery decision; it does not delete a remote target."
confirmLabel="Confirm absent and compensate"
tone="danger"
busy={handoffBusy}
onCancel={() => setCompensating(null)}
onConfirm={() => void compensate()}
/>
</main>
);
}
@@ -233,12 +360,14 @@ function FormField({
value,
disabled,
diagnostics,
optionLabels,
onChange
}: {
field: FormFieldDefinition;
value: unknown;
disabled: boolean;
diagnostics: ValidationResult[];
optionLabels: Record<string, string>;
onChange: (value: unknown) => void;
}) {
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
@@ -260,7 +389,7 @@ function FormField({
<label className="form-field">
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
{field.help_text && <small>{field.help_text}</small>}
{renderInput(field, value, disabled, describedBy, onChange)}
{renderInput(field, value, disabled, describedBy, onChange, optionLabels)}
<FieldMessages id={describedBy} diagnostics={diagnostics} />
</label>
);
@@ -271,7 +400,8 @@ function renderInput(
value: unknown,
disabled: boolean,
describedBy: string | undefined,
onChange: (value: unknown) => void
onChange: (value: unknown) => void,
optionLabels: Record<string, string>
) {
const common = { disabled, required: field.required, "aria-describedby": describedBy };
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
@@ -288,7 +418,7 @@ function renderInput(
return (
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
<option value="">Select</option>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
</select>
);
}
@@ -300,7 +430,7 @@ function renderInput(
multiple
value={selected}
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
{field.options.map((option) => <option key={option} value={option}>{optionLabels[option] ?? option}</option>)}
</select>
);
}
@@ -375,6 +505,90 @@ function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function visibleGroups(definition: FormDefinition, values: Record<string, unknown>) {
const fields = new Map(definition.fields.map((field) => [field.key, field]));
const fieldVisible = (field: FormFieldDefinition) => !field.visibility_condition || evaluateCondition(field.visibility_condition, values);
if (!definition.pages?.length) {
return [{
pageKey: "page",
sectionKey: "section",
sectionTitle: definition.title,
description: definition.description,
fields: definition.fields.filter(fieldVisible)
}];
}
return definition.pages.flatMap((page) => {
if (page.visibility_condition && !evaluateCondition(page.visibility_condition, values)) return [];
return page.sections.flatMap((section) => {
if (section.visibility_condition && !evaluateCondition(section.visibility_condition, values)) return [];
return [{
pageKey: page.key,
sectionKey: section.key,
sectionTitle: section.title,
description: section.description,
fields: section.field_keys.map((key) => fields.get(key)).filter((field): field is FormFieldDefinition => Boolean(field && fieldVisible(field)))
}];
});
});
}
function evaluateCondition(condition: NonNullable<FormFieldDefinition["visibility_condition"]>, values: Record<string, unknown>): boolean {
if (condition.kind === "all") return condition.conditions.every((item) => evaluateCondition(item, values));
if (condition.kind === "any") return condition.conditions.some((item) => evaluateCondition(item, values));
if (condition.kind === "not") return !evaluateCondition(condition.conditions[0], values);
const actual = values[condition.field_key];
const expected = condition.value;
switch (condition.operator) {
case "eq": return actual === expected;
case "neq": return actual !== expected;
case "is_empty": return emptyValue(actual);
case "is_not_empty": return !emptyValue(actual);
case "in": return Array.isArray(expected) && expected.includes(actual);
case "not_in": return Array.isArray(expected) && !expected.includes(actual);
case "contains": return typeof actual === "string" ? actual.includes(String(expected ?? "")) : Array.isArray(actual) && actual.includes(expected);
case "lt": return comparable(actual, expected, (left, right) => left < right);
case "lte": return comparable(actual, expected, (left, right) => left <= right);
case "gt": return comparable(actual, expected, (left, right) => left > right);
case "gte": return comparable(actual, expected, (left, right) => left >= right);
default: return false;
}
}
function emptyValue(value: unknown): boolean {
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
}
function comparable(left: unknown, right: unknown, compare: (left: number | string, right: number | string) => boolean): boolean {
if ((typeof left === "number" && typeof right === "number") || (typeof left === "string" && typeof right === "string")) {
return compare(left, right);
}
return false;
}
function localizeDefinition(definition: FormDefinition | null) {
const canonical = {
title: definition?.title ?? "",
description: definition?.description ?? null,
fieldLabels: {} as Record<string, string>,
fieldHelpTexts: {} as Record<string, string>,
optionLabels: {} as Record<string, Record<string, string>>,
sectionTitles: {} as Record<string, string>
};
if (!definition?.localizations?.length) return canonical;
const browserLocales = navigator.languages.map((value) => value.toLowerCase());
const localization = definition.localizations.find((item) => browserLocales.some((locale) => locale === item.locale.toLowerCase() || locale.startsWith(`${item.locale.toLowerCase()}-`)))
?? definition.localizations.find((item) => item.locale.toLowerCase() === definition.fallback_locale?.toLowerCase());
if (!localization) return canonical;
return {
title: localization.title || canonical.title,
description: localization.description || canonical.description,
fieldLabels: localization.field_labels,
fieldHelpTexts: localization.field_help_texts,
optionLabels: localization.option_labels,
sectionTitles: localization.section_titles
};
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
+97
View File
@@ -143,6 +143,24 @@
padding: 20px 0;
}
.form-runtime-section > header {
padding-top: 16px;
border-bottom: 1px solid var(--border);
}
.form-runtime-section > header h2,
.form-handoffs h2 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0;
}
.form-runtime-section > header p {
margin: 5px 0 10px;
color: var(--text-soft);
font-size: 0.84rem;
}
.form-field {
display: flex;
min-width: 0;
@@ -216,6 +234,71 @@
color: var(--text);
}
.form-handoffs {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.form-handoff-heading,
.form-handoff-create,
.form-handoff-actions {
display: flex;
align-items: center;
gap: 8px;
}
.form-handoff-heading {
justify-content: space-between;
flex-wrap: wrap;
}
.form-handoff-create input {
width: min(260px, 34vw);
}
.form-handoff-empty {
margin: 0;
color: var(--text-soft);
font-size: 0.84rem;
}
.form-handoff-list {
border-top: 1px solid var(--border);
}
.form-handoff-row {
display: grid;
grid-template-columns: minmax(160px, 1fr) auto minmax(180px, 1fr) auto;
align-items: center;
gap: 10px;
min-height: 54px;
border-bottom: 1px solid var(--border);
}
.form-handoff-row > span:first-child {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.form-handoff-row small,
.form-handoff-error {
overflow: hidden;
color: var(--text-soft);
font-size: 0.78rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-handoff-error {
color: var(--danger);
}
.form-instance-actions {
display: flex;
align-items: end;
@@ -296,4 +379,18 @@
align-items: stretch;
flex-direction: column;
}
.form-handoff-create,
.form-handoff-row {
align-items: stretch;
grid-template-columns: 1fr;
}
.form-handoff-create {
flex-direction: column;
}
.form-handoff-create input {
width: 100%;
}
}