diff --git a/packages/govoplan-meta/pyproject.toml b/packages/govoplan-meta/pyproject.toml index fe07d86..e6b9f06 100644 --- a/packages/govoplan-meta/pyproject.toml +++ b/packages/govoplan-meta/pyproject.toml @@ -4,19 +4,19 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan" -version = "0.1.34" +version = "0.1.35" description = "Developer convenience package for a versioned GovOPlaN composition" readme = "README.md" requires-python = ">=3.12" license = { text = "AGPL-3.0-or-later" } dependencies = [ - "govoplan-core[server]==0.1.34", + "govoplan-core[server]==0.1.35", "govoplan-tenancy==0.1.18", "govoplan-organizations==0.1.18", "govoplan-identity==0.1.18", "govoplan-idm==0.1.20", - "govoplan-access==0.1.19", - "govoplan-admin==0.1.18", + "govoplan-access==0.1.20", + "govoplan-admin==0.1.19", "govoplan-policy==0.1.20", "govoplan-audit==0.1.19", "govoplan-dashboard==0.1.18", @@ -47,7 +47,7 @@ full = [ "govoplan-encryption==0.1.18", "govoplan-evaluation==0.1.19", "govoplan-facilities==0.1.19", - "govoplan-forms==0.1.19", + "govoplan-forms==0.1.20", "govoplan-forms-runtime==0.1.18", "govoplan-grants==0.1.19", "govoplan-helpdesk==0.1.20", diff --git a/packages/product/service-to-decision/README.md b/packages/product/service-to-decision/README.md index 84cd46b..07c8b14 100644 --- a/packages/product/service-to-decision/README.md +++ b/packages/product/service-to-decision/README.md @@ -17,6 +17,26 @@ human review handoff, formal outcome, Postbox delivery channel, and Records filing/retention target. Generic permit wording is no longer acceptance evidence for this package. +The package is now executable rather than metadata-only. Its Access fragments +create the bounded resident-permit clerk role, collect only the tenant-local +responsibility group key and name, create that group, and bind the role. The +Forms-owned fragment carries a digest-bound German-reference application schema +and imports it as a tenant-local draft with source provenance. Reapplying the +same source digest is a no-op; replacing an unrelated local definition remains +blocked unless the reviewed package explicitly selects a new revision. Normal +Forms review and publication are still required before the definition can serve +new applications. The Workflow Engine-owned fragment materializes and activates +the tenant review baseline, resolves the chosen responsibility group into each +human handoff, and preserves the evidence, decision, and EUR 30 payment-review +steps as a replay-safe contributed definition. + +Services, Cases, Payments, Tasks, and the optional delivery and Records modules +already execute the pinned journey through their runtime +contracts, but their reusable configuration fragments are not yet claimed by +this package. Until those module-owned configuration providers are added, the +package preflight deliberately distinguishes the installed runtime composition +from the Access, Forms, and Workflow configurations it can currently materialize. + An installed Forms and Forms Runtime pair adds an alternative governed entry path before case/workflow handoff: @@ -76,3 +96,11 @@ and Decision revision filing. Target-environment browser accessibility, production identity and delivery, a named archive profile, and recovery evidence are still required before this product package may claim `reference_ready` maturity. + +The generic package orchestrator stops at the first provider apply or health +blocker. Access and Forms may commit in separate provider transactions, so the +operator must retain the reviewed pre-apply database snapshot until verification +is complete. The Admin result reports no-op, snapshot-required, or partial-apply +recovery state and never describes this as atomic cross-module undo. Exported +fragments carry source/module/operator/scope provenance; supplied values and +credentials are not serialized into that provenance. diff --git a/packages/product/service-to-decision/package.json b/packages/product/service-to-decision/package.json index 4b1f7c1..83cc4ac 100644 --- a/packages/product/service-to-decision/package.json +++ b/packages/product/service-to-decision/package.json @@ -8,36 +8,366 @@ "category": "institutional-governance", "license": "AGPL-3.0-or-later", "required_modules": [ + {"module_id": "access"}, {"module_id": "audit"}, {"module_id": "cases"}, {"module_id": "decisions"}, + {"module_id": "forms"}, + {"module_id": "forms_runtime"}, {"module_id": "mandates"}, {"module_id": "parties"}, + {"module_id": "payments"}, {"module_id": "policy"}, {"module_id": "portal"}, - {"module_id": "services"} + {"module_id": "services"}, + {"module_id": "tasks"}, + {"module_id": "workflow_engine"} ], "required_capabilities": [ + "access.configuration", "cases.party_context", "cases.service_intake", "decisions.registry", + "forms.configuration", + "forms.definitions", "mandates.resolver", "parties.resolver", + "payments.requests", "portal.service_directory", "services.availability", - "services.definitions" + "services.definitions", + "workflow.configuration" ], "optional_modules": [ {"module_id": "approvals"}, {"module_id": "committee"}, {"module_id": "files"}, - {"module_id": "forms"}, - {"module_id": "forms_runtime"}, {"module_id": "postbox"}, {"module_id": "records"}, - {"module_id": "search"}, - {"module_id": "tasks"}, - {"module_id": "workflow_engine"} + {"module_id": "search"} + ], + "data_requirements": [ + { + "key": "responsible_group_slug", + "label": "Responsible permit group key", + "data_type": "string", + "required": true, + "secret": false, + "description": "Tenant-local stable key for the group that reviews resident parking permit applications." + }, + { + "key": "responsible_group_name", + "label": "Responsible permit group name", + "data_type": "string", + "required": true, + "secret": false, + "description": "Human-readable tenant-local name shown for the responsible permit group." + } + ], + "fragments": [ + { + "module_id": "access", + "fragment_type": "roles", + "fragment_id": "resident-parking-permit-clerk", + "payload": { + "items": [ + { + "slug": "resident-parking-permit-clerk", + "name": "Resident parking permit clerk", + "description": "Reviews resident parking permit submissions, workflow handoffs, cases, decisions, and payment evidence.", + "permissions": [ + "cases:case:read", + "cases:case:create", + "cases:case:update", + "decisions:decision:read", + "decisions:decision:write", + "forms:definition:read", + "forms_runtime:workspace:read", + "forms_runtime:workspace:write", + "payments:payment:read", + "payments:payment:write", + "tasks:item:read", + "tasks:item:write", + "workflow:definition:read", + "workflow:instance:read", + "workflow:instance:start", + "workflow:instance:transition" + ] + } + ] + } + }, + { + "module_id": "access", + "fragment_type": "groups", + "fragment_id": "resident-parking-permit-responsibility", + "payload": { + "items": [ + { + "slug": {"$data": "responsible_group_slug"}, + "name": {"$data": "responsible_group_name"}, + "description": "Tenant-local responsibility group for the resident parking permit reference journey." + } + ] + } + }, + { + "module_id": "access", + "fragment_type": "group_role_assignments", + "fragment_id": "resident-parking-permit-clerk-assignment", + "payload": { + "items": [ + { + "group": {"$data": "responsible_group_slug"}, + "role": "resident-parking-permit-clerk" + } + ] + } + }, + { + "module_id": "forms", + "fragment_type": "definition", + "fragment_id": "resident-parking-permit-application", + "payload": { + "on_conflict": "new_revision", + "change_reason": "Install the reviewed resident parking permit reference form.", + "fragment": { + "kind": "govoplan.forms.definition", + "contract_version": "0.1.0", + "definition": { + "reference": { + "kind": "form", + "owner_module": "forms", + "object_id": "resident-parking-permit-application", + "tenant_id": "reference-package", + "version": "3", + "valid_at": null, + "label": null + }, + "key": "resident-parking-permit-application", + "temporal": { + "revision": "3", + "valid_from": null, + "valid_to": null, + "recorded_at": "2026-08-22T00:00:00+00:00", + "superseded_at": null, + "change_reason": "Reference package revision." + }, + "title": "Resident parking permit", + "description": "Apply for a resident parking permit through a digital or assisted channel.", + "fields": [ + { + "key": "applicant_name", + "label": "Name", + "value_type": "text", + "required": true, + "help_text": null, + "options": [], + "constraints": {"min_length": 2, "max_length": 200}, + "default_value": null + }, + { + "key": "applicant_email", + "label": "Email", + "value_type": "text", + "required": true, + "help_text": null, + "options": [], + "constraints": {"format": "email"}, + "default_value": null + }, + { + "key": "residence_address", + "label": "Primary residence", + "value_type": "text", + "required": true, + "help_text": null, + "options": [], + "constraints": {"max_length": 500}, + "default_value": null + }, + { + "key": "licence_plate", + "label": "Licence plate", + "value_type": "text", + "required": true, + "help_text": null, + "options": [], + "constraints": {"max_length": 20}, + "default_value": null + } + ], + "publication_state": "published", + "allow_drafts": true, + "max_attachments": 4, + "signature_requirement": "none", + "policy_refs": [ + "law:resident-parking-permit", + "records:resident-parking-permit" + ], + "handoff_kinds": ["case", "workflow"], + "metadata": {}, + "pages": [ + { + "key": "application", + "title": "Application", + "description": null, + "sections": [ + { + "key": "applicant-and-vehicle", + "title": "Applicant and vehicle", + "description": null, + "field_keys": [ + "applicant_name", + "applicant_email", + "residence_address", + "licence_plate" + ] + } + ] + } + ], + "localizations": [ + { + "locale": "de", + "title": "Anwohnerparkausweis beantragen", + "description": "Einen Anwohnerparkausweis digital oder mit Unterstützung beantragen.", + "field_labels": { + "applicant_name": "Name", + "applicant_email": "E-Mail-Adresse", + "residence_address": "Hauptwohnsitz", + "licence_plate": "Kennzeichen" + }, + "field_help_texts": {}, + "option_labels": {}, + "page_titles": {"application": "Antrag"}, + "section_titles": { + "applicant-and-vehicle": "Antragstellende Person und Fahrzeug" + } + } + ], + "fallback_locale": "de" + }, + "definition_sha256": "7dc108002d532c07e5e7f3b14029a9d4deb3836ebb65d97fb6b51166a70e0ed4", + "provenance": { + "owner_module": "forms", + "tenant_id": "reference-package", + "form_id": "resident-parking-permit-application", + "revision": "3", + "exported_at": "2026-08-22T12:00:00+00:00", + "exported_by": "GovOPlaN reference package" + } + } + } + }, + { + "module_id": "workflow_engine", + "fragment_type": "workflow_definitions", + "fragment_id": "resident-parking-permit-workflow", + "payload": { + "schema_version": 1, + "origin_module_id": "configuration_package.service_to_decision", + "origin_module_version": "0.1.0", + "items": [ + { + "definition_key": "resident-parking-permit-review", + "name": "Resident parking permit review", + "description": "Review evidence, record the formal decision, and verify payment evidence for the resident parking permit reference journey.", + "scope_type": "tenant", + "allow_start": true, + "allow_reuse": true, + "allow_automation": false, + "execution_mode": "guided", + "activate_on_install": true, + "graph": { + "schema_version": 1, + "nodes": [ + { + "id": "start", + "type": "workflow.start.manual", + "label": "Application received", + "config": {"input_schema_ref": "form:resident-parking-permit-application"} + }, + { + "id": "review-evidence", + "type": "workflow.review", + "label": "Review application evidence", + "config": { + "title": "Review resident parking permit evidence", + "reviewer": { + "kind": "group", + "id": {"$data": "responsible_group_slug"}, + "label": {"$data": "responsible_group_name"} + }, + "due_after": "P14D", + "required_evidence": [ + "identity", + "primary_residence", + "vehicle_registration" + ], + "view_surface_ids": [] + } + }, + { + "id": "record-decision", + "type": "workflow.activity", + "label": "Record formal decision", + "config": { + "title": "Record the resident parking permit decision", + "instructions": "Record the operative result, reasoning, legal basis, remedy, and exact evidence references through the Decisions capability.", + "assignee": { + "kind": "group", + "id": {"$data": "responsible_group_slug"}, + "label": {"$data": "responsible_group_name"} + }, + "due_after": "P7D", + "view_surface_ids": [] + } + }, + { + "id": "verify-payment", + "type": "workflow.activity", + "label": "Verify payment evidence", + "config": { + "title": "Verify the resident parking permit fee", + "instructions": "Verify the EUR 30.00 obligation, immutable receipt evidence, currency, amount, and transaction reference before completion.", + "assignee": { + "kind": "group", + "id": {"$data": "responsible_group_slug"}, + "label": {"$data": "responsible_group_name"} + }, + "due_after": "P14D", + "view_surface_ids": [] + } + }, + { + "id": "completed", + "type": "workflow.end.completed", + "label": "Permit journey complete", + "config": {"output_mapping": {}} + } + ], + "edges": [ + {"id": "start-review", "source": "start", "target": "review-evidence"}, + {"id": "review-decision", "source": "review-evidence", "source_port": "approved", "target": "record-decision"}, + {"id": "decision-payment", "source": "record-decision", "target": "verify-payment"}, + {"id": "payment-completed", "source": "verify-payment", "target": "completed"} + ], + "metadata": { + "reference_journey": "resident-parking-permit", + "locale": "de-DE", + "payment_amount_minor": 3000, + "payment_currency": "EUR" + } + }, + "metadata": { + "reference_package": "product.service-to-decision", + "form_id": "resident-parking-permit-application" + } + } + ] + } + } ], "evidence": [ { diff --git a/requirements-release.txt b/requirements-release.txt index 514ca29..1143ab4 100644 --- a/requirements-release.txt +++ b/requirements-release.txt @@ -5,8 +5,8 @@ govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@ govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.18 govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.18 govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.20 -govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.19 -govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.18 +govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.20 +govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.19 govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.20 govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.19 govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.18 diff --git a/tests/test_configuration_package_artifacts.py b/tests/test_configuration_package_artifacts.py index b021fb9..5012016 100644 --- a/tests/test_configuration_package_artifacts.py +++ b/tests/test_configuration_package_artifacts.py @@ -5,9 +5,17 @@ import json from pathlib import Path import unittest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + from govoplan_core.core.configuration_packages import ( + ConfigurationApplyResult, + ConfigurationExportResult, ConfigurationPackageManifest, + ConfigurationPlanItem, ConfigurationPreflightContext, + ConfigurationPreflightResult, + ConfigurationProviderDescription, configuration_package_claim_issues, dry_run_configuration_package, ) @@ -54,10 +62,19 @@ class ConfigurationPackageArtifactTests(unittest.TestCase): f"Missing repository for {requirement.module_id}", ) + provider_module_ids = tuple( + sorted({fragment.module_id for fragment in manifest.fragments}) + ) + providers = tuple(_ArtifactProvider(module_id) for module_id in provider_module_ids) + supplied_data = { + str(item["key"]): _sample_value(item) + for item in manifest.data_requirements + } result = dry_run_configuration_package( manifest, - (), + providers, ConfigurationPreflightContext( + supplied_data=supplied_data, installed_modules={ item.module_id: item.version or "workspace" for item in manifest.required_modules @@ -74,6 +91,155 @@ class ConfigurationPackageArtifactTests(unittest.TestCase): self.assertIn("product.governed-data-assurance", package_ids) self.assertIn("product.service-to-decision", package_ids) + def test_service_to_decision_package_imports_its_form_as_an_idempotent_local_draft(self) -> None: + from govoplan_forms.backend.configuration_provider import ( + _apply_definition, + _preflight_definition, + ) + from govoplan_forms.backend.db.models import FormDefinitionRevision + from govoplan_forms.backend.service import get_form_definition + + package = ConfigurationPackageManifest.from_mapping(json.loads( + (PACKAGE_ROOT / "product/service-to-decision/package.json").read_text( + encoding="utf-8" + ) + )) + fragment = next( + item + for item in package.fragments + if item.module_id == "forms" and item.fragment_type == "definition" + ) + context = ConfigurationPreflightContext( + tenant_id="tenant-reference-test", + operator_user_id="operator-1", + operator_scopes=frozenset({"system:governance:write"}), + ) + engine = create_engine("sqlite+pysqlite:///:memory:") + FormDefinitionRevision.__table__.create(engine) + session = Session(engine) + try: + preflight = _preflight_definition(session, fragment, context) + applied = _apply_definition(session, fragment, context) + session.commit() + replay = _apply_definition(session, fragment, context) + imported = get_form_definition( + session, + type("Principal", (), {"tenant_id": "tenant-reference-test"})(), + form_id="resident-parking-permit-application", + ) + finally: + session.close() + engine.dispose() + + self.assertFalse(any(item.severity == "blocker" for item in preflight.diagnostics)) + self.assertEqual("create", preflight.plan[0].action) + self.assertEqual(1, len(applied.created_refs)) + self.assertEqual({}, replay.created_refs) + self.assertIsNotNone(imported) + assert imported is not None + self.assertEqual("tenant-reference-test", imported.reference.tenant_id) + self.assertEqual("draft", imported.publication_state) + self.assertEqual("de", imported.fallback_locale) + + def test_service_to_decision_package_materializes_its_tenant_workflow_idempotently(self) -> None: + from govoplan_core.core.configuration_packages import _resolve_fragment_data_references + from govoplan_workflow_engine.backend.configuration_provider import ( + apply_workflow_definitions, + preflight_workflow_definitions, + ) + from govoplan_workflow_engine.backend.db.models import ( + WorkflowDefinition, + WorkflowDefinitionRevision, + ) + + package = ConfigurationPackageManifest.from_mapping(json.loads( + (PACKAGE_ROOT / "product/service-to-decision/package.json").read_text( + encoding="utf-8" + ) + )) + fragment = next( + item + for item in package.fragments + if item.module_id == "workflow_engine" + ) + context = ConfigurationPreflightContext( + tenant_id="tenant-reference-test", + supplied_data={ + "responsible_group_slug": "traffic-permits", + "responsible_group_name": "Traffic permits", + }, + ) + resolved = _resolve_fragment_data_references( + fragment, + context.supplied_data, + ) + engine = create_engine("sqlite+pysqlite:///:memory:") + WorkflowDefinition.__table__.create(engine) + WorkflowDefinitionRevision.__table__.create(engine) + session = Session(engine) + try: + preflight = preflight_workflow_definitions(session, resolved, context) + applied = apply_workflow_definitions( + session, + resolved, + context, + registry=None, + ) + replay = apply_workflow_definitions( + session, + resolved, + context, + registry=None, + ) + session.commit() + finally: + session.close() + engine.dispose() + + self.assertFalse(any(item.severity == "blocker" for item in preflight.diagnostics)) + self.assertEqual("create", preflight.plan[0].action) + self.assertIn("resident-parking-permit-review", applied.created_refs) + self.assertEqual({}, replay.created_refs) + self.assertEqual({}, replay.updated_refs) + + +class _ArtifactProvider: + def __init__(self, module_id: str) -> None: + self.module_id = module_id + + def describe(self) -> ConfigurationProviderDescription: + return ConfigurationProviderDescription(module_id=self.module_id) + + def preflight(self, fragment, context) -> ConfigurationPreflightResult: + del context + return ConfigurationPreflightResult(plan=(ConfigurationPlanItem( + action="create", + module_id=fragment.module_id, + fragment_type=fragment.fragment_type, + fragment_id=fragment.fragment_id, + ),)) + + def apply(self, fragment, supplied_data, context) -> ConfigurationApplyResult: + del fragment, supplied_data, context + return ConfigurationApplyResult() + + def export(self, selection, context) -> ConfigurationExportResult: + del selection, context + return ConfigurationExportResult() + + def health(self, import_result, context): + del import_result, context + return () + + +def _sample_value(requirement: dict[str, object]) -> object: + data_type = str(requirement.get("data_type") or requirement.get("type") or "string") + if data_type == "boolean": + return False + if data_type in {"integer", "number"}: + return 1 + return f"fixture-{requirement['key']}" + if __name__ == "__main__": unittest.main()