Add governed form handoffs

This commit is contained in:
2026-08-01 20:57:26 +02:00
parent 9dc49fe27b
commit b4388b1e1e
15 changed files with 2236 additions and 89 deletions
+283 -13
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import UTC, datetime, timedelta
import unittest
@@ -9,17 +9,24 @@ from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
CAPABILITY_SERVICE_DEFINITIONS,
FormConditionExpression,
FormDefinition,
FormFieldDefinition,
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceLaunchRequest,
ServiceLaunchResult,
TemporalRevision,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import SqlFormDefinitionProvider, record_form_definition
from govoplan_forms.backend.service import (
SqlFormDefinitionProvider,
record_form_definition,
)
from govoplan_forms_runtime.backend.db.models import (
FormHandoffEffect,
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
@@ -29,6 +36,7 @@ from govoplan_forms_runtime.backend.service import (
FormRuntimeService,
FormsServiceLauncher,
)
from govoplan_forms_runtime.backend.handoffs import FormHandoffService
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@@ -41,16 +49,17 @@ class Principal:
class Registry:
def __init__(self, provider: object) -> None:
self.provider = provider
def __init__(self, provider: object, **capabilities: object) -> None:
self.capabilities = {
CAPABILITY_FORM_DEFINITIONS: provider,
**capabilities,
}
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_FORM_DEFINITIONS
return name in self.capabilities
def require_capability(self, name: str) -> object:
if name != CAPABILITY_FORM_DEFINITIONS:
raise KeyError(name)
return self.provider
return self.capabilities[name]
def form_definition(
@@ -71,9 +80,7 @@ def form_definition(
temporal=TemporalRevision(
revision=revision,
recorded_at=NOW + timedelta(minutes=int(revision) - 2),
change_reason=(
"Initial schema." if revision == "1" else "Revise schema."
),
change_reason=("Initial schema." if revision == "1" else "Revise schema."),
),
title="Permit form",
fields=(
@@ -105,6 +112,7 @@ class FormsRuntimeTests(unittest.TestCase):
FormInstanceIdentity.__table__,
FormInstanceRevision.__table__,
FormInstanceEvent.__table__,
FormHandoffEffect.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
@@ -316,12 +324,16 @@ class FormsRuntimeTests(unittest.TestCase):
request=request,
)
self.assertEqual("form_submission", first.target_ref.kind if first.target_ref else None)
self.assertEqual(
"form_submission", first.target_ref.kind if first.target_ref else None
)
self.assertEqual("1", first.metadata["form_definition_revision"])
self.assertTrue(replay.replayed)
self.assertEqual(first.target_ref.object_id, replay.target_ref.object_id)
def test_create_replay_is_actor_bound_and_survives_schema_supersession(self) -> None:
def test_create_replay_is_actor_bound_and_survives_schema_supersession(
self,
) -> None:
first = self.runtime.create_instance(
self.session,
self.principal,
@@ -413,6 +425,264 @@ class FormsRuntimeTests(unittest.TestCase):
instance_id="fixed-instance",
)
def test_hidden_required_fields_are_not_required_or_persisted(self) -> None:
conditional = replace(
form_definition(form_id="conditional-form"),
fields=(
FormFieldDefinition(
key="include_details",
label="Include details",
value_type="boolean",
),
FormFieldDefinition(
key="details",
label="Details",
required=True,
visibility_condition=FormConditionExpression(
kind="predicate",
field_key="include_details",
operator="eq",
value=True,
),
),
),
)
stored = record_form_definition(
self.session,
self.principal,
definition=conditional,
)
hidden = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=stored.reference,
values={"include_details": False, "details": "stale secret"},
idempotency_key="conditional-hidden",
recorded_at=NOW,
)
self.assertNotIn("details", hidden.values)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=hidden.instance_id,
expected_revision=1,
values=hidden.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="conditional-submit",
recorded_at=NOW + timedelta(minutes=1),
)
self.assertEqual("submitted", submitted.status)
shown = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=stored.reference,
values={"include_details": True},
idempotency_key="conditional-shown",
recorded_at=NOW,
)
with self.assertRaisesRegex(FormRuntimeError, "required"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=shown.instance_id,
expected_revision=1,
values=shown.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="conditional-shown-submit",
recorded_at=NOW + timedelta(minutes=1),
)
def test_native_handoff_is_durable_replay_safe_and_reconciles_unknown(self) -> None:
form_binding = ServiceBinding(kind="form", reference="permit-form/1")
case_binding = ServiceBinding(kind="case", reference="permit-case")
service = ServiceDefinition(
reference=InstitutionalReference(
kind="service",
owner_module="services",
object_id="permit-service",
tenant_id="tenant-1",
version="4",
),
key="permit-service",
temporal=TemporalRevision(
revision="4",
recorded_at=NOW - timedelta(minutes=2),
change_reason="Publish intake targets.",
),
title="Apply for permit",
audience=("resident",),
bindings=(form_binding, case_binding),
publication_state="published",
)
class Services:
def get_service_definition(
self, session, principal, *, reference, effective_at=None
):
return service if reference == service.reference else None
def list_service_definitions(
self, session, principal, *, tenant_id, query="", limit=100
):
return (service,)
class Cases:
fail_unknown = False
def launch_service(self, session, principal, *, definition, request):
if self.fail_unknown:
raise ConnectionError("Provider response was lost.")
return ServiceLaunchResult(
service_ref=definition.reference,
binding=request.binding,
state="started",
target_ref=InstitutionalReference(
kind="case",
owner_module="cases",
object_id="case-1",
tenant_id="tenant-1",
version="1",
),
href="/cases/case-1",
replayed=request.idempotency_key.startswith("known"),
metadata={"case_number": "CASE-1"},
)
cases = Cases()
registry = Registry(
SqlFormDefinitionProvider(),
**{
CAPABILITY_SERVICE_DEFINITIONS: Services(),
"cases.service_launcher": cases,
},
)
runtime = FormRuntimeService(registry)
draft = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="native-create",
recorded_at=NOW,
service_ref=service.reference,
service_binding=form_binding,
)
submitted = runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values=draft.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="native-submit",
recorded_at=NOW + timedelta(minutes=1),
)
handoffs = FormHandoffService(registry)
prepared = handoffs.prepare(
self.session,
self.principal,
instance_id=submitted.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference="permit-case",
idempotency_key="native-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.session.commit()
self.assertEqual("requested", prepared.state)
self.assertEqual(
"requested",
self.session.query(FormHandoffEffect).one().state,
)
accepted, revised = handoffs.execute(
self.session,
self.principal,
effect_id=prepared.effect_id,
executed_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("accepted", accepted.state)
self.assertEqual("handed_off", revised.status)
replay = handoffs.prepare(
self.session,
self.principal,
instance_id=submitted.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference="permit-case",
idempotency_key="native-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("accepted", replay.state)
second = runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Grace"},
idempotency_key="unknown-create",
recorded_at=NOW,
service_ref=service.reference,
service_binding=form_binding,
)
second = runtime.submit_instance(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=1,
values=second.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="unknown-submit",
recorded_at=NOW + timedelta(minutes=1),
)
unknown = handoffs.prepare(
self.session,
self.principal,
instance_id=second.instance_id,
expected_revision=2,
binding_kind="case",
binding_reference=None,
idempotency_key="unknown-handoff",
requested_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.session.commit()
cases.fail_unknown = True
unknown, _ = handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=2),
allow_all=True,
)
self.assertEqual("outcome_unknown", unknown.state)
with self.assertRaisesRegex(FormRuntimeError, "must be reconciled"):
handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=3),
allow_all=True,
)
cases.fail_unknown = False
reconciled, revised = handoffs.execute(
self.session,
self.principal,
effect_id=unknown.effect_id,
executed_at=NOW + timedelta(minutes=4),
allow_all=True,
reconcile=True,
)
self.assertEqual("reconciled", reconciled.state)
self.assertEqual("handed_off", revised.status)
if __name__ == "__main__":
unittest.main()
+5 -2
View File
@@ -14,7 +14,9 @@ from govoplan_forms_runtime.backend.manifest import get_manifest as get_runtime_
class FormsRuntimeMigrationTests(unittest.TestCase):
def test_fresh_migration_creates_runtime_store_and_head(self) -> None:
with tempfile.TemporaryDirectory(prefix="govoplan-forms-runtime-migration-") as directory:
with tempfile.TemporaryDirectory(
prefix="govoplan-forms-runtime-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'forms-runtime.db'}"
migrate_database(
database_url=url,
@@ -29,11 +31,12 @@ class FormsRuntimeMigrationTests(unittest.TestCase):
"form_instance_identities",
"form_instance_revisions",
"form_instance_events",
"form_handoff_effects",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"f2a3b4c5d6e7",
"a3d5f7b9c1e2",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally: