feat: add governed assisted form intake

This commit is contained in:
2026-08-19 02:45:53 +02:00
parent eea3db3d2e
commit d83bb92ec8
18 changed files with 1895 additions and 39 deletions
+35 -4
View File
@@ -58,6 +58,14 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
- an administrator-only public-intake dialog that selects exact published Form
revisions, configures expiry/rate limits, enables or disables profiles, and
exposes invitation secrets only once
- authenticated assisted-intake profiles and operator sessions that retain the
channel, affected and represented party references, authority basis, purpose,
notice, responsible function, language, accessibility needs, and field-level
source/confidence provenance on the same exact Form revision used digitally
- append-only assisted read-back outcomes bound to the exact current Form
revision, normalized values, attachments, and signatures; draft corrections
invalidate earlier confirmation evidence and submission fails closed until a
new confirmation or explicitly noted unavailable-confirmation record exists
- Files-owned one-time evidence upload grants bound to the tenant, exact Form
instance and definition revision, purpose, custodian, size, media types, and a
maximum 15-minute upload window
@@ -83,6 +91,13 @@ revision; a mismatched provider response fails closed.
Policy-referenced definitions fail closed when no compatible
`forms_runtime.policy_evaluator` is active.
Assisted intake is not part of the default authenticated role. Starting a
session or recording read-back evidence requires the dedicated
`forms_runtime:submission:assist` permission or the manager write permission.
The assistant role combines that permission with ordinary participation so the
operator can resume only their own drafts; tenant-wide takeover still requires
manager authority.
Files and signature providers retain their own content and key custody. Runtime
stores only same-tenant evidence references and bounded inspection snapshots.
Files stores only upload-token digests and never grants a public intake actor
@@ -96,6 +111,22 @@ new one-time bearer URL for each participant. The UI can copy a newly issued
secret but cannot retrieve it later. Disabling a profile prevents new starts;
already submitted revisions and their evidence remain governed records.
Administrators enable assisted profiles against the same published Form
revisions. An authenticated operator starts the session only after recording
the governed party and function references, authority and purpose, channel,
notice state, language, and required communication support. The resulting
draft remains resumable through the ordinary Forms workspace and uses the same
server validation, evidence inspection, receipt, and downstream handoff rules
as digital intake. Assisted mode is provenance, not a privileged validation or
authorization bypass.
Before submission, the operator must make the exact values and managed evidence
available through spoken read-back, a written preview, or an accessible copy.
The immutable outcome identifies the confirming party and operator. Corrections
are saved as a new Form revision and require a new read-back. When confirmation
cannot be obtained, the operator must choose that outcome and record the
exception; policy or downstream review may still stop or escalate the case.
## Approved Intake And Evidence Profiles
The product and security profile approved on 2026-08-04 sets the next
@@ -148,11 +179,11 @@ 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.
Public intake, Files-backed attachment evidence, and authenticated
Public and assisted intake, Files-backed attachment evidence, and authenticated
acknowledgements implement the approved first profiles. Conditional multi-page
definitions are resolved from Forms, and native Case/Workflow handoffs execute
automatically when the exact owner capability is installed. CAPTCHA,
pseudonymous intake, advanced or qualified electronic signatures, richer
attachment classification/retention controls, and additional target kinds
remain provider or product depth; none may weaken the implemented owner and
security boundaries.
attachment classification/retention controls, dedicated party pickers, and
additional target kinds remain provider or product depth; none may weaken the
implemented owner and security boundaries.
@@ -0,0 +1,86 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
import hashlib
import json
from govoplan_core.core.institutional import EvidenceReference
from govoplan_forms_runtime.backend.domain import FormInstance
ASSISTED_CHANNELS = frozenset(
{
"counter",
"telephone",
"paper",
"email",
"mobile",
"representative",
"offline_import",
}
)
ASSISTED_CONFIRMATION_METHODS = frozenset(
{"spoken_readback", "written_preview", "accessible_copy", "unavailable"}
)
ASSISTED_CONFIRMATION_OUTCOMES = frozenset(
{"confirmed", "corrected", "confirmation_unavailable"}
)
ASSISTED_SOURCE_KINDS = frozenset(
{"person_statement", "representative_statement", "document", "system", "derived"}
)
ASSISTED_CONFIDENCE_LEVELS = frozenset({"stated", "verified", "uncertain"})
def assisted_submission_payload_sha256(
instance: FormInstance,
*,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
) -> str:
return _hash(
{
"tenant_id": instance.tenant_id,
"instance_id": instance.instance_id,
"instance_revision": instance.revision,
"definition_ref": instance.definition_ref.to_dict(),
"values": dict(values),
"attachment_refs": [item.to_dict() for item in attachment_refs],
"signature_refs": [item.to_dict() for item in signature_refs],
}
)
def confirmation_payload(value: object) -> dict[str, object]:
return {
"confirmation_id": str(getattr(value, "confirmation_id")),
"instance_id": str(getattr(value, "instance_id")),
"instance_revision": int(getattr(value, "instance_revision")),
"outcome": str(getattr(value, "outcome")),
"method": str(getattr(value, "method")),
"confirmed_by_ref": str(getattr(value, "confirmed_by_ref")),
"operator_actor_id": str(getattr(value, "operator_actor_id")),
"confirmed_at": getattr(value, "confirmed_at").isoformat(),
"payload_sha256": str(getattr(value, "payload_sha256")),
"correction_note": getattr(value, "correction_note"),
"metadata": dict(getattr(value, "details")),
}
def _hash(value: Mapping[str, object]) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":"), default=str).encode(
"utf-8"
)
).hexdigest()
__all__ = [
"ASSISTED_CHANNELS",
"ASSISTED_CONFIDENCE_LEVELS",
"ASSISTED_CONFIRMATION_METHODS",
"ASSISTED_CONFIRMATION_OUTCOMES",
"ASSISTED_SOURCE_KINDS",
"assisted_submission_payload_sha256",
"confirmation_payload",
]
@@ -291,6 +291,57 @@ class FormIntakeSession(Base, TimestampMixin):
)
class FormAssistedConfirmation(Base, TimestampMixin):
__tablename__ = "form_assisted_confirmations"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"confirmation_id",
name="uq_form_assisted_confirmation",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_assisted_confirmation_idempotency",
),
Index(
"ix_form_assisted_confirmation_instance",
"tenant_id",
"instance_id",
"instance_revision",
),
)
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)
confirmation_id: Mapped[str] = mapped_column(
String(36), nullable=False, index=True
)
intake_session_id: Mapped[str] = mapped_column(
ForeignKey("form_intake_sessions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
method: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
confirmed_by_ref: Mapped[str] = mapped_column(String(255), nullable=False)
operator_actor_id: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
confirmed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
correction_note: Mapped[str | None] = mapped_column(String(1000), nullable=True)
details: Mapped[dict[str, Any]] = mapped_column(
"metadata", JSON, default=dict, nullable=False
)
class FormAcknowledgement(Base, TimestampMixin):
__tablename__ = "form_acknowledgements"
__table_args__ = (
@@ -335,6 +386,7 @@ class FormAcknowledgement(Base, TimestampMixin):
__all__ = [
"FormAcknowledgement",
"FormAssistedConfirmation",
"FormInstanceEvent",
"FormHandoffEffect",
"FormIntakeProfile",
+492 -9
View File
@@ -19,15 +19,28 @@ from govoplan_core.core.institutional import (
InstitutionalReference,
)
from govoplan_forms_runtime.backend.db.models import (
FormAssistedConfirmation,
FormIntakeProfile,
FormIntakeSession,
)
from govoplan_forms_runtime.backend.assisted import (
ASSISTED_CHANNELS,
ASSISTED_CONFIDENCE_LEVELS,
ASSISTED_CONFIRMATION_METHODS,
ASSISTED_CONFIRMATION_OUTCOMES,
ASSISTED_SOURCE_KINDS,
assisted_submission_payload_sha256,
)
from govoplan_forms_runtime.backend.domain import FormInstance
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
from govoplan_forms_runtime.backend.service import (
FormRuntimeError,
FormRuntimeService,
normalize_form_values,
)
IntakeMode = Literal["anonymous", "invitation"]
INTAKE_MODES = frozenset({"anonymous", "invitation"})
IntakeMode = Literal["anonymous", "invitation", "assisted"]
INTAKE_MODES = frozenset({"anonymous", "invitation", "assisted"})
DEFAULT_DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60
DEFAULT_INVITATION_TTL_SECONDS = 14 * 24 * 60 * 60
DEFAULT_RATE_LIMIT_PER_MINUTE = 60
@@ -95,9 +108,9 @@ class FormIntakeService:
actor_id = _principal_actor(principal)
_require_aware(recorded_at, "Intake profile recorded_at")
if mode not in INTAKE_MODES:
raise FormIntakeError(f"Unsupported public intake mode: {mode!r}.")
raise FormIntakeError(f"Unsupported Form intake mode: {mode!r}.")
if not custodian_ref.strip():
raise FormIntakeError("A public intake profile requires a custodian.")
raise FormIntakeError("A Form intake profile requires a custodian.")
if not 60 <= draft_ttl_seconds <= 365 * 24 * 60 * 60:
raise FormIntakeError(
"Draft expiry must be between one minute and one year."
@@ -117,7 +130,11 @@ class FormIntakeService:
effective_at=recorded_at,
)
if definition.publication_state != "published":
raise FormIntakeError("Only a published Form can receive public intake.")
raise FormIntakeError("Only a published Form can receive configured intake.")
if mode == "assisted" and not definition.allow_drafts:
raise FormIntakeError(
"Assisted intake requires a published Form that permits resumable drafts."
)
profile = FormIntakeProfile(
tenant_id=tenant_id,
profile_id=str(uuid.uuid4()),
@@ -136,9 +153,10 @@ class FormIntakeService:
created_by=actor_id,
updated_by=actor_id,
details={
**dict(metadata or {}),
"identity_claim_allowed": False,
"pseudonymous": False,
**dict(metadata or {}),
"definition_title": definition.title,
},
)
session.add(profile)
@@ -351,6 +369,346 @@ class FormIntakeService:
session.flush()
return self._result(session, intake_session, instance=instance)
def list_assisted_profiles(
self,
session: Session,
principal: object,
) -> tuple[FormIntakeProfile, ...]:
return tuple(
session.query(FormIntakeProfile)
.filter(
FormIntakeProfile.tenant_id == _principal_tenant(principal),
FormIntakeProfile.mode == "assisted",
FormIntakeProfile.enabled.is_(True),
)
.order_by(FormIntakeProfile.definition_id.asc())
.all()
)
def start_assisted(
self,
session: Session,
principal: object,
*,
profile_id: str,
values: Mapping[str, object],
channel: str,
affected_party_ref: str,
represented_party_ref: str | None,
authority_basis: str,
purpose: str,
legal_basis_ref: str | None,
consent_basis: str | None,
notice_given: bool,
responsible_function_ref: str,
language: str,
accessibility_needs: Sequence[str],
field_sources: Mapping[str, Mapping[str, object]],
idempotency_key: str,
recorded_at: datetime,
) -> IntakeSessionResult:
_require_aware(recorded_at, "Assisted intake recorded_at")
profile = self._profile_for_admin(
session,
principal,
profile_id=profile_id,
lock=True,
)
if profile.mode != "assisted" or not profile.enabled:
raise FormIntakeError("This assisted intake profile is unavailable.")
context = _assisted_context(
principal,
channel=channel,
affected_party_ref=affected_party_ref,
represented_party_ref=represented_party_ref,
authority_basis=authority_basis,
purpose=purpose,
legal_basis_ref=legal_basis_ref,
consent_basis=consent_basis,
notice_given=notice_given,
responsible_function_ref=responsible_function_ref,
language=language,
accessibility_needs=accessibility_needs,
field_sources=field_sources,
)
context_sources = context.get("field_sources")
if not isinstance(context_sources, Mapping):
raise FormIntakeError("Assisted intake field provenance is invalid.")
missing_sources = sorted(
key
for key, value in values.items()
if value not in (None, "")
and key not in context_sources
)
if missing_sources:
raise FormIntakeError(
"Assisted intake requires source provenance for every supplied field: "
+ ", ".join(missing_sources[:10])
)
request = {
"operation": "assisted_start",
"profile_id": profile.profile_id,
"values": dict(values),
"context": context,
"recorded_at": recorded_at.isoformat(),
}
internal_key = (
f"assisted:{profile.profile_id}:"
f"{_clean_text(idempotency_key, 'Assisted intake idempotency key', 255)}"
)
request_sha256 = _hash(request)
replay = self._session_replay(
session,
tenant_id=profile.tenant_id,
idempotency_key=internal_key,
request_sha256=request_sha256,
)
if replay is not None:
if replay.actor_id != _principal_actor(principal):
raise FormIntakeError(
"Assisted intake idempotency conflict: this key belongs to another operator."
)
instance = self._runtime.get_instance(
session,
principal,
instance_id=_session_instance_id(replay),
allow_all=True,
)
if instance is None:
raise FormIntakeError("The assisted intake Form is unavailable.")
return self._result(
session,
replay,
instance=instance,
replayed=True,
)
intake_session = self._new_session(
profile,
token=secrets.token_urlsafe(32),
idempotency_key=internal_key,
request_sha256=request_sha256,
expires_at=recorded_at + timedelta(seconds=profile.draft_ttl_seconds),
created_by=_principal_actor(principal),
actor_id=_principal_actor(principal),
metadata={"assisted_context": context},
)
session.add(intake_session)
session.flush()
instance = self._runtime.create_instance(
session,
principal,
definition_ref=_profile_definition_ref(profile),
values=values,
idempotency_key=f"assisted-start:{intake_session.session_id}",
recorded_at=recorded_at,
metadata={
"intake": {
"session_id": intake_session.session_id,
"profile_id": profile.profile_id,
"mode": "assisted",
**context,
}
},
)
intake_session.instance_id = instance.instance_id
intake_session.status = "active"
intake_session.started_at = recorded_at
session.add(intake_session)
session.flush()
return self._result(session, intake_session, instance=instance)
def record_assisted_confirmation(
self,
session: Session,
principal: object,
*,
instance_id: str,
expected_revision: int,
values: Mapping[str, object],
attachment_refs: Sequence[EvidenceReference],
signature_refs: Sequence[EvidenceReference],
outcome: str,
method: str,
confirmed_by_ref: str,
confirmed_at: datetime,
idempotency_key: str,
field_sources: Mapping[str, Mapping[str, object]],
correction_note: str | None = None,
metadata: Mapping[str, object] | None = None,
allow_all: bool = False,
) -> FormAssistedConfirmation:
_require_aware(confirmed_at, "Assisted confirmation confirmed_at")
intake_session = self._assisted_session(
session,
principal,
instance_id=instance_id,
lock=True,
allow_all=allow_all,
)
if outcome not in ASSISTED_CONFIRMATION_OUTCOMES:
raise FormIntakeError(f"Unsupported assisted confirmation outcome: {outcome!r}.")
if method not in ASSISTED_CONFIRMATION_METHODS:
raise FormIntakeError(f"Unsupported assisted confirmation method: {method!r}.")
if (outcome == "confirmation_unavailable") != (method == "unavailable"):
raise FormIntakeError(
"Unavailable assisted confirmation must use the unavailable method, and that method is reserved for the unavailable outcome."
)
if (
intake_session.status != "active"
or _aware(intake_session.expires_at) <= confirmed_at
or (
intake_session.started_at is not None
and confirmed_at < _aware(intake_session.started_at)
)
):
raise FormIntakeError("This assisted intake session is no longer active.")
clean_note = str(correction_note or "").strip() or None
if outcome in {"corrected", "confirmation_unavailable"} and not clean_note:
raise FormIntakeError(
"Corrected or unavailable confirmation requires a bounded note."
)
if clean_note and len(clean_note) > 1000:
raise FormIntakeError("Assisted confirmation note is limited to 1000 characters.")
instance = self._runtime.get_instance(
session,
principal,
instance_id=instance_id,
allow_all=allow_all,
)
if instance is None:
raise LookupError("Assisted intake Form not found.")
if instance.status not in {"started", "draft"}:
raise FormIntakeError("Only an editable assisted intake can be confirmed.")
if instance.revision != expected_revision:
raise FormIntakeError(
"Assisted confirmation revision conflict: the expected revision is stale."
)
definition = self._definition(
session,
principal,
reference=instance.definition_ref,
effective_at=confirmed_at,
)
clean_values = normalize_form_values(definition, values)
attachments = tuple(attachment_refs)
signatures = tuple(signature_refs)
clean_sources = _clean_field_sources(field_sources)
missing_sources = sorted(
key
for key, value in clean_values.items()
if value not in (None, "") and key not in clean_sources
)
if missing_sources:
raise FormIntakeError(
"Assisted read-back requires source provenance for every populated field: "
+ ", ".join(missing_sources[:10])
)
if (
clean_values != dict(instance.values)
or attachments != tuple(instance.attachment_refs)
or signatures != tuple(instance.signature_refs)
):
raise FormIntakeError(
"Save assisted corrections and evidence as a new draft revision before recording read-back confirmation."
)
payload_sha256 = assisted_submission_payload_sha256(
instance,
values=clean_values,
attachment_refs=attachments,
signature_refs=signatures,
)
internal_key = (
f"assisted-confirmation:{intake_session.session_id}:"
f"{_clean_text(idempotency_key, 'Assisted confirmation idempotency key', 255)}"
)
request = {
"operation": "assisted_confirmation",
"session_id": intake_session.session_id,
"instance_id": instance.instance_id,
"instance_revision": instance.revision,
"outcome": outcome,
"method": method,
"confirmed_by_ref": _clean_text(
confirmed_by_ref, "Assisted confirmation party", 255
),
"confirmed_at": confirmed_at.isoformat(),
"payload_sha256": payload_sha256,
"field_sources": clean_sources,
"correction_note": clean_note,
"metadata": dict(metadata or {}),
}
request_sha256 = _hash(request)
existing = (
session.query(FormAssistedConfirmation)
.filter(
FormAssistedConfirmation.tenant_id == instance.tenant_id,
FormAssistedConfirmation.idempotency_key == internal_key,
)
.one_or_none()
)
if existing is not None:
if existing.request_sha256 != request_sha256:
raise FormIntakeError(
"Assisted confirmation idempotency conflict: this key was used for another request."
)
return existing
confirmation = FormAssistedConfirmation(
tenant_id=instance.tenant_id,
confirmation_id=str(uuid.uuid4()),
intake_session_id=intake_session.id,
instance_id=instance.instance_id,
instance_revision=instance.revision,
outcome=outcome,
method=method,
confirmed_by_ref=str(request["confirmed_by_ref"]),
operator_actor_id=_principal_actor(principal),
confirmed_at=confirmed_at,
payload_sha256=payload_sha256,
idempotency_key=internal_key,
request_sha256=request_sha256,
correction_note=clean_note,
details={
**dict(metadata or {}),
"statement": "The shown values and managed evidence were read back or made available for review.",
"field_sources": clean_sources,
},
)
session.add(confirmation)
intake_session.details = {
**dict(intake_session.details),
"current_field_sources": clean_sources,
"latest_confirmation_id": confirmation.confirmation_id,
}
session.add(intake_session)
session.flush()
return confirmation
def list_assisted_confirmations(
self,
session: Session,
principal: object,
*,
instance_id: str,
allow_all: bool = False,
) -> tuple[FormAssistedConfirmation, ...]:
self._assisted_session(
session,
principal,
instance_id=instance_id,
lock=False,
allow_all=allow_all,
)
return tuple(
session.query(FormAssistedConfirmation)
.filter(
FormAssistedConfirmation.tenant_id == _principal_tenant(principal),
FormAssistedConfirmation.instance_id == instance_id,
)
.order_by(FormAssistedConfirmation.confirmed_at.desc())
.all()
)
def get_public_instance(
self,
session: Session,
@@ -549,6 +907,29 @@ class FormIntakeService:
session.add(profile)
return profile
def _assisted_session(
self,
session: Session,
principal: object,
*,
instance_id: str,
lock: bool,
allow_all: bool,
) -> FormIntakeSession:
query = session.query(FormIntakeSession).filter(
FormIntakeSession.tenant_id == _principal_tenant(principal),
FormIntakeSession.instance_id == instance_id,
FormIntakeSession.mode == "assisted",
)
if lock:
query = query.with_for_update()
intake_session = query.one_or_none()
if intake_session is None:
raise LookupError("Assisted intake session not found.")
if not allow_all and intake_session.actor_id != _principal_actor(principal):
raise PermissionError("Assisted intake access denied.")
return intake_session
def _public_session(
self,
session: Session,
@@ -606,7 +987,7 @@ class FormIntakeService:
)
if existing is not None and existing.request_sha256 != request_sha256:
raise FormIntakeError(
"Public intake idempotency conflict: this key was used for another request."
"Form intake idempotency conflict: this key was used for another request."
)
return existing
@@ -619,6 +1000,7 @@ class FormIntakeService:
request_sha256: str,
expires_at: datetime,
created_by: str,
actor_id: str | None = None,
metadata: Mapping[str, object] | None = None,
) -> FormIntakeSession:
session_id = str(uuid.uuid4())
@@ -630,7 +1012,7 @@ class FormIntakeService:
mode=profile.mode,
status="issued",
instance_id=None,
actor_id=f"form-public:{session_id}",
actor_id=actor_id or f"form-public:{session_id}",
idempotency_key=idempotency_key,
request_sha256=request_sha256,
expires_at=expires_at,
@@ -725,6 +1107,93 @@ def _public_principal(
)
def _assisted_context(
principal: object,
*,
channel: str,
affected_party_ref: str,
represented_party_ref: str | None,
authority_basis: str,
purpose: str,
legal_basis_ref: str | None,
consent_basis: str | None,
notice_given: bool,
responsible_function_ref: str,
language: str,
accessibility_needs: Sequence[str],
field_sources: Mapping[str, Mapping[str, object]],
) -> dict[str, object]:
clean_channel = str(channel or "").strip()
if clean_channel not in ASSISTED_CHANNELS:
raise FormIntakeError(f"Unsupported assisted intake channel: {channel!r}.")
clean_sources = _clean_field_sources(field_sources)
if len(accessibility_needs) > 30:
raise FormIntakeError("Assisted intake is limited to 30 accessibility needs.")
clean_accessibility = []
for item in accessibility_needs:
value = _clean_text(item, "Accessibility need", 255)
if value not in clean_accessibility:
clean_accessibility.append(value)
represented = _optional_text(
represented_party_ref, "Represented party reference", 255
)
legal_basis = _optional_text(legal_basis_ref, "Legal basis reference", 255)
consent = _optional_text(consent_basis, "Consent basis", 255)
return {
"channel": clean_channel,
"affected_party_ref": _clean_text(
affected_party_ref, "Affected party reference", 255
),
"represented_party_ref": represented,
"authority_basis": _clean_text(authority_basis, "Authority basis", 255),
"purpose": _clean_text(purpose, "Assisted intake purpose", 500),
"legal_basis_ref": legal_basis,
"consent_basis": consent,
"notice_given": bool(notice_given),
"responsible_function_ref": _clean_text(
responsible_function_ref, "Responsible function reference", 255
),
"language": _clean_text(language, "Assisted intake language", 35),
"accessibility_needs": clean_accessibility,
"field_sources": clean_sources,
"operator": {
"actor_id": _principal_actor(principal),
"auth_method": str(getattr(principal, "auth_method", "") or "authenticated"),
"acting_for_account_id": getattr(principal, "acting_for_account_id", None),
"acting_assignment_id": getattr(principal, "acting_assignment_id", None),
},
}
def _clean_field_sources(
field_sources: Mapping[str, Mapping[str, object]],
) -> dict[str, dict[str, object]]:
clean_sources: dict[str, dict[str, object]] = {}
for field_key, source in field_sources.items():
key = _clean_text(field_key, "Assisted field source key", 255)
kind = str(source.get("source") or "").strip()
confidence = str(source.get("confidence") or "").strip()
if kind not in ASSISTED_SOURCE_KINDS:
raise FormIntakeError(
f"Unsupported source for assisted field {key!r}: {kind!r}."
)
if confidence not in ASSISTED_CONFIDENCE_LEVELS:
raise FormIntakeError(
f"Unsupported confidence for assisted field {key!r}: {confidence!r}."
)
declared_by_ref = str(source.get("declared_by_ref") or "").strip()
if len(declared_by_ref) > 255:
raise FormIntakeError(
f"Declared-by reference for assisted field {key!r} is limited to 255 characters."
)
clean_sources[key] = {
"source": kind,
"confidence": confidence,
"declared_by_ref": declared_by_ref or None,
}
return clean_sources
def _profile_definition_ref(profile: FormIntakeProfile) -> InstitutionalReference:
return InstitutionalReference(
kind="form",
@@ -787,6 +1256,20 @@ def _hash(value: Mapping[str, object]) -> str:
).hexdigest()
def _clean_text(value: object, label: str, maximum: int) -> str:
clean = str(value or "").strip()
if not clean or len(clean) > maximum:
raise FormIntakeError(f"{label} is required and limited to {maximum} characters.")
return clean
def _optional_text(value: object, label: str, maximum: int) -> str | None:
clean = str(value or "").strip()
if len(clean) > maximum:
raise FormIntakeError(f"{label} is limited to {maximum} characters.")
return clean or None
def _require_aware(value: datetime, label: str) -> None:
if value.tzinfo is None or value.utcoffset() is None:
raise FormIntakeError(f"{label} must include a timezone.")
+53 -4
View File
@@ -57,6 +57,7 @@ MODULE_ID = "forms_runtime"
MODULE_NAME = "Forms Runtime"
MODULE_VERSION = "0.1.18"
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
ASSIST_SCOPE = "forms_runtime:submission:assist"
READ_SCOPE = "forms_runtime:workspace:read"
WRITE_SCOPE = "forms_runtime:workspace:write"
ADMIN_SCOPE = "forms_runtime:workspace:admin"
@@ -92,6 +93,11 @@ PERMISSIONS = (
"Complete assigned forms",
"Start, read, save, and submit the acting account's own Form instances.",
),
_permission(
ASSIST_SCOPE,
"Conduct assisted Form intake",
"Start authenticated assisted sessions and record party read-back or correction evidence.",
),
_permission(
READ_SCOPE,
"View form submissions",
@@ -117,11 +123,17 @@ ROLE_TEMPLATES = (
permissions=(PARTICIPATE_SCOPE,),
default_authenticated=True,
),
RoleTemplate(
slug="forms_runtime_assistant",
name="Forms intake assistant",
description="Conduct purpose-bound assisted intake for another party.",
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE),
),
RoleTemplate(
slug="forms_runtime_manager",
name="Forms Runtime manager",
description="Review, transition, and hand off Form submissions.",
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
permissions=(PARTICIPATE_SCOPE, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="forms_runtime_viewer",
@@ -191,10 +203,33 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
)
).count(),
"public_intake_profiles": session.query(runtime_models.FormIntakeProfile)
.filter(runtime_models.FormIntakeProfile.tenant_id == tenant_id)
.filter(
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
runtime_models.FormIntakeProfile.mode.in_(("anonymous", "invitation")),
)
.count(),
"public_intake_sessions": session.query(runtime_models.FormIntakeSession)
.filter(runtime_models.FormIntakeSession.tenant_id == tenant_id)
.filter(
runtime_models.FormIntakeSession.tenant_id == tenant_id,
runtime_models.FormIntakeSession.mode.in_(("anonymous", "invitation")),
)
.count(),
"assisted_intake_profiles": session.query(runtime_models.FormIntakeProfile)
.filter(
runtime_models.FormIntakeProfile.tenant_id == tenant_id,
runtime_models.FormIntakeProfile.mode == "assisted",
)
.count(),
"assisted_intake_sessions": session.query(runtime_models.FormIntakeSession)
.filter(
runtime_models.FormIntakeSession.tenant_id == tenant_id,
runtime_models.FormIntakeSession.mode == "assisted",
)
.count(),
"assisted_confirmations": session.query(
runtime_models.FormAssistedConfirmation
)
.filter(runtime_models.FormAssistedConfirmation.tenant_id == tenant_id)
.count(),
"authenticated_acknowledgements": session.query(
runtime_models.FormAcknowledgement
@@ -315,6 +350,9 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
ModuleInterfaceProvider(name="forms_runtime.public_intake", version="1.0.0"),
ModuleInterfaceProvider(
name="forms_runtime.assisted_intake", version="1.0.0"
),
ModuleInterfaceProvider(
name="forms_runtime.authenticated_acknowledgement",
version="1.0.0",
@@ -379,6 +417,7 @@ manifest = ModuleManifest(
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
runtime_models.FormAcknowledgement,
runtime_models.FormAssistedConfirmation,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
@@ -392,6 +431,7 @@ manifest = ModuleManifest(
uninstall_guard_providers=(
persistent_table_uninstall_guard(
runtime_models.FormAcknowledgement,
runtime_models.FormAssistedConfirmation,
runtime_models.FormIntakeSession,
runtime_models.FormIntakeProfile,
runtime_models.FormHandoffEffect,
@@ -416,7 +456,7 @@ manifest = ModuleManifest(
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. 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."
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
" Invitation and explicitly enabled anonymous intake use hash-only expiring tokens, bounded rate limits, and isolated synthetic actors. Authenticated assisted sessions retain channel, affected and represented parties, authority, purpose, notice, responsible function, language, accessibility needs, and field provenance without bypassing the exact Form rules. Submission requires immutable read-back evidence bound to the current revision, values, attachments, and signatures; any later draft edit invalidates it. Files-backed attachments use one-time purpose-bound grants, while authenticated acknowledgements bind an exact actor and payload digest without claiming advanced or qualified signature assurance. When Search is enabled, Forms Runtime contributes a rebuildable metadata-only projection; submitted values and evidence content are excluded, and every candidate receives a current workspace or participant access check."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -435,6 +475,8 @@ manifest = ModuleManifest(
"forms_runtime.workspace",
"forms_runtime.instance",
"forms_runtime.public-intake",
"forms_runtime.assisted-intake",
"forms_runtime.assisted-confirmation",
"forms_runtime.search.result",
"forms_runtime.state.read-only",
"forms_runtime.state.permission-blocked",
@@ -444,6 +486,7 @@ manifest = ModuleManifest(
"Validation messages expose field-level diagnostics without disclosing unrelated submissions.",
"Handoff rows retain provider references and outcomes but do not bypass target-module authorization.",
"Public intake tokens and Files upload tokens are retained only as cryptographic digests; anonymous submissions cannot later be claimed by an identity.",
"Assisted session provenance names governed party/function references and purpose; operators should not duplicate names or evidence content in free-text references and notes.",
],
},
),
@@ -459,6 +502,7 @@ manifest = ModuleManifest(
"stable idempotency key. Rejected effects may be retried. Unknown outcomes must be reconciled before retry to avoid a "
"duplicate target. Administrative compensation records verified absence and never deletes a remote target. "
"When Records is enabled, only immutable submitted revisions can be resolved for filing; current Forms Runtime access is rechecked and editable drafts fail closed."
" Assisted intake begins with an authenticated, purpose-bound session. The operator records the channel, party and representation references, authority basis, notice, responsible function, language, accessibility support, and per-field sources. Read-back outcomes are append-only and bind the exact current payload. Corrections must first be saved as a new draft revision and confirmed again; an unavailable confirmation requires an explicit exception note."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -487,6 +531,8 @@ manifest = ModuleManifest(
"forms_runtime.action.issue-invitation",
"forms_runtime.action.upload-evidence",
"forms_runtime.action.acknowledge",
"forms_runtime.action.start-assisted-intake",
"forms_runtime.action.confirm-assisted-readback",
"records.action.file",
],
"consequence_classes": {
@@ -498,6 +544,8 @@ manifest = ModuleManifest(
"public_intake": "Starts an isolated, expiring invitation or explicitly enabled anonymous submission without granting general platform access.",
"upload_evidence": "Issues a short-lived provider grant; the returned immutable evidence reference must pass owner verification again at submission.",
"acknowledge": "Binds the acting account, statement version, exact Form revision, values, and attachments in an authenticated acknowledgement digest.",
"start_assisted_intake": "Creates a resumable authenticated draft with explicit channel, party, authority, purpose, notice, function, accessibility, and source provenance.",
"confirm_assisted_readback": "Creates immutable evidence for the exact current revision and payload; a later correction requires a new confirmation before submission.",
"file_submission": "Resolves the exact immutable submission revision under current access and preserves only a digest-bound reference in Records.",
},
},
@@ -511,6 +559,7 @@ manifest = ModuleManifest(
test_ref="tests/test_forms_runtime.py",
known_limits=(
"External advanced or qualified signature providers, scheduled expiry cleanup, and target kinds beyond native Case/Workflow handoffs remain adapter depth. Public links intentionally cannot substitute the native authenticated acknowledgement profile.",
"The assisted-intake API retains per-field provenance, while the first operator dialog applies one selected source/confidence profile to all populated values; mixed-source field editing remains UI depth.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
@@ -0,0 +1,83 @@
"""Add immutable assisted-intake read-back confirmations.
Revision ID: c5f7a9b1d3e4
Revises: b4e6f8a0c2d3
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c5f7a9b1d3e4"
down_revision = "b4e6f8a0c2d3"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"form_assisted_confirmations",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("confirmation_id", sa.String(length=36), nullable=False),
sa.Column("intake_session_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("instance_revision", sa.Integer(), nullable=False),
sa.Column("outcome", sa.String(length=30), nullable=False),
sa.Column("method", sa.String(length=30), nullable=False),
sa.Column("confirmed_by_ref", sa.String(length=255), nullable=False),
sa.Column("operator_actor_id", sa.String(length=255), nullable=False),
sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("payload_sha256", sa.String(length=64), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("correction_note", sa.String(length=1000), 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.ForeignKeyConstraint(
["intake_session_id"],
["form_intake_sessions.id"],
name=op.f(
"fk_form_assisted_confirmations_intake_session_id_form_intake_sessions"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_assisted_confirmations")),
sa.UniqueConstraint(
"tenant_id", "confirmation_id", name="uq_form_assisted_confirmation"
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_assisted_confirmation_idempotency",
),
)
for column in (
"tenant_id",
"confirmation_id",
"intake_session_id",
"instance_id",
"outcome",
"method",
"operator_actor_id",
"confirmed_at",
):
op.create_index(
op.f(f"ix_form_assisted_confirmations_{column}"),
"form_assisted_confirmations",
[column],
unique=False,
)
op.create_index(
"ix_form_assisted_confirmation_instance",
"form_assisted_confirmations",
["tenant_id", "instance_id", "instance_revision"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_assisted_confirmations")
@@ -14,11 +14,14 @@ from govoplan_core.core.institutional import (
from govoplan_core.db.session import get_session
from govoplan_forms_runtime.backend.manifest import (
ADMIN_SCOPE,
ASSIST_SCOPE,
PARTICIPATE_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_forms_runtime.backend.schemas import (
AssistedFormConfirmationRequest,
AssistedFormStartRequest,
FormAcknowledgementRequest,
FormDraftUpdateRequest,
FormEvidenceGrantCreateRequest,
@@ -37,6 +40,7 @@ from govoplan_forms_runtime.backend.schemas import (
FormTransitionRequest,
FormNativeHandoffRequest,
)
from govoplan_forms_runtime.backend.assisted import confirmation_payload
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
from govoplan_forms_runtime.backend.intake import (
FormIntakeError,
@@ -190,6 +194,132 @@ def create_router(registry: object | None) -> APIRouter:
raise _public_error(exc) from exc
return result.to_dict()
@router.get("/assisted-intake/profiles", response_model=dict[str, object])
def api_list_assisted_intake_profiles(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
return {
"profiles": [
profile_payload(item)
for item in intake.list_assisted_profiles(session, principal)
]
}
@router.post(
"/assisted-intake/start",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_start_assisted_intake(
payload: AssistedFormStartRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
try:
result = intake.start_assisted(
session,
principal,
profile_id=payload.profile_id,
values=payload.values,
channel=payload.channel,
affected_party_ref=payload.affected_party_ref,
represented_party_ref=payload.represented_party_ref,
authority_basis=payload.authority_basis,
purpose=payload.purpose,
legal_basis_ref=payload.legal_basis_ref,
consent_basis=payload.consent_basis,
notice_given=payload.notice_given,
responsible_function_ref=payload.responsible_function_ref,
language=payload.language,
accessibility_needs=payload.accessibility_needs,
field_sources={
key: value.model_dump()
for key, value in payload.field_sources.items()
},
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
)
session.commit()
except (
FormIntakeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return result.to_dict()
@router.get(
"/instances/{instance_id}/assisted-confirmations",
response_model=dict[str, object],
)
def api_list_assisted_confirmations(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE)
try:
items = intake.list_assisted_confirmations(
session,
principal,
instance_id=instance_id,
allow_all=has_scope(principal, READ_SCOPE)
or has_scope(principal, WRITE_SCOPE),
)
except (FormIntakeError, LookupError, PermissionError) as exc:
raise _error(exc) from exc
return {"confirmations": [confirmation_payload(item) for item in items]}
@router.post(
"/instances/{instance_id}/assisted-confirmations",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_record_assisted_confirmation(
instance_id: str,
payload: AssistedFormConfirmationRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, ASSIST_SCOPE, WRITE_SCOPE)
try:
item = intake.record_assisted_confirmation(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
values=payload.values,
attachment_refs=_evidence(payload.attachment_refs),
signature_refs=_evidence(payload.signature_refs),
outcome=payload.outcome,
method=payload.method,
confirmed_by_ref=payload.confirmed_by_ref,
confirmed_at=payload.confirmed_at,
idempotency_key=payload.idempotency_key,
field_sources={
key: value.model_dump()
for key, value in payload.field_sources.items()
},
correction_note=payload.correction_note,
metadata=payload.metadata,
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (
FormRuntimeError,
InstitutionalContextError,
LookupError,
PermissionError,
) as exc:
session.rollback()
raise _error(exc) from exc
return confirmation_payload(item)
@router.post(
"/public/intake/start",
response_model=dict[str, object],
+68 -1
View File
@@ -93,7 +93,7 @@ class FormIntakeProfileCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition_ref: dict[str, Any]
mode: Literal["anonymous", "invitation"]
mode: Literal["anonymous", "invitation", "assisted"]
draft_ttl_seconds: int = Field(default=2_592_000, ge=60, le=31_536_000)
invitation_ttl_seconds: int = Field(default=1_209_600, ge=60, le=7_776_000)
rate_limit_per_minute: int = Field(default=60, ge=1, le=10_000)
@@ -125,6 +125,70 @@ class PublicFormStartRequest(BaseModel):
recorded_at: datetime
class AssistedFieldSourceRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source: Literal[
"person_statement",
"representative_statement",
"document",
"system",
"derived",
]
confidence: Literal["stated", "verified", "uncertain"]
declared_by_ref: str | None = Field(default=None, min_length=1, max_length=255)
class AssistedFormStartRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
profile_id: str = Field(min_length=1, max_length=255)
values: dict[str, Any] = Field(default_factory=dict)
channel: Literal[
"counter",
"telephone",
"paper",
"email",
"mobile",
"representative",
"offline_import",
]
affected_party_ref: str = Field(min_length=1, max_length=255)
represented_party_ref: str | None = Field(
default=None, min_length=1, max_length=255
)
authority_basis: str = Field(min_length=1, max_length=255)
purpose: str = Field(min_length=1, max_length=500)
legal_basis_ref: str | None = Field(default=None, min_length=1, max_length=255)
consent_basis: str | None = Field(default=None, min_length=1, max_length=255)
notice_given: bool
responsible_function_ref: str = Field(min_length=1, max_length=255)
language: str = Field(min_length=1, max_length=35)
accessibility_needs: list[str] = Field(default_factory=list, max_length=30)
field_sources: dict[str, AssistedFieldSourceRequest] = Field(default_factory=dict)
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
class AssistedFormConfirmationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
values: dict[str, Any]
attachment_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=1000)
signature_refs: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
outcome: Literal["confirmed", "corrected", "confirmation_unavailable"]
method: Literal[
"spoken_readback", "written_preview", "accessible_copy", "unavailable"
]
confirmed_by_ref: str = Field(min_length=1, max_length=255)
confirmed_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
field_sources: dict[str, AssistedFieldSourceRequest]
correction_note: str | None = Field(default=None, min_length=1, max_length=1000)
metadata: dict[str, Any] = Field(default_factory=dict)
class FormEvidenceGrantCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -166,6 +230,9 @@ class FormInstanceEventsResponse(BaseModel):
__all__ = [
"AssistedFieldSourceRequest",
"AssistedFormConfirmationRequest",
"AssistedFormStartRequest",
"FormAcknowledgementRequest",
"FormDraftUpdateRequest",
"FormEvidenceGrantCreateRequest",
+64 -1
View File
@@ -34,10 +34,13 @@ from govoplan_core.core.institutional import (
ServiceLaunchResult,
)
from govoplan_forms_runtime.backend.db.models import (
FormAssistedConfirmation,
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
FormIntakeSession,
)
from govoplan_forms_runtime.backend.assisted import assisted_submission_payload_sha256
from govoplan_forms_runtime.backend.domain import FormInstance
from govoplan_forms_runtime.backend.evidence import FormEvidenceCoordinator
@@ -329,7 +332,7 @@ class FormRuntimeService:
action="submit",
instance=current,
)
return self._revise(
submitted = self._revise(
session,
principal,
current=current,
@@ -348,6 +351,21 @@ class FormRuntimeService:
definition=definition,
allowed_current_statuses=("started", "draft"),
)
assisted_session = (
session.query(FormIntakeSession)
.filter(
FormIntakeSession.tenant_id == submitted.tenant_id,
FormIntakeSession.instance_id == submitted.instance_id,
FormIntakeSession.mode == "assisted",
)
.one_or_none()
)
if assisted_session is not None and assisted_session.status != "submitted":
assisted_session.status = "submitted"
assisted_session.submitted_at = recorded_at
session.add(assisted_session)
session.flush()
return submitted
def transition_instance(
self,
@@ -821,6 +839,46 @@ class FormRuntimeService:
)
if replay is not None:
return replay
if operation == "submitted" and _is_assisted_instance(current):
assisted_session = (
session.query(FormIntakeSession)
.filter(
FormIntakeSession.tenant_id == current.tenant_id,
FormIntakeSession.instance_id == current.instance_id,
FormIntakeSession.mode == "assisted",
)
.one_or_none()
)
if (
assisted_session is None
or assisted_session.status != "active"
or _aware(assisted_session.expires_at) <= recorded_at
):
raise FormRuntimeError(
"Assisted intake submission requires an active, unexpired session."
)
expected_confirmation_digest = assisted_submission_payload_sha256(
current,
values=clean_values,
attachment_refs=attachments,
signature_refs=signatures,
)
confirmation = (
session.query(FormAssistedConfirmation)
.filter(
FormAssistedConfirmation.tenant_id == current.tenant_id,
FormAssistedConfirmation.instance_id == current.instance_id,
FormAssistedConfirmation.instance_revision == current.revision,
FormAssistedConfirmation.payload_sha256
== expected_confirmation_digest,
)
.order_by(FormAssistedConfirmation.confirmed_at.desc())
.first()
)
if confirmation is None:
raise FormRuntimeError(
"Assisted intake submission requires a current read-back confirmation for the exact values and evidence."
)
if allowed_current_statuses is not None and current.status not in set(
allowed_current_statuses
):
@@ -1123,6 +1181,11 @@ def validate_form_values(
return tuple(diagnostics)
def _is_assisted_instance(instance: FormInstance) -> bool:
intake = instance.metadata.get("intake")
return isinstance(intake, Mapping) and intake.get("mode") == "assisted"
def normalize_form_values(
definition: FormDefinition,
values: Mapping[str, object],
+207
View File
@@ -27,6 +27,7 @@ from govoplan_forms.backend.service import (
)
from govoplan_forms_runtime.backend.db.models import (
FormAcknowledgement,
FormAssistedConfirmation,
FormHandoffEffect,
FormInstanceEvent,
FormInstanceIdentity,
@@ -119,6 +120,7 @@ class FormsRuntimeTests(unittest.TestCase):
FormHandoffEffect.__table__,
FormIntakeProfile.__table__,
FormIntakeSession.__table__,
FormAssistedConfirmation.__table__,
FormAcknowledgement.__table__,
):
table.create(self.engine)
@@ -780,6 +782,211 @@ class FormsRuntimeTests(unittest.TestCase):
now=NOW + timedelta(minutes=1),
)
def test_assisted_intake_is_resumable_and_requires_exact_readback_evidence(
self,
) -> None:
intake = FormIntakeService(self.registry)
profile = intake.create_profile(
self.session,
self.principal,
definition_ref=self.definition.reference,
mode="assisted",
custodian_ref="function:parking-permits",
recorded_at=NOW,
)
started = intake.start_assisted(
self.session,
self.principal,
profile_id=profile.profile_id,
values={"name": "Ada", "delivery": "mail"},
channel="counter",
affected_party_ref="party:resident-1",
represented_party_ref=None,
authority_basis="self",
purpose="Apply for a resident parking permit.",
legal_basis_ref="law:resident-parking",
consent_basis="verbal-consent",
notice_given=True,
responsible_function_ref="function:parking-permits",
language="de",
accessibility_needs=("plain-language",),
field_sources={
"name": {
"source": "person_statement",
"confidence": "stated",
"declared_by_ref": "party:resident-1",
},
"delivery": {
"source": "person_statement",
"confidence": "stated",
"declared_by_ref": "party:resident-1",
},
},
idempotency_key="assisted-start-1",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertIsNone(started.token)
self.assertEqual("assisted", started.mode)
self.assertEqual("counter", started.instance.metadata["intake"]["channel"])
replay = intake.start_assisted(
self.session,
self.principal,
profile_id=profile.profile_id,
values={"name": "Ada", "delivery": "mail"},
channel="counter",
affected_party_ref="party:resident-1",
represented_party_ref=None,
authority_basis="self",
purpose="Apply for a resident parking permit.",
legal_basis_ref="law:resident-parking",
consent_basis="verbal-consent",
notice_given=True,
responsible_function_ref="function:parking-permits",
language="de",
accessibility_needs=("plain-language",),
field_sources={
"name": {
"source": "person_statement",
"confidence": "stated",
"declared_by_ref": "party:resident-1",
},
"delivery": {
"source": "person_statement",
"confidence": "stated",
"declared_by_ref": "party:resident-1",
},
},
idempotency_key="assisted-start-1",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertTrue(replay.replayed)
self.assertEqual(started.instance.instance_id, replay.instance.instance_id)
with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=started.instance.instance_id,
expected_revision=1,
values=started.instance.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="assisted-submit-unconfirmed",
recorded_at=NOW + timedelta(minutes=2),
)
with self.assertRaisesRegex(FormIntakeError, "Save assisted corrections"):
intake.record_assisted_confirmation(
self.session,
self.principal,
instance_id=started.instance.instance_id,
expected_revision=1,
values={"name": "Ada Lovelace", "delivery": "mail"},
attachment_refs=(),
signature_refs=(),
outcome="corrected",
method="spoken_readback",
confirmed_by_ref="party:resident-1",
confirmed_at=NOW + timedelta(minutes=2),
idempotency_key="readback-unsaved",
field_sources={
key: {"source": "person_statement", "confidence": "stated"}
for key in ("name", "delivery")
},
correction_note="Applicant corrected the full name.",
)
confirmed = intake.record_assisted_confirmation(
self.session,
self.principal,
instance_id=started.instance.instance_id,
expected_revision=1,
values=started.instance.values,
attachment_refs=(),
signature_refs=(),
outcome="confirmed",
method="spoken_readback",
confirmed_by_ref="party:resident-1",
confirmed_at=NOW + timedelta(minutes=3),
idempotency_key="readback-1",
field_sources={
key: {"source": "person_statement", "confidence": "stated"}
for key in ("name", "delivery")
},
)
self.assertEqual("confirmed", confirmed.outcome)
corrected_draft = self.runtime.update_draft(
self.session,
self.principal,
instance_id=started.instance.instance_id,
expected_revision=1,
values={"name": "Ada Lovelace", "delivery": "mail"},
attachment_refs=(),
signature_refs=(),
idempotency_key="assisted-correction-save",
recorded_at=NOW + timedelta(minutes=4),
change_reason="Correct the applicant name after read-back.",
)
with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=corrected_draft.instance_id,
expected_revision=2,
values=corrected_draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="assisted-submit-stale-readback",
recorded_at=NOW + timedelta(minutes=5),
)
corrected = intake.record_assisted_confirmation(
self.session,
self.principal,
instance_id=corrected_draft.instance_id,
expected_revision=2,
values=corrected_draft.values,
attachment_refs=(),
signature_refs=(),
outcome="corrected",
method="written_preview",
confirmed_by_ref="party:resident-1",
confirmed_at=NOW + timedelta(minutes=6),
idempotency_key="readback-2",
field_sources={
key: {"source": "person_statement", "confidence": "stated"}
for key in ("name", "delivery")
},
correction_note="Applicant corrected the full name.",
)
self.assertNotEqual(confirmed.payload_sha256, corrected.payload_sha256)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=corrected_draft.instance_id,
expected_revision=2,
values=corrected_draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="assisted-submit-confirmed",
recorded_at=NOW + timedelta(minutes=7),
)
self.assertEqual("submitted", submitted.status)
stored_session = (
self.session.query(FormIntakeSession)
.filter(FormIntakeSession.instance_id == submitted.instance_id)
.one()
)
self.assertEqual("submitted", stored_session.status)
self.assertEqual(
2,
len(
intake.list_assisted_confirmations(
self.session,
self.principal,
instance_id=submitted.instance_id,
)
),
)
def test_native_acknowledgement_is_bound_to_actor_and_exact_payload(self) -> None:
draft = self.runtime.create_instance(
self.session,
+2 -1
View File
@@ -4,6 +4,7 @@ import unittest
from govoplan_forms_runtime.backend.manifest import (
ADMIN_SCOPE,
ASSIST_SCOPE,
PARTICIPATE_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
@@ -19,7 +20,7 @@ class ManifestTests(unittest.TestCase):
self.assertEqual(manifest.dependencies, ("access", "forms"))
self.assertEqual(
{permission.scope for permission in manifest.permissions},
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
{PARTICIPATE_SCOPE, ASSIST_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
)
participant = next(
item
+2 -1
View File
@@ -34,12 +34,13 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
"form_handoff_effects",
"form_intake_profiles",
"form_intake_sessions",
"form_assisted_confirmations",
"form_acknowledgements",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"b4e6f8a0c2d3",
"c5f7a9b1d3e4",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
+137 -3
View File
@@ -40,7 +40,7 @@ export type FormIntakeProfile = {
profile_id: string;
public_id: string;
definition_ref: InstitutionalReference;
mode: "anonymous" | "invitation";
mode: "anonymous" | "invitation" | "assisted";
enabled: boolean;
revision: number;
draft_ttl_seconds: number;
@@ -51,7 +51,7 @@ export type FormIntakeProfile = {
export type PublicIntakeResult = {
session_id: string;
mode: "anonymous" | "invitation";
mode: "anonymous" | "invitation" | "assisted";
status: string;
expires_at: string;
instance?: FormInstance | null;
@@ -59,6 +59,43 @@ export type PublicIntakeResult = {
replayed: boolean;
};
export type AssistedIntakeContext = {
session_id: string;
profile_id: string;
mode: "assisted";
channel: "counter" | "telephone" | "paper" | "email" | "mobile" | "representative" | "offline_import";
affected_party_ref: string;
represented_party_ref?: string | null;
authority_basis: string;
purpose: string;
legal_basis_ref?: string | null;
consent_basis?: string | null;
notice_given: boolean;
responsible_function_ref: string;
language: string;
accessibility_needs: string[];
field_sources: Record<string, {
source: "person_statement" | "representative_statement" | "document" | "system" | "derived";
confidence: "stated" | "verified" | "uncertain";
declared_by_ref?: string | null;
}>;
operator: Record<string, unknown>;
};
export type AssistedConfirmation = {
confirmation_id: string;
instance_id: string;
instance_revision: number;
outcome: "confirmed" | "corrected" | "confirmation_unavailable";
method: "spoken_readback" | "written_preview" | "accessible_copy" | "unavailable";
confirmed_by_ref: string;
operator_actor_id: string;
confirmed_at: string;
payload_sha256: string;
correction_note?: string | null;
metadata: Record<string, unknown>;
};
export type FormCondition =
| { kind: "predicate"; field_key: string; operator: string; value?: unknown }
| { kind: "all" | "any" | "not"; conditions: FormCondition[] };
@@ -139,6 +176,7 @@ export type FormInstance = {
change_reason: string;
created_by: string;
changed_by: string;
metadata: Record<string, unknown>;
replayed: boolean;
};
@@ -349,7 +387,7 @@ export function listFormIntakeDefinitions(
export function createFormIntakeProfile(
settings: ApiSettings,
definitionRef: InstitutionalReference,
mode: "anonymous" | "invitation",
mode: "anonymous" | "invitation" | "assisted",
options: {
draftTtlSeconds?: number;
invitationTtlSeconds?: number;
@@ -369,6 +407,102 @@ export function createFormIntakeProfile(
});
}
export function listAssistedIntakeProfiles(
settings: ApiSettings,
signal?: AbortSignal
): Promise<{ profiles: FormIntakeProfile[] }> {
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/profiles", { signal });
}
export function startAssistedFormIntake(
settings: ApiSettings,
options: {
profileId: string;
channel: AssistedIntakeContext["channel"];
affectedPartyRef: string;
representedPartyRef?: string;
authorityBasis: string;
purpose: string;
legalBasisRef?: string;
consentBasis?: string;
noticeGiven: boolean;
responsibleFunctionRef: string;
language: string;
accessibilityNeeds: string[];
}
): Promise<PublicIntakeResult> {
return apiFetch(settings, "/api/v1/forms-runtime/assisted-intake/start", {
method: "POST",
body: JSON.stringify({
profile_id: options.profileId,
values: {},
channel: options.channel,
affected_party_ref: options.affectedPartyRef,
represented_party_ref: options.representedPartyRef?.trim() || null,
authority_basis: options.authorityBasis,
purpose: options.purpose,
legal_basis_ref: options.legalBasisRef?.trim() || null,
consent_basis: options.consentBasis?.trim() || null,
notice_given: options.noticeGiven,
responsible_function_ref: options.responsibleFunctionRef,
language: options.language,
accessibility_needs: options.accessibilityNeeds,
field_sources: {},
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
})
});
}
export function listAssistedConfirmations(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ confirmations: AssistedConfirmation[] }> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/assisted-confirmations`,
{ signal }
);
}
export function recordAssistedConfirmation(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
attachmentRefs: EvidenceReference[],
signatureRefs: EvidenceReference[],
options: {
outcome: AssistedConfirmation["outcome"];
method: AssistedConfirmation["method"];
confirmedByRef: string;
fieldSources: AssistedIntakeContext["field_sources"];
correctionNote?: string;
}
): Promise<AssistedConfirmation> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/assisted-confirmations`,
{
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: attachmentRefs,
signature_refs: signatureRefs,
outcome: options.outcome,
method: options.method,
confirmed_by_ref: options.confirmedByRef,
confirmed_at: new Date().toISOString(),
idempotency_key: crypto.randomUUID(),
field_sources: options.fieldSources,
correction_note: options.correctionNote?.trim() || null,
metadata: {}
})
}
);
}
export function setFormIntakeProfileEnabled(
settings: ApiSettings,
profile: FormIntakeProfile,
@@ -0,0 +1,216 @@
import { Play } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
Dialog,
DialogForm,
DialogSection,
DismissibleAlert,
FormField,
FormGrid,
LoadingIndicator,
ToggleSwitch,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
listAssistedIntakeProfiles,
startAssistedFormIntake,
type AssistedIntakeContext,
type FormIntakeProfile
} from "../../api/formsRuntime";
type AssistedIntakeDialogProps = {
open: boolean;
settings: PlatformRouteContext["settings"];
language: string;
onStarted: (instanceId: string) => void;
onClose: () => void;
};
export default function AssistedIntakeDialog({
open,
settings,
language,
onStarted,
onClose
}: AssistedIntakeDialogProps) {
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
const [profileId, setProfileId] = useState("");
const [channel, setChannel] = useState<AssistedIntakeContext["channel"]>("counter");
const [affectedPartyRef, setAffectedPartyRef] = useState("");
const [representedPartyRef, setRepresentedPartyRef] = useState("");
const [authorityBasis, setAuthorityBasis] = useState("self");
const [purpose, setPurpose] = useState("");
const [legalBasisRef, setLegalBasisRef] = useState("");
const [consentBasis, setConsentBasis] = useState("");
const [noticeGiven, setNoticeGiven] = useState(false);
const [responsibleFunctionRef, setResponsibleFunctionRef] = useState("");
const [accessibilityNeeds, setAccessibilityNeeds] = useState("");
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const result = await listAssistedIntakeProfiles(settings, signal);
setProfiles(result.profiles);
setProfileId((current) => current || result.profiles[0]?.profile_id || "");
} finally {
setLoading(false);
}
}, [settings]);
useEffect(() => {
if (!open) return undefined;
const controller = new AbortController();
void load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Assisted intake profiles could not be loaded.");
}
});
return () => controller.abort();
}, [load, open]);
async function start() {
if (!valid()) return;
setBusy(true);
setError("");
try {
const result = await startAssistedFormIntake(settings, {
profileId,
channel,
affectedPartyRef: affectedPartyRef.trim(),
representedPartyRef: representedPartyRef.trim() || undefined,
authorityBasis,
purpose: purpose.trim(),
legalBasisRef: legalBasisRef.trim() || undefined,
consentBasis: consentBasis.trim() || undefined,
noticeGiven,
responsibleFunctionRef: responsibleFunctionRef.trim(),
language: language || "de",
accessibilityNeeds: accessibilityNeeds.split(/[,\n]/).map((item) => item.trim()).filter(Boolean)
});
if (!result.instance) throw new Error("The assisted session was created without a Form instance.");
onStarted(result.instance.instance_id);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The assisted intake could not be started.");
} finally {
setBusy(false);
}
}
function valid() {
return Boolean(
profileId
&& affectedPartyRef.trim()
&& authorityBasis.trim()
&& purpose.trim()
&& responsibleFunctionRef.trim()
);
}
return (
<Dialog
open={open}
title="Start assisted intake"
description="Capture who is acting, for whom, through which channel, and for what purpose before entering Form values."
size="large"
closeDisabled={busy}
onClose={onClose}
helpContextId="forms_runtime.assisted-intake"
helpTopicId="forms_runtime.submissions"
footer={
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={() => void start()} disabled={busy || loading || !valid()}>
<Play size={16} aria-hidden="true" />
Start session
</Button>
</>
}>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{loading && <LoadingIndicator label="Loading assisted intake profiles" />}
{!loading && profiles.length === 0 &&
<DismissibleAlert tone="info">
No assisted intake profile is enabled. Ask a Forms Runtime administrator to add one for the published Form.
</DismissibleAlert>
}
{!loading && profiles.length > 0 &&
<DialogForm onSubmit={(event) => { event.preventDefault(); void start(); }}>
<DialogSection>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<FormField label="Published Form">
<select value={profileId} onChange={(event) => setProfileId(event.target.value)} disabled={busy}>
{profiles.map((profile) =>
<option key={profile.profile_id} value={profile.profile_id}>
{profileTitle(profile)} · revision {profile.definition_ref.version}
</option>
)}
</select>
</FormField>
<FormField label="Intake channel">
<select value={channel} onChange={(event) => setChannel(event.target.value as AssistedIntakeContext["channel"])} disabled={busy}>
<option value="counter">Service counter</option>
<option value="telephone">Telephone</option>
<option value="paper">Paper</option>
<option value="email">Email</option>
<option value="mobile">Mobile service</option>
<option value="representative">Representative</option>
<option value="offline_import">Offline import</option>
</select>
</FormField>
<FormField label="Affected party reference" help="Use the governed person or organization reference; do not enter a display name only.">
<input value={affectedPartyRef} onChange={(event) => setAffectedPartyRef(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Represented party reference" help="Optional when the affected party is acting directly.">
<input value={representedPartyRef} onChange={(event) => setRepresentedPartyRef(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Authority basis">
<select value={authorityBasis} onChange={(event) => setAuthorityBasis(event.target.value)} disabled={busy}>
<option value="self">Acting for self</option>
<option value="documented_representation">Documented representation</option>
<option value="legal_guardianship">Legal guardianship</option>
<option value="statutory_authority">Statutory authority</option>
</select>
</FormField>
<FormField label="Responsible function reference">
<input value={responsibleFunctionRef} onChange={(event) => setResponsibleFunctionRef(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Purpose">
<input value={purpose} onChange={(event) => setPurpose(event.target.value)} disabled={busy} required />
</FormField>
<FormField label="Legal basis reference">
<input value={legalBasisRef} onChange={(event) => setLegalBasisRef(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Consent basis">
<input value={consentBasis} onChange={(event) => setConsentBasis(event.target.value)} disabled={busy} />
</FormField>
<FormField label="Accessibility or communication support" help="Separate multiple needs with commas.">
<input value={accessibilityNeeds} onChange={(event) => setAccessibilityNeeds(event.target.value)} disabled={busy} />
</FormField>
</FormGrid>
</DialogSection>
<DialogSection variant="inset">
<ToggleSwitch
label="Privacy and procedural notice was provided"
checked={noticeGiven}
onChange={setNoticeGiven}
disabled={busy}
help="Record the fact of notice here; retain any separately required evidence through its owning module."
/>
</DialogSection>
</DialogForm>
}
</Dialog>
);
}
function profileTitle(profile: FormIntakeProfile): string {
const title = profile.metadata.definition_title;
return typeof title === "string" && title.trim()
? title
: profile.definition_ref.label ?? profile.definition_ref.object_id;
}
+215 -6
View File
@@ -1,13 +1,18 @@
import { Archive, ArrowLeft, BadgeCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { Archive, ArrowLeft, BadgeCheck, ClipboardCheck, ExternalLink, RefreshCw, Save, Send, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import { ActionToolbar,
ActionBlockerHint,
Button,
ConfirmDialog,
DescriptionItem,
DescriptionList,
Dialog,
DocumentationHelpLink,
DismissibleAlert,
FileDropZone,
FormField as CoreFormField,
FormGrid,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
@@ -27,6 +32,8 @@ import {
getFormInstanceEvents,
getFormInstanceHistory,
listFormHandoffs,
listAssistedConfirmations,
recordAssistedConfirmation,
startFormHandoff,
actOnFormHandoff,
acknowledgeFormInstance,
@@ -36,6 +43,8 @@ import {
submitFormInstance,
uploadFormEvidence,
type EvidenceReference,
type AssistedConfirmation,
type AssistedIntakeContext,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
@@ -75,23 +84,37 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const [confirmingSubmit, setConfirmingSubmit] = useState(false);
const [confirmingHandoff, setConfirmingHandoff] = useState(false);
const [confirmingAcknowledgement, setConfirmingAcknowledgement] = useState(false);
const [confirmingAssisted, setConfirmingAssisted] = useState(false);
const [assistedConfirmations, setAssistedConfirmations] = useState<AssistedConfirmation[]>([]);
const [assistedOutcome, setAssistedOutcome] = useState<AssistedConfirmation["outcome"]>("confirmed");
const [assistedMethod, setAssistedMethod] = useState<AssistedConfirmation["method"]>("spoken_readback");
const [assistedConfirmedBy, setAssistedConfirmedBy] = useState("");
const [assistedSource, setAssistedSource] = useState<AssistedIntakeContext["field_sources"][string]["source"]>("person_statement");
const [assistedConfidence, setAssistedConfidence] = useState<AssistedIntakeContext["field_sources"][string]["confidence"]>("stated");
const [assistedNote, setAssistedNote] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const nextInstance = await getFormInstance(settings, instanceId, signal);
const [nextDefinition, nextHistory, nextEvents, nextHandoffs] = await Promise.all([
const assisted = intakeContext(nextInstance);
const [nextDefinition, nextHistory, nextEvents, nextHandoffs, nextConfirmations] = await Promise.all([
getFormDefinition(settings, instanceId, signal),
getFormInstanceHistory(settings, instanceId, signal),
getFormInstanceEvents(settings, instanceId, signal),
listFormHandoffs(settings, instanceId, signal)
listFormHandoffs(settings, instanceId, signal),
assisted
? listAssistedConfirmations(settings, instanceId, signal)
: Promise.resolve({ confirmations: [] })
]);
setInstance(nextInstance);
setDefinition(nextDefinition);
setHistory(nextHistory.revisions);
setEvents(nextEvents.events);
setHandoffs(nextHandoffs.handoffs);
setAssistedConfirmations(nextConfirmations.confirmations);
setAssistedConfirmedBy((current) => current || assisted?.affected_party_ref || "");
setValues(nextInstance.values);
setAttachmentRefs(nextInstance.attachment_refs);
setSignatureRefs(nextInstance.signature_refs);
@@ -156,6 +179,13 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
const attachmentLimitReached = Boolean(
definition && attachmentRefs.length >= definition.max_attachments
);
const assisted = intakeContext(instance);
const assistedConfirmationCurrent = Boolean(
assisted
&& !changed
&& assistedConfirmations.some((item) => item.instance_revision === instance?.revision)
);
const assistedSaveBlocked = Boolean(assisted && changed);
async function save(): Promise<boolean> {
if (!instance || !canSave || !changed || !changeReason.trim()) return false;
@@ -200,6 +230,43 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
}
async function confirmAssistedReadback() {
if (!instance || !assisted || !assistedConfirmedBy.trim()) return;
setSaving(true);
setError("");
try {
const confirmation = await recordAssistedConfirmation(
settings,
instance,
values,
attachmentRefs,
signatureRefs,
{
outcome: assistedOutcome,
method: assistedMethod,
confirmedByRef: assistedConfirmedBy.trim(),
fieldSources: Object.fromEntries(
Object.entries(values)
.filter(([, value]) => value !== null && value !== undefined && value !== "")
.map(([key]) => [key, {
source: assistedSource,
confidence: assistedConfidence,
declared_by_ref: assistedConfirmedBy.trim()
}])
),
correctionNote: assistedNote
}
);
setAssistedConfirmations((current) => [confirmation, ...current]);
setConfirmingAssisted(false);
setConfirmingSubmit(true);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The assisted read-back confirmation could not be recorded.");
} finally {
setSaving(false);
}
}
function changeValue(fieldKey: string, value: unknown) {
setValues((current) => {
const next = { ...current };
@@ -358,6 +425,33 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
<h1>{localized.title}</h1>
{localized.description && <p>{localized.description}</p>}
</header>
{assisted &&
<section className="form-assisted-context">
<div className="form-evidence-heading">
<h2>Assisted intake context</h2>
<StatusBadge
status={assistedConfirmationCurrent ? "active" : "inactive"}
label={assistedConfirmationCurrent ? "Read-back current" : "Read-back required"}
/>
</div>
<DescriptionList columns={3} collapseAt="workspace" density="compact">
<DescriptionItem term="Channel">{humanize(assisted.channel)}</DescriptionItem>
<DescriptionItem term="Affected party">{assisted.affected_party_ref}</DescriptionItem>
{assisted.represented_party_ref && <DescriptionItem term="Represented party">{assisted.represented_party_ref}</DescriptionItem>}
<DescriptionItem term="Authority">{humanize(assisted.authority_basis)}</DescriptionItem>
<DescriptionItem term="Responsible function">{assisted.responsible_function_ref}</DescriptionItem>
<DescriptionItem term="Purpose">{assisted.purpose}</DescriptionItem>
<DescriptionItem term="Notice">{assisted.notice_given ? "Provided" : "Not recorded"}</DescriptionItem>
<DescriptionItem term="Language">{assisted.language}</DescriptionItem>
{assisted.accessibility_needs.length > 0 &&
<DescriptionItem term="Communication support">{assisted.accessibility_needs.join(", ")}</DescriptionItem>
}
</DescriptionList>
<p className="form-assisted-context-note">
This context records provenance and does not bypass the published Form rules. Any saved correction invalidates the earlier read-back for submission.
</p>
</section>
}
{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>}
@@ -453,9 +547,19 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
Save draft
</Button>
}
<Button variant="primary" onClick={() => setConfirmingSubmit(true)} disabled={saving || signatureBlocked} disabledReason={saving ? FORMS_RUNTIME_I18N.saving : signatureBlocked ? "Record the required acknowledgement before submitting." : undefined}>
<Send size={16} aria-hidden="true" />
Submit
<Button
variant="primary"
onClick={() => assisted && !assistedConfirmationCurrent ? setConfirmingAssisted(true) : setConfirmingSubmit(true)}
disabled={saving || signatureBlocked || assistedSaveBlocked}
disabledReason={saving
? FORMS_RUNTIME_I18N.saving
: signatureBlocked
? "Record and save the required acknowledgement before submitting."
: assistedSaveBlocked
? "Save assisted corrections and evidence before read-back."
: undefined}>
{assisted && !assistedConfirmationCurrent ? <ClipboardCheck size={16} aria-hidden="true" /> : <Send size={16} aria-hidden="true" />}
{assisted && !assistedConfirmationCurrent ? "Read back and submit" : "Submit"}
</Button>
</div>
}
@@ -529,6 +633,74 @@ export default function FormInstancePage({ settings, auth }: PlatformRouteContex
}
</PageScrollViewport>
</WorkspaceFrame>
<Dialog
open={confirmingAssisted}
title="Record assisted read-back"
description="Make the exact values and managed evidence available to the confirming party. Record corrections before continuing; the server binds this evidence to the current revision and submission payload."
closeDisabled={saving}
onClose={() => setConfirmingAssisted(false)}
helpContextId="forms_runtime.assisted-confirmation"
helpTopicId="forms_runtime.submissions"
footer={
<>
<Button onClick={() => setConfirmingAssisted(false)} disabled={saving}>Cancel</Button>
<Button
variant="primary"
disabled={saving || !assistedConfirmedBy.trim() || (assistedOutcome !== "confirmed" && !assistedNote.trim())}
onClick={() => void confirmAssistedReadback()}>
<ClipboardCheck size={16} aria-hidden="true" />
Record and continue
</Button>
</>
}>
<FormGrid columns={2} gap="small" collapseAt="workspace">
<CoreFormField label="Outcome">
<select
value={assistedOutcome}
disabled={saving}
onChange={(event) => {
const outcome = event.target.value as AssistedConfirmation["outcome"];
setAssistedOutcome(outcome);
if (outcome === "confirmation_unavailable") setAssistedMethod("unavailable");
else if (assistedMethod === "unavailable") setAssistedMethod("spoken_readback");
}}>
<option value="confirmed">Confirmed without correction</option>
<option value="corrected">Corrected and confirmed</option>
<option value="confirmation_unavailable">Confirmation unavailable</option>
</select>
</CoreFormField>
<CoreFormField label="Confirmation method">
<select value={assistedMethod} onChange={(event) => setAssistedMethod(event.target.value as AssistedConfirmation["method"])} disabled={saving || assistedOutcome === "confirmation_unavailable"}>
<option value="spoken_readback">Spoken read-back</option>
<option value="written_preview">Written preview</option>
<option value="accessible_copy">Accessible copy</option>
{assistedOutcome === "confirmation_unavailable" && <option value="unavailable">Unavailable</option>}
</select>
</CoreFormField>
<CoreFormField label="Confirming party reference">
<input value={assistedConfirmedBy} onChange={(event) => setAssistedConfirmedBy(event.target.value)} disabled={saving} required />
</CoreFormField>
<CoreFormField label="Value source" help="Applied to each populated field in this first assisted-intake slice.">
<select value={assistedSource} onChange={(event) => setAssistedSource(event.target.value as typeof assistedSource)} disabled={saving}>
<option value="person_statement">Person statement</option>
<option value="representative_statement">Representative statement</option>
<option value="document">Document</option>
<option value="system">Existing system</option>
<option value="derived">Derived by operator</option>
</select>
</CoreFormField>
<CoreFormField label="Source confidence">
<select value={assistedConfidence} onChange={(event) => setAssistedConfidence(event.target.value as typeof assistedConfidence)} disabled={saving}>
<option value="stated">Stated</option>
<option value="verified">Verified</option>
<option value="uncertain">Uncertain</option>
</select>
</CoreFormField>
<CoreFormField label={assistedOutcome === "confirmed" ? "Note (optional)" : "Correction or exception note"}>
<textarea rows={3} value={assistedNote} onChange={(event) => setAssistedNote(event.target.value)} disabled={saving} required={assistedOutcome !== "confirmed"} />
</CoreFormField>
</FormGrid>
</Dialog>
<ConfirmDialog
open={confirmingAcknowledgement}
title="Record acknowledgement"
@@ -729,6 +901,43 @@ function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function intakeContext(instance: FormInstance | null): AssistedIntakeContext | null {
const value = instance?.metadata?.intake;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const context = value as Record<string, unknown>;
if (context.mode !== "assisted"
|| typeof context.session_id !== "string"
|| typeof context.profile_id !== "string"
|| typeof context.channel !== "string"
|| typeof context.affected_party_ref !== "string"
|| typeof context.authority_basis !== "string"
|| typeof context.purpose !== "string"
|| typeof context.responsible_function_ref !== "string"
|| typeof context.language !== "string") return null;
return {
session_id: context.session_id,
profile_id: context.profile_id,
mode: "assisted",
channel: context.channel as AssistedIntakeContext["channel"],
affected_party_ref: context.affected_party_ref,
represented_party_ref: typeof context.represented_party_ref === "string" ? context.represented_party_ref : null,
authority_basis: context.authority_basis,
purpose: context.purpose,
legal_basis_ref: typeof context.legal_basis_ref === "string" ? context.legal_basis_ref : null,
consent_basis: typeof context.consent_basis === "string" ? context.consent_basis : null,
notice_given: context.notice_given === true,
responsible_function_ref: context.responsible_function_ref,
language: context.language,
accessibility_needs: Array.isArray(context.accessibility_needs) ? context.accessibility_needs.map(String) : [],
field_sources: typeof context.field_sources === "object" && context.field_sources && !Array.isArray(context.field_sources)
? context.field_sources as AssistedIntakeContext["field_sources"]
: {},
operator: typeof context.operator === "object" && context.operator && !Array.isArray(context.operator)
? context.operator as Record<string, unknown>
: {}
};
}
export 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);
+21 -1
View File
@@ -1,4 +1,4 @@
import { Link2, RefreshCw } from "lucide-react";
import { Link2, RefreshCw, UserRoundPlus } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ActionToolbar,
Button,
@@ -21,6 +21,7 @@ import { ActionToolbar,
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
import { FORMS_RUNTIME_DOCUMENTATION, FORMS_RUNTIME_I18N } from "./interfacePatterns";
import IntakeProfilesDialog from "./IntakeProfilesDialog";
import AssistedIntakeDialog from "./AssistedIntakeDialog";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
@@ -34,7 +35,10 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [intakeOpen, setIntakeOpen] = useState(false);
const [assistedOpen, setAssistedOpen] = useState(false);
const canAdmin = hasScope(auth, "forms_runtime:workspace:admin");
const canAssist = hasScope(auth, "forms_runtime:submission:assist")
|| hasScope(auth, "forms_runtime:workspace:write");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
@@ -74,6 +78,12 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
Public intake
</Button>
}
{canAssist &&
<Button onClick={() => setAssistedOpen(true)}>
<UserRoundPlus size={16} aria-hidden="true" />
Assisted intake
</Button>
}
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
@@ -117,6 +127,16 @@ export default function FormsRuntimePage({ settings, auth }: PlatformRouteContex
</PageScrollViewport>
</WorkspaceFrame>
<IntakeProfilesDialog open={intakeOpen} settings={settings} onClose={() => setIntakeOpen(false)} />
<AssistedIntakeDialog
open={assistedOpen}
settings={settings}
language={language}
onClose={() => setAssistedOpen(false)}
onStarted={(instanceId) => {
setAssistedOpen(false);
navigate(`/forms-runtime/${encodeURIComponent(instanceId)}`);
}}
/>
</main>
);
}
@@ -30,7 +30,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
const [profiles, setProfiles] = useState<FormIntakeProfile[]>([]);
const [definitions, setDefinitions] = useState<FormDefinition[]>([]);
const [definitionId, setDefinitionId] = useState("");
const [mode, setMode] = useState<"anonymous" | "invitation">("invitation");
const [mode, setMode] = useState<FormIntakeProfile["mode"]>("invitation");
const [draftDays, setDraftDays] = useState(30);
const [invitationDays, setInvitationDays] = useState(14);
const [rateLimit, setRateLimit] = useState(60);
@@ -85,7 +85,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
rateLimitPerMinute: rateLimit
});
await load();
setNotice("The public intake profile was created.");
setNotice("The Form intake profile was created.");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The public intake profile could not be created.");
} finally {
@@ -138,7 +138,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
return (
<Dialog
open={open}
title="Public Form intake"
title="Form intake profiles"
className="form-intake-dialog"
closeDisabled={Boolean(busyKey)}
onClose={onClose}
@@ -165,9 +165,10 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</label>
<label>
<span>Access mode</span>
<select value={mode} onChange={(event) => setMode(event.target.value as "anonymous" | "invitation")} disabled={Boolean(busyKey)}>
<select value={mode} onChange={(event) => setMode(event.target.value as FormIntakeProfile["mode"])} disabled={Boolean(busyKey)}>
<option value="invitation">Invitation link</option>
<option value="anonymous">Open anonymous link</option>
<option value="assisted">Authenticated assisted session</option>
</select>
</label>
<label>
@@ -176,11 +177,11 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</label>
<label>
<span>Invitation validity (days)</span>
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "anonymous"} />
<input type="number" min={1} max={90} value={invitationDays} onChange={(event) => setInvitationDays(Number(event.target.value))} disabled={Boolean(busyKey) || mode !== "invitation"} />
</label>
<label>
<span>Starts per minute</span>
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey)} />
<input type="number" min={1} max={10_000} value={rateLimit} onChange={(event) => setRateLimit(Number(event.target.value))} disabled={Boolean(busyKey) || mode === "assisted"} />
</label>
<Button
variant="primary"
@@ -194,7 +195,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
</section>
<section className="form-intake-profiles">
<h3>Configured profiles</h3>
{profiles.length === 0 && <p className="form-intake-empty">No public intake profile has been configured.</p>}
{profiles.length === 0 && <p className="form-intake-empty">No Form intake profile has been configured.</p>}
{profiles.map((profile) => {
const definition = definitionsByKey.get(referenceKey(profile));
const link = profile.mode === "anonymous"
@@ -205,7 +206,7 @@ export default function IntakeProfilesDialog({ open, settings, onClose }: Intake
<div className="form-intake-profile-row" key={profile.profile_id}>
<span className="form-intake-profile-main">
<strong>{definition?.title ?? profile.definition_ref.label ?? profile.definition_ref.object_id}</strong>
<small>Revision {profile.definition_ref.version} · {profile.mode === "anonymous" ? "Anonymous link" : "Invitation links"}</small>
<small>Revision {profile.definition_ref.version} · {modeLabel(profile.mode)}</small>
</span>
<StatusBadge status={profile.enabled ? "active" : "inactive"} label={profile.enabled ? "Active" : "Inactive"} />
<ToggleSwitch label="Profile active" checked={profile.enabled} disabled={Boolean(busyKey)} onChange={(enabled) => void setEnabled(profile, enabled)} />
@@ -243,6 +244,12 @@ function referenceKey(profile: FormIntakeProfile): string {
return `${profile.definition_ref.object_id}:${profile.definition_ref.version ?? ""}`;
}
function modeLabel(mode: FormIntakeProfile["mode"]): string {
if (mode === "anonymous") return "Anonymous link";
if (mode === "invitation") return "Invitation links";
return "Authenticated assisted sessions";
}
function absolutePath(path: string): string {
return typeof window === "undefined" ? path : new URL(path, window.location.origin).toString();
}
+17
View File
@@ -261,6 +261,23 @@
width: min(980px, calc(100vw - 40px));
}
.form-assisted-context {
display: flex;
flex-direction: column;
gap: 12px;
margin: 16px 0 4px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-subtle);
}
.form-assisted-context-note {
margin: 0;
color: var(--text-soft);
font-size: 0.78rem;
}
.form-intake-create,
.form-intake-profiles {
display: flex;