Add governed form handoffs
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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=(
|
||||
|
||||
+77
@@ -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")
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user