diff --git a/docs/architecture/ASSISTED_AND_NON_DIGITAL_CHANNELS.md b/docs/architecture/ASSISTED_AND_NON_DIGITAL_CHANNELS.md index 40c0415..d723667 100644 --- a/docs/architecture/ASSISTED_AND_NON_DIGITAL_CHANNELS.md +++ b/docs/architecture/ASSISTED_AND_NON_DIGITAL_CHANNELS.md @@ -72,6 +72,16 @@ An assisted session is a resumable work item, not a privileged bypass. It: 8. creates follow-up tasks when original documents, signatures, translation, or verification remain outstanding. +The first executable slice is implemented in Forms Runtime for authenticated +assisted sessions. Administrators enable an exact published Form revision; +operators then record channel, party and representation references, authority, +purpose, notice, responsible function, language, accessibility needs, and +field-level source/confidence provenance. Read-back outcomes are append-only and +payload-bound. A draft correction changes the Form revision and invalidates the +prior confirmation for submission. The resident-parking-permit fixture proves +resume and submission enforcement; browser accessibility and target archive +evidence remain acceptance work. + The helper's normal account and represented function remain in the audit chain. Assistance never grants access to unrelated records about the person. diff --git a/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md b/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md index e0ad02b..7cc7066 100644 --- a/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md +++ b/docs/strategy/REFERENCE_JOURNEY_PROGRAM.md @@ -57,8 +57,12 @@ all three contribute metadata-only native Search projections that can be rebuilt from authoritative state. The executable fixtures prove those native transitions without claiming archival custody. A persisted Workflow Engine handoff is now reloaded through the Tasks aggregation surface and remains -visible until the authoritative Workflow transition completes. The journey -still needs pinned-composition reconstruction evidence and one target-tested +visible until the authoritative Workflow transition completes. Authenticated +assisted intake now uses the same exact Form revision and validation as digital +intake while retaining purpose, authority, party, channel, accessibility, +source, correction, and payload-bound read-back evidence across a session +restart. The journey still needs browser accessibility evidence for both +channels, pinned-composition reconstruction evidence, and one target-tested archive profile. ## Why this sequence diff --git a/docs/strategy/STRATEGY_STATUS.md b/docs/strategy/STRATEGY_STATUS.md index fbfa637..feaa7fd 100644 --- a/docs/strategy/STRATEGY_STATUS.md +++ b/docs/strategy/STRATEGY_STATUS.md @@ -98,7 +98,7 @@ reconciliation, governed export/delivery, and browser-level handoff evidence. | --- | --- | --- | | No reference-ready product package | The platform cannot yet make a bounded supported-product claim | Complete one named target composition and evidence bundle | | Human-work spine is only an MVP | Tasks aggregates explicit work plus Workflow, Approval, and unread Postbox projections, but broad domain coverage, deadline escalation, assignment lifecycle, and focused product UX remain | Extend source providers through the three reference journeys and prove overdue/reassignment behavior in browser tests | -| Records/eAkte target integration incomplete | Native lifecycle, retention, holds, approval, recovery, and transfer simulation are implemented, but real custody is not proved | Target-test one archive/xdomea profile and complete the assisted reference journey | +| Records/eAkte target integration incomplete | Native lifecycle, retention, holds, approval, recovery, and transfer simulation are implemented, but real custody is not proved | Target-test one archive/xdomea profile and browser-test the now server-enforced assisted reference journey | | Cross-cutting governance adoption uneven | Historical and purpose-sensitive behavior varies by module | Enforced adoption declarations and route/query/effect migration | | Explicit help/accessibility depth incomplete | German/reference and F1 association gates now pass, but generic fallback remains too common | High-risk German help content and browser/a11y matrix | | Real federation absent | Cross-institution exchange remains connector-specific | Paired-instance signed exchange and reconciliation proof | @@ -111,8 +111,9 @@ reconciliation, governed export/delivery, and browser-level handoff evidence. 2. Complete governed communication and Postbox against a named target. 3. Complete the monthly-data flow and use it as the data foundation for sanctions screening. -4. Complete the browser and resumable-work proof for the digital and assisted - service-to-decision journey with its existing exact eAkte filing contracts. +4. Complete the browser proof for the digital and assisted service-to-decision + journey; server-side assisted resume, provenance, correction, and read-back + enforcement now complement its existing exact eAkte filing contracts. 5. Complete native PostgreSQL search coverage for remaining journey-owned objects and prove reauthorization and reindex operations at target volume; keep OpenSearch optional. Communication, Records, service-to-decision, diff --git a/packages/product/service-to-decision/README.md b/packages/product/service-to-decision/README.md index 71653e8..84cd46b 100644 --- a/packages/product/service-to-decision/README.md +++ b/packages/product/service-to-decision/README.md @@ -25,6 +25,14 @@ Service discovery -> exact Form revision -> validated draft/submission -> receipt and handoff evidence -> Case or Workflow owner ``` +The assisted path now creates an authenticated, resumable session against that +same exact Form revision. It records channel, affected and represented parties, +authority, purpose, notice, responsible function, language, accessibility +support, and field provenance. Submission fails closed until an immutable +read-back outcome matches the current revision, values, attachments, and +signatures. Saving a correction therefore requires a fresh confirmation rather +than silently reusing old evidence. + Services, Cases, Parties, Mandates, Committee, and Decisions retain immutable provider-owned revisions for the parts they own. Portal, Cases, and Committee consume capabilities for cross-module semantics only. The package does not @@ -59,9 +67,10 @@ The executable fixture in `tests/test_institutional_governance_journey.py` proves SQL-backed Service, Case, Party, Mandate, Committee meeting/agendum/vote/minute, and Decision state. `tests/test_institutional_service_journey.py` separately proves exact Portal -Form launch, persisted submission provenance, idempotent replay, and a durable -Workflow handoff that remains visible through Tasks after the database session -is reopened and disappears only after the Workflow Engine records completion. +Form launch, persisted submission provenance, idempotent replay, resumable +assisted intake with enforced read-back evidence, and a durable Workflow handoff +that remains visible through Tasks after the database session is reopened and +disappears only after the Workflow Engine records completion. Module-level Records source tests prove exact Form submission, Case revision, and Decision revision filing. Target-environment browser accessibility, production identity and delivery, a named archive profile, and recovery evidence diff --git a/tests/fixtures/resident_parking_permit_journey.json b/tests/fixtures/resident_parking_permit_journey.json index 628e6fc..9dd435d 100644 --- a/tests/fixtures/resident_parking_permit_journey.json +++ b/tests/fixtures/resident_parking_permit_journey.json @@ -25,6 +25,21 @@ "licence_plate": "B-AL 1843" } }, + "assisted_intake": { + "channel": "counter", + "affected_party_ref": "party:resident-ada-lovelace", + "represented_party_ref": null, + "authority_basis": "self", + "purpose": "Apply for a resident parking permit.", + "legal_basis_ref": "law:resident-parking-permit", + "consent_basis": "in-person-confirmation", + "notice_given": true, + "responsible_function_ref": "function:parking-permits", + "language": "de", + "accessibility_needs": ["plain-language"], + "confirmation_method": "written_preview", + "confirmation_outcome": "confirmed" + }, "case": { "type_key": "resident-parking-permit-application", "number": "RPP-2026-0001", @@ -51,6 +66,7 @@ "acceptance": { "automated": [ "The published service and exact form revision drive digital intake.", + "An authenticated assisted session uses the same exact form and validation rules while retaining purpose, authority, channel, party, accessibility, source, correction, and read-back provenance.", "An idempotent replay returns the same persisted submission.", "The human review handoff survives a database-session restart and remains visible in Tasks until completion.", "The formal decision retains party, mandate, legal-basis, evidence, delivery, review, and exact revision references.", @@ -58,7 +74,7 @@ ], "manual_or_target": [ "Complete the digital journey with keyboard and screen reader at desktop and mobile widths.", - "Repeat intake through an assisted-channel operator without weakening identity, purpose, or provenance.", + "Complete the assisted operator journey with keyboard and screen reader at desktop and mobile widths.", "Verify the configured Postbox or external delivery provider, including unknown outcome and reconciliation.", "Restore the pinned composition and reconstruct the exact form, case, decision, delivery evidence, and eAkte chronology.", "Transfer through a named archive profile and retain independently signed target evidence." diff --git a/tests/test_institutional_service_journey.py b/tests/test_institutional_service_journey.py index b538703..1edca6c 100644 --- a/tests/test_institutional_service_journey.py +++ b/tests/test_institutional_service_journey.py @@ -44,11 +44,16 @@ from govoplan_forms.backend.service import ( record_form_definition, ) from govoplan_forms_runtime.backend.db.models import ( + FormAssistedConfirmation, FormInstanceEvent, FormInstanceIdentity, FormInstanceRevision, + FormIntakeProfile, + FormIntakeSession, ) +from govoplan_forms_runtime.backend.intake import FormIntakeService from govoplan_forms_runtime.backend.service import ( + FormRuntimeError, FormRuntimeService, FormsServiceLauncher, ) @@ -243,7 +248,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase): def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None: self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"]) self.assertEqual("de-DE", JOURNEY["locale"]) - self.assertEqual(5, len(JOURNEY["acceptance"]["automated"])) + self.assertEqual(6, len(JOURNEY["acceptance"]["automated"])) self.assertEqual(5, len(JOURNEY["acceptance"]["manual_or_target"])) def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None: @@ -378,6 +383,162 @@ class InstitutionalServiceJourneyTests(unittest.TestCase): session.close() engine.dispose() + def test_assisted_intake_reuses_exact_form_and_persists_readback_provenance( + self, + ) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:") + for table in ( + FormDefinitionRevision.__table__, + FormInstanceIdentity.__table__, + FormInstanceRevision.__table__, + FormInstanceEvent.__table__, + FormIntakeProfile.__table__, + FormIntakeSession.__table__, + FormAssistedConfirmation.__table__, + ): + table.create(engine) + sessions = sessionmaker(bind=engine) + principal = _Principal() + assisted = JOURNEY["assisted_intake"] + try: + with sessions() as session: + form = record_form_definition( + session, + principal, + definition=FormDefinition( + reference=InstitutionalReference( + kind="form", + owner_module="forms", + object_id=JOURNEY["form"]["object_id"], + tenant_id="tenant-1", + version=JOURNEY["form"]["version"], + ), + key=JOURNEY["form"]["object_id"], + temporal=TemporalRevision( + revision=JOURNEY["form"]["version"], + recorded_at=NOW - timedelta(days=2), + change_reason="Publish the resident parking permit application.", + ), + title=JOURNEY["title"], + fields=tuple( + FormFieldDefinition( + key=key, + label=key.replace("_", " ").title(), + required=True, + constraints={"min_length": 2}, + ) + for key in JOURNEY["form"]["fields"] + ), + publication_state="published", + allow_drafts=True, + handoff_kinds=("case",), + ), + ) + registry = _FormRegistry(_service()) + intake = FormIntakeService(registry) + profile = intake.create_profile( + session, + principal, + definition_ref=form.reference, + mode="assisted", + custodian_ref=assisted["responsible_function_ref"], + recorded_at=NOW, + ) + started = intake.start_assisted( + session, + principal, + profile_id=profile.profile_id, + values=JOURNEY["form"]["fields"], + channel=assisted["channel"], + affected_party_ref=assisted["affected_party_ref"], + represented_party_ref=assisted["represented_party_ref"], + authority_basis=assisted["authority_basis"], + purpose=assisted["purpose"], + legal_basis_ref=assisted["legal_basis_ref"], + consent_basis=assisted["consent_basis"], + notice_given=assisted["notice_given"], + responsible_function_ref=assisted["responsible_function_ref"], + language=assisted["language"], + accessibility_needs=assisted["accessibility_needs"], + field_sources={ + key: { + "source": "person_statement", + "confidence": "stated", + "declared_by_ref": assisted["affected_party_ref"], + } + for key in JOURNEY["form"]["fields"] + }, + idempotency_key="resident-permit-assisted-start", + recorded_at=NOW + timedelta(minutes=1), + ) + self.assertEqual(form.reference, started.instance.definition_ref) + self.assertEqual(JOURNEY["form"]["fields"], started.instance.values) + self.assertEqual( + assisted["purpose"], started.instance.metadata["intake"]["purpose"] + ) + instance_id = started.instance.instance_id + session.commit() + + with sessions() as resumed: + runtime = FormRuntimeService(registry) + current = runtime.get_instance( + resumed, + principal, + instance_id=instance_id, + ) + self.assertIsNotNone(current) + with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"): + runtime.submit_instance( + resumed, + principal, + instance_id=instance_id, + expected_revision=current.revision, + values=current.values, + attachment_refs=(), + signature_refs=(), + idempotency_key="resident-permit-assisted-unconfirmed", + recorded_at=NOW + timedelta(minutes=2), + ) + confirmation = FormIntakeService(registry).record_assisted_confirmation( + resumed, + principal, + instance_id=instance_id, + expected_revision=current.revision, + values=current.values, + attachment_refs=(), + signature_refs=(), + outcome=assisted["confirmation_outcome"], + method=assisted["confirmation_method"], + confirmed_by_ref=assisted["affected_party_ref"], + confirmed_at=NOW + timedelta(minutes=3), + idempotency_key="resident-permit-assisted-readback", + field_sources={ + key: { + "source": "person_statement", + "confidence": "stated", + "declared_by_ref": assisted["affected_party_ref"], + } + for key in JOURNEY["form"]["fields"] + }, + ) + submitted = runtime.submit_instance( + resumed, + principal, + instance_id=instance_id, + expected_revision=current.revision, + values=current.values, + attachment_refs=(), + signature_refs=(), + idempotency_key="resident-permit-assisted-submit", + recorded_at=NOW + timedelta(minutes=4), + ) + resumed.commit() + self.assertEqual("submitted", submitted.status) + self.assertEqual(current.revision, confirmation.instance_revision) + self.assertEqual(assisted["affected_party_ref"], confirmation.confirmed_by_ref) + finally: + engine.dispose() + def test_workflow_handoff_survives_session_reopen_and_projects_into_tasks( self, ) -> None: