feat: implement definition-aware forms runtime

This commit is contained in:
2026-08-01 17:48:35 +02:00
parent 396d6b0c90
commit 9dc49fe27b
25 changed files with 4180 additions and 79 deletions
+6
View File
@@ -1,5 +1,11 @@
# GovOPlaN Forms Runtime Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Forms Runtime internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope
This repository owns the GovOPlaN Forms Runtime platform module seed.
+24 -7
View File
@@ -4,13 +4,22 @@
**Repository type:** module (platform).
<!-- govoplan-repository-type:end -->
`govoplan-forms-runtime` is the GovOPlaN platform module seed for runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.
`govoplan-forms-runtime` owns definition-aware runtime form submissions,
including validation, permitted drafts, attachment/signature references, status
history, receipts, and handoff evidence.
Its runtime module ID is `forms_runtime`; the repository and Python distribution retain the hyphenated `govoplan-forms-runtime` name.
This repository is initialized as a discoverable module seed. It exposes a module manifest, initial permissions, role templates, documentation metadata, Gitea workflow templates, and a focused manifest test. It intentionally does not yet add HTTP routes, database models, migrations, or WebUI navigation.
The module persists tenant-bound immutable revisions and events, exposes bounded
owner/manager APIs and WebUI routes, and provides both
`forms_runtime.registry` and `forms_runtime.service_launcher`.
## Initial Ownership
Portal invokes the launcher only after re-fetching and re-evaluating an exact
published Service revision. A Form binding uses `<form-id>/<revision>`; the
runtime resolves that exact immutable definition through `forms.definitions`,
validates launch values, and retains both Service and binding provenance.
## Ownership
- form submissions
- draft state
@@ -31,13 +40,20 @@ Detailed boundary notes are in [docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md](docs/FORM
## Integrations
Expected optional integrations:
Required integrations:
- access
- forms
Optional integrations:
- files
- approvals
- workflow
- workflow engine
- portal
- cases
- policy
- audit
## Development Install
@@ -48,11 +64,12 @@ cd /mnt/DATA/git/govoplan-core
./.venv/bin/python -m pip install -e ../govoplan-forms-runtime
```
Focused manifest verification:
Focused verification:
```bash
cd /mnt/DATA/git/govoplan-forms-runtime
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src /mnt/DATA/git/govoplan-core/.venv/bin/python -m unittest discover -s tests
PYTHONPATH=src:/mnt/DATA/git/govoplan-forms/src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
```
## Gitea Workflow
+54 -13
View File
@@ -19,27 +19,68 @@ Runtime form submissions for validation, drafts, attachments, signatures, status
- document storage
- domain-specific adjudication
## Integration Candidates
## Required Integrations
- access
- forms
## Optional Integration Candidates
- files
- approvals
- workflow
- workflow engine
- portal
- cases
- policy
- audit
## Seed State
## Implemented State
The current repository state is intentionally small:
- exact immutable Form-definition resolution through `forms.definitions`
- tenant-bound instance identities and append-only revisions/status events
- server-side type, option, constraint, required, attachment, signature, and
optional policy validation
- `started` launch sessions and definition-controlled draft persistence
- final submission receipts, handoff references, replay safety, and OCC
- actor-bound idempotency that permits an exact retry after definition
supersession without exposing another participant's submission
- owner-restricted participant access plus manager scopes
- bounded list/detail/history/event APIs and accessible definition-driven WebUI
- `forms_runtime.service_launcher` retaining exact Service and binding
provenance
- migrations, uninstall guards, tenant summaries, events, recovery notes, and
tenant/replay/stale-write/validation/handoff tests
- module manifest and entry point
- tenant-level permission definitions
- manager and viewer role templates
- documentation topic describing the module boundary
- Gitea issue workflow templates
- manifest contract test
## Security And Policy
No runtime API, database model, migration, WebUI route, or navigation item is registered yet. The first implementation slice should preserve the boundary above and only add user-visible surfaces once the workflow model is clear.
Authenticated accounts receive only the participant role by default. It permits
access to their own instances; tenant-wide reads and review/handoff transitions
require manager scopes. Event payloads exclude submitted values. Exact
definition lookup, publication state, tenant, current authorization, and
optional policy references are re-evaluated for each consequential operation.
Definition providers must return the requested owner, tenant, object, and exact
revision; a mismatched provider response fails closed.
Policy-referenced definitions fail closed when no compatible
`forms_runtime.policy_evaluator` is active.
## First Implementation Slice
Files and signature providers retain their own content and key custody. Runtime
stores only same-tenant evidence references. Cases and Workflow Engine retain
their own target state; Runtime stores only a permitted same-tenant handoff
reference and status evidence.
Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.
## Recovery And Operations
Database recovery restores identities, revisions, and events together. After
restore, verify one current revision per instance, monotonically increasing
revision history, matching event revisions, resolvable exact Form and Service
references, and referenced evidence availability. A replay with the original
idempotency key and request hash must return the original revision; a changed
request must conflict. Failed handoffs leave the prior revision current.
Destructive retirement is blocked while state exists and requires a verified
database snapshot plus an export or retention decision for referenced evidence.
No local generated files are required, so API and worker nodes remain stateless.
Anonymous public intake, concrete file/signature upload adapters, conditional
multi-page layout, and automated target handoff execution remain product depth;
the owner and security boundaries no longer depend on those additions.
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@govoplan/forms-runtime",
"version": "0.1.8",
"version": "0.1.14",
"private": true,
"description": "GovOPlaN Forms Runtime platform module seed.",
"description": "Definition-aware form submissions and service launch for GovOPlaN.",
"type": "module",
"peerDependencies": {}
}
+4 -3
View File
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-forms-runtime"
version = "0.1.8"
description = "GovOPlaN Forms Runtime platform module seed."
version = "0.1.14"
description = "Definition-aware form submissions and service launch for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-core>=0.1.14",
"govoplan-access>=0.1.8",
"govoplan-forms>=0.1.14",
]
[tool.setuptools.packages.find]
@@ -0,0 +1 @@
"""Forms Runtime database models."""
@@ -0,0 +1,132 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, ForeignKey, Index, Integer, JSON, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class FormInstanceIdentity(Base, TimestampMixin):
__tablename__ = "form_instance_identities"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"instance_id",
name="uq_form_instance_identity",
),
Index(
"ix_form_instance_owner",
"tenant_id",
"created_by",
"definition_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
definition_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
definition_revision: Mapped[str] = mapped_column(
String(255), nullable=False, index=True
)
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
class FormInstanceRevision(Base, TimestampMixin):
__tablename__ = "form_instance_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"instance_id",
"revision",
name="uq_form_instance_revision",
),
Index(
"ix_form_instance_current",
"tenant_id",
"instance_id",
"superseded_at",
),
Index(
"ix_form_instance_catalog",
"tenant_id",
"status",
"recorded_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(
ForeignKey("form_instance_identities.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
previous_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("form_instance_revisions.id", ondelete="RESTRICT"),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
superseded_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
changed_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
class FormInstanceEvent(Base, TimestampMixin):
__tablename__ = "form_instance_events"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"event_id",
name="uq_form_instance_event",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_instance_idempotency",
),
Index(
"ix_form_instance_event_history",
"tenant_id",
"instance_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
instance_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
instance_revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
event_type: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
__all__ = [
"FormInstanceEvent",
"FormInstanceIdentity",
"FormInstanceRevision",
]
@@ -0,0 +1,142 @@
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import datetime
from typing import Mapping
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
ServiceBinding,
)
FORM_INSTANCE_STATUSES = frozenset(
{
"started",
"draft",
"submitted",
"validated",
"needs_review",
"accepted",
"rejected",
"handed_off",
"archived",
}
)
@dataclass(frozen=True, slots=True)
class FormInstance:
tenant_id: str
instance_id: str
revision: int
status: str
definition_ref: InstitutionalReference
values: Mapping[str, object]
validation_results: tuple[Mapping[str, object], ...]
recorded_at: datetime
change_reason: str
created_by: str
changed_by: str
attachment_refs: tuple[EvidenceReference, ...] = ()
signature_refs: tuple[EvidenceReference, ...] = ()
handoff_refs: tuple[InstitutionalReference, ...] = ()
service_ref: InstitutionalReference | None = None
service_binding: ServiceBinding | None = None
receipt_id: str | None = None
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if not self.tenant_id or not self.instance_id:
raise InstitutionalContextError(
"A Form instance requires tenant and instance identities."
)
if self.revision < 1:
raise InstitutionalContextError(
"A Form instance revision must be positive."
)
if self.status not in FORM_INSTANCE_STATUSES:
raise InstitutionalContextError(
f"Unsupported Form instance status: {self.status!r}."
)
if (
self.definition_ref.kind != "form"
or self.definition_ref.owner_module != "forms"
or self.definition_ref.tenant_id != self.tenant_id
or not self.definition_ref.version
):
raise InstitutionalContextError(
"A Form instance requires an exact same-tenant Forms definition."
)
if self.service_ref is not None and (
self.service_ref.kind != "service"
or self.service_ref.tenant_id != self.tenant_id
or not self.service_ref.version
):
raise InstitutionalContextError(
"Form instance Service provenance must be exact and same-tenant."
)
for item in (*self.attachment_refs, *self.signature_refs):
if item.tenant_id != self.tenant_id:
raise InstitutionalContextError(
"Form instance evidence cannot cross tenants."
)
for item in self.handoff_refs:
if item.tenant_id != self.tenant_id:
raise InstitutionalContextError(
"Form instance handoff references cannot cross tenants."
)
@property
def reference(self) -> InstitutionalReference:
return InstitutionalReference(
kind="form_submission",
owner_module="forms_runtime",
object_id=self.instance_id,
tenant_id=self.tenant_id,
version=str(self.revision),
valid_at=self.recorded_at,
)
def with_replay(self) -> "FormInstance":
return replace(self, replayed=True)
def to_dict(self, *, include_values: bool = True) -> dict[str, object]:
return {
"reference": self.reference.to_dict(),
"tenant_id": self.tenant_id,
"instance_id": self.instance_id,
"revision": self.revision,
"status": self.status,
"definition_ref": self.definition_ref.to_dict(disclose_label=True),
"values": dict(self.values) if include_values else {},
"validation_results": [dict(item) for item in self.validation_results],
"attachment_refs": [
item.to_dict(include_inspection=False) for item in self.attachment_refs
],
"signature_refs": [
item.to_dict(include_inspection=False) for item in self.signature_refs
],
"handoff_refs": [item.to_dict() for item in self.handoff_refs],
"service_ref": (
self.service_ref.to_dict() if self.service_ref is not None else None
),
"service_binding": (
self.service_binding.to_dict()
if self.service_binding is not None
else None
),
"receipt_id": self.receipt_id,
"recorded_at": self.recorded_at.isoformat(),
"change_reason": self.change_reason,
"created_by": self.created_by,
"changed_by": self.changed_by,
"replayed": self.replayed,
"metadata": dict(self.metadata),
}
__all__ = ["FORM_INSTANCE_STATUSES", "FormInstance"]
+270 -42
View File
@@ -1,20 +1,59 @@
from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import DocumentationLink, DocumentationTopic, ModuleManifest, PermissionDefinition, RoleTemplate
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.institutional import CAPABILITY_FORM_DEFINITIONS
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_forms_runtime.backend.db import models as runtime_models
from govoplan_forms_runtime.backend.service import (
CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,
CAPABILITY_FORMS_RUNTIME_REGISTRY,
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER,
FormRuntimeService,
FormsServiceLauncher,
)
MODULE_ID = "forms_runtime"
MODULE_NAME = "Forms Runtime"
MODULE_VERSION = "0.1.8"
MODULE_VERSION = "0.1.14"
PARTICIPATE_SCOPE = "forms_runtime:submission:participate"
READ_SCOPE = "forms_runtime:workspace:read"
WRITE_SCOPE = "forms_runtime:workspace:write"
ADMIN_SCOPE = "forms_runtime:workspace:admin"
OPTIONAL_DEPENDENCIES = (
"forms",
"files",
"approvals",
"workflow_engine",
"portal",
"cases",
"policy",
"audit",
)
@@ -24,7 +63,7 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
scope=scope,
label=label,
description=description,
category="Forms Runtime",
category=MODULE_NAME,
level="tenant",
module_id=module_id,
resource=resource,
@@ -33,66 +72,255 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
PERMISSIONS = (
_permission(READ_SCOPE, "View forms runtime workspace", "Read forms runtime records, configuration, and workflow context."),
_permission(WRITE_SCOPE, "Manage forms runtime workspace", "Create and update forms runtime records and workflow state."),
_permission(ADMIN_SCOPE, "Administer forms runtime workspace", "Configure forms runtime policies, templates, and tenant-level administration."),
_permission(
PARTICIPATE_SCOPE,
"Complete assigned forms",
"Start, read, save, and submit the acting account's own Form instances.",
),
_permission(
READ_SCOPE,
"View form submissions",
"Read tenant Form instances, immutable history, and status evidence.",
),
_permission(
WRITE_SCOPE,
"Manage form submissions",
"Review, transition, and hand off tenant Form submissions.",
),
_permission(
ADMIN_SCOPE,
"Administer Forms Runtime",
"Administer Forms Runtime policy, recovery, and retirement.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="forms_runtime_participant",
name="Forms participant",
description="Complete the acting account's own Forms.",
permissions=(PARTICIPATE_SCOPE,),
default_authenticated=True,
),
RoleTemplate(
slug="forms_runtime_manager",
name="Forms Runtime manager",
description="Manage forms runtime records and workflow state.",
permissions=(READ_SCOPE, WRITE_SCOPE),
description="Review, transition, and hand off Form submissions.",
permissions=(PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="forms_runtime_viewer",
name="Forms Runtime viewer",
description="Read forms runtime records and workflow context.",
description="Read Form submissions and their immutable history.",
permissions=(READ_SCOPE,),
),
)
DOCUMENTATION = (
DocumentationTopic(
id=f"{MODULE_ID}.module-boundary",
title=f"{MODULE_NAME} module boundary",
summary="Runtime form submissions for validation, drafts, attachments, signatures, status tracking, and handoff to domain modules.",
body=(
"This repository is currently a platform module seed. It registers the domain boundary, "
"permission surface, role templates, and documentation metadata before runtime APIs, "
"database models, migrations, and WebUI routes are introduced."
),
layer="available",
documentation_types=("admin",),
audience=("operator", "module_admin", "product_owner"),
order=100,
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Repository domain boundary",
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
metadata={
"seed": True,
"domain_objects": ['form submissions', 'draft state', 'runtime validation results', 'attachment references', 'signature state', 'handoff status'],
"first_slice": "Define submission, draft, validation, attachment, signature, status, and handoff contracts around existing form definitions.",
},
),
def _router(context: ModuleContext):
from govoplan_forms_runtime.backend.router import create_router
return create_router(context.registry)
def _registry(context: ModuleContext) -> FormRuntimeService:
return FormRuntimeService(context.registry)
def _service_launcher(context: ModuleContext) -> FormsServiceLauncher:
return FormsServiceLauncher(context.registry)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
current = session.query(runtime_models.FormInstanceRevision).filter(
runtime_models.FormInstanceRevision.tenant_id == tenant_id,
runtime_models.FormInstanceRevision.superseded_at.is_(None),
)
return {
"form_instances": current.count(),
"open_form_instances": current.filter(
runtime_models.FormInstanceRevision.status.in_(
("started", "draft", "submitted", "validated", "needs_review")
)
).count(),
}
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("access",),
dependencies=("access", "forms"),
optional_dependencies=OPTIONAL_DEPENDENCIES,
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_FORM_DEFINITIONS,
),
optional_capabilities=(CAPABILITY_FORMS_RUNTIME_POLICY_EVALUATOR,),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION,
route_factory=_router,
nav_items=(
NavItem(
path="/forms-runtime",
label="Forms",
icon="form",
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
order=37,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/forms-runtime-webui",
routes=(
FrontendRoute(
path="/forms-runtime",
component="FormsRuntimePage",
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
order=37,
),
FrontendRoute(
path="/forms-runtime/:instanceId",
component="FormInstancePage",
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
order=38,
),
),
nav_items=(
NavItem(
path="/forms-runtime",
label="Forms",
icon="form",
required_any=(PARTICIPATE_SCOPE, READ_SCOPE),
order=37,
),
),
view_surfaces=(
ViewSurface(
id="forms_runtime.navigation",
module_id=MODULE_ID,
kind="navigation",
label="Forms navigation",
order=10,
),
ViewSurface(
id="forms_runtime.workspace",
module_id=MODULE_ID,
kind="route",
label="Forms workspace",
order=20,
),
ViewSurface(
id="forms_runtime.instance",
module_id=MODULE_ID,
kind="route",
label="Form instance",
order=30,
),
),
),
provides_interfaces=(
ModuleInterfaceProvider(name="forms_runtime.registry", version="0.1.0"),
ModuleInterfaceProvider(name="forms_runtime.service_launcher", version="0.1.0"),
),
requires_interfaces=(
ModuleInterfaceRequirement(
name="forms.definitions",
version_min="0.1.0",
version_max_exclusive="0.2.0",
),
),
capability_factories={
CAPABILITY_FORMS_RUNTIME_REGISTRY: _registry,
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: _service_launcher,
},
capability_documentation={
CAPABILITY_FORMS_RUNTIME_REGISTRY: CapabilityDocumentation(
label="Forms Runtime registry",
summary="Manages tenant-bound, revisioned Form instances and handoff evidence.",
contract_version="0.1.0",
),
CAPABILITY_FORMS_RUNTIME_SERVICE_LAUNCHER: CapabilityDocumentation(
label="Form-bound Service launcher",
summary="Starts a replay-safe Form instance from an exact published Service and Form revision.",
contract_version="0.1.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
migration_after=("forms",),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
runtime_models.FormInstanceEvent,
runtime_models.FormInstanceRevision,
runtime_models.FormInstanceIdentity,
label=MODULE_NAME,
),
retirement_notes="Destructive retirement removes submissions and immutable status evidence and requires a verified database and referenced-evidence recovery plan.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
runtime_models.FormInstanceIdentity,
runtime_models.FormInstanceRevision,
runtime_models.FormInstanceEvent,
label=MODULE_NAME,
),
),
tenant_summary_providers=(_tenant_summary,),
documentation=(
DocumentationTopic(
id="forms_runtime.submissions",
title="Complete and manage Forms",
summary="Save permitted drafts, submit validated values, and retain exact definition and handoff evidence.",
body=(
"Every instance resolves one immutable published Form revision. Draft and final values are validated on the server; final submission also enforces attachment, signature, and policy requirements. "
"Service launches retain the exact Service and binding. History, receipts, and handoffs are append-only, replay-safe, and optimistic-concurrency guarded."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
links=(
DocumentationLink(
label="Forms Runtime security and recovery",
href="govoplan-forms-runtime/docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="human_work_procedure",
kind="runtime",
maturity="vertical_slice",
documentation_ref="docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",
test_ref="tests/test_forms_runtime.py",
known_limits=(
"Anonymous public intake, concrete Files/signature adapters, and automatic target handoff execution remain adapter depth; authenticated Portal entry is supported.",
),
supported_authority_modes=("native_authoritative",),
owned_concepts=(
"form instance",
"form submission",
"runtime validation",
"submission receipt",
"form handoff evidence",
),
non_owned_concepts=(
"form definition",
"file content",
"case",
"workflow definition",
"signature key custody",
),
reference_packages=("product.service-to-decision",),
migration_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
recovery_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
security_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
operations_docs=("docs/FORMS_RUNTIME_DOMAIN_BOUNDARY.md",),
),
)
@@ -0,0 +1 @@
"""Forms Runtime Alembic revisions."""
@@ -0,0 +1 @@
"""Forms Runtime migration versions."""
@@ -0,0 +1,175 @@
"""v0.1.14 definition-aware Forms Runtime.
Revision ID: f2a3b4c5d6e7
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f2a3b4c5d6e7"
down_revision = None
branch_labels = None
depends_on = "e1f2a3b4c5d6"
def upgrade() -> None:
op.create_table(
"form_instance_identities",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("definition_id", sa.String(length=255), nullable=False),
sa.Column("definition_revision", sa.String(length=255), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_identities")),
sa.UniqueConstraint(
"tenant_id",
"instance_id",
name="uq_form_instance_identity",
),
)
for column in (
"tenant_id",
"instance_id",
"definition_id",
"definition_revision",
"created_by",
):
op.create_index(
op.f(f"ix_form_instance_identities_{column}"),
"form_instance_identities",
[column],
unique=False,
)
op.create_index(
"ix_form_instance_owner",
"form_instance_identities",
["tenant_id", "created_by", "definition_id"],
unique=False,
)
op.create_table(
"form_instance_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("previous_revision_id", sa.String(length=36), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("changed_by", sa.String(length=255), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["identity_id"],
["form_instance_identities.id"],
name=op.f("fk_form_instance_revisions_identity_id_form_instance_identities"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["previous_revision_id"],
["form_instance_revisions.id"],
name=op.f("fk_form_instance_revisions_previous_revision_id_form_instance_revisions"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_revisions")),
sa.UniqueConstraint(
"tenant_id",
"instance_id",
"revision",
name="uq_form_instance_revision",
),
)
for column in (
"tenant_id",
"instance_id",
"identity_id",
"previous_revision_id",
"status",
"recorded_at",
"superseded_at",
"changed_by",
):
op.create_index(
op.f(f"ix_form_instance_revisions_{column}"),
"form_instance_revisions",
[column],
unique=False,
)
op.create_index(
"ix_form_instance_current",
"form_instance_revisions",
["tenant_id", "instance_id", "superseded_at"],
unique=False,
)
op.create_index(
"ix_form_instance_catalog",
"form_instance_revisions",
["tenant_id", "status", "recorded_at"],
unique=False,
)
op.create_table(
"form_instance_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("instance_id", sa.String(length=255), nullable=False),
sa.Column("instance_revision", sa.Integer(), nullable=False),
sa.Column("event_id", sa.String(length=36), nullable=False),
sa.Column("event_type", sa.String(length=120), nullable=False),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_form_instance_events")),
sa.UniqueConstraint(
"tenant_id",
"event_id",
name="uq_form_instance_event",
),
sa.UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_form_instance_idempotency",
),
)
for column in (
"tenant_id",
"instance_id",
"event_id",
"event_type",
"status",
"occurred_at",
"actor_id",
):
op.create_index(
op.f(f"ix_form_instance_events_{column}"),
"form_instance_events",
[column],
unique=False,
)
op.create_index(
"ix_form_instance_event_history",
"form_instance_events",
["tenant_id", "instance_id", "occurred_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("form_instance_events")
op.drop_table("form_instance_revisions")
op.drop_table("form_instance_identities")
@@ -0,0 +1,339 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.institutional import (
EvidenceReference,
InstitutionalContextError,
InstitutionalReference,
)
from govoplan_core.db.session import get_session
from govoplan_forms_runtime.backend.manifest import (
ADMIN_SCOPE,
PARTICIPATE_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
)
from govoplan_forms_runtime.backend.schemas import (
FormDraftUpdateRequest,
FormHandoffRequest,
FormInstanceCreateRequest,
FormInstanceEventsResponse,
FormInstanceHistoryResponse,
FormInstanceListResponse,
FormSubmitRequest,
FormTransitionRequest,
)
from govoplan_forms_runtime.backend.service import FormRuntimeError, FormRuntimeService
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/forms-runtime", tags=["forms-runtime"])
runtime = FormRuntimeService(registry)
@router.get("/instances", response_model=FormInstanceListResponse)
def api_list_instances(
instance_status: list[str] | None = Query(default=None, alias="status"),
definition_id: str | None = Query(default=None, max_length=255),
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormInstanceListResponse:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
items, total = runtime.list_instances(
session,
principal,
statuses=instance_status,
definition_id=definition_id,
offset=offset,
limit=limit,
allow_all=has_scope(principal, READ_SCOPE),
)
except FormRuntimeError as exc:
raise _error(exc) from exc
return FormInstanceListResponse(
instances=[item.to_dict(include_values=False) for item in items],
total=total,
offset=offset,
limit=limit,
)
@router.post(
"/instances",
response_model=dict[str, object],
status_code=status.HTTP_201_CREATED,
)
def api_create_instance(
payload: FormInstanceCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
try:
item = runtime.create_instance(
session,
principal,
definition_ref=InstitutionalReference.from_mapping(
payload.definition_ref
),
values=payload.values,
attachment_refs=_evidence(payload.attachment_refs),
signature_refs=_evidence(payload.signature_refs),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
instance_id=payload.instance_id,
metadata=payload.metadata,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get("/instances/{instance_id}", response_model=dict[str, object])
def api_get_instance(
instance_id: str,
revision: int | None = Query(default=None, ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
item = runtime.get_instance(
session,
principal,
instance_id=instance_id,
revision=revision,
allow_all=has_scope(principal, READ_SCOPE),
)
except PermissionError as exc:
raise _error(exc) from exc
if item is None:
raise HTTPException(status_code=404, detail="Form instance not found")
return item.to_dict()
@router.get(
"/instances/{instance_id}/definition",
response_model=dict[str, object],
)
def api_get_instance_definition(
instance_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
item = runtime.get_instance_definition(
session,
principal,
instance_id=instance_id,
allow_all=has_scope(principal, READ_SCOPE),
)
except PermissionError as exc:
raise _error(exc) from exc
if item is None:
raise HTTPException(status_code=404, detail="Form instance not found")
return item.to_dict()
@router.patch("/instances/{instance_id}", response_model=dict[str, object])
def api_update_draft(
instance_id: str,
payload: FormDraftUpdateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
try:
item = runtime.update_draft(
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),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/instances/{instance_id}/submit",
response_model=dict[str, object],
)
def api_submit_instance(
instance_id: str,
payload: FormSubmitRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, PARTICIPATE_SCOPE, WRITE_SCOPE)
try:
item = runtime.submit_instance(
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),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
allow_all=has_scope(principal, WRITE_SCOPE),
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/instances/{instance_id}/transition",
response_model=dict[str, object],
)
def api_transition_instance(
instance_id: str,
payload: FormTransitionRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = runtime.transition_instance(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
status=payload.status,
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.post(
"/instances/{instance_id}/handoffs",
response_model=dict[str, object],
)
def api_handoff_instance(
instance_id: str,
payload: FormHandoffRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require_any(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
item = runtime.handoff_instance(
session,
principal,
instance_id=instance_id,
expected_revision=payload.expected_revision,
target_ref=InstitutionalReference.from_mapping(payload.target_ref),
idempotency_key=payload.idempotency_key,
recorded_at=payload.recorded_at,
change_reason=payload.change_reason,
allow_all=True,
)
session.commit()
except (FormRuntimeError, InstitutionalContextError, LookupError, PermissionError) as exc:
session.rollback()
raise _error(exc) from exc
return item.to_dict()
@router.get(
"/instances/{instance_id}/history",
response_model=FormInstanceHistoryResponse,
)
def api_instance_history(
instance_id: str,
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormInstanceHistoryResponse:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
items = runtime.history(
session,
principal,
instance_id=instance_id,
limit=limit,
allow_all=has_scope(principal, READ_SCOPE),
)
except PermissionError as exc:
raise _error(exc) from exc
if not items:
raise HTTPException(status_code=404, detail="Form instance not found")
return FormInstanceHistoryResponse(
revisions=[item.to_dict() for item in items]
)
@router.get(
"/instances/{instance_id}/events",
response_model=FormInstanceEventsResponse,
)
def api_instance_events(
instance_id: str,
limit: int = Query(default=200, ge=1, le=500),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> FormInstanceEventsResponse:
_require_any(principal, PARTICIPATE_SCOPE, READ_SCOPE)
try:
items = runtime.events(
session,
principal,
instance_id=instance_id,
limit=limit,
allow_all=has_scope(principal, READ_SCOPE),
)
except PermissionError as exc:
raise _error(exc) from exc
return FormInstanceEventsResponse(events=[dict(item) for item in items])
return router
def _evidence(values: list[dict[str, object]]) -> tuple[EvidenceReference, ...]:
return tuple(EvidenceReference.from_mapping(item) for item in values)
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
if not any(has_scope(principal, scope) for scope in scopes):
raise HTTPException(
status_code=403,
detail=f"Missing one of the scopes: {', '.join(scopes)}",
)
def _error(exc: Exception) -> HTTPException:
message = str(exc)
lowered = message.casefold()
if isinstance(exc, LookupError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif any(word in lowered for word in ("conflict", "stale", "already")):
code = 409
elif "validation" in lowered:
code = 422
else:
code = 400
return HTTPException(status_code=code, detail=message)
__all__ = ["create_router"]
@@ -0,0 +1,95 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class FormInstanceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
definition_ref: dict[str, Any]
values: dict[str, Any] = Field(default_factory=dict)
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)
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
instance_id: str | None = Field(default=None, min_length=1, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class FormDraftUpdateRequest(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)
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1000)
class FormSubmitRequest(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)
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
class FormTransitionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
status: Literal[
"validated",
"needs_review",
"accepted",
"rejected",
"archived",
]
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1000)
class FormHandoffRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
target_ref: dict[str, Any]
idempotency_key: str = Field(min_length=1, max_length=255)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1000)
class FormInstanceListResponse(BaseModel):
instances: list[dict[str, Any]]
total: int
offset: int
limit: int
class FormInstanceHistoryResponse(BaseModel):
revisions: list[dict[str, Any]]
class FormInstanceEventsResponse(BaseModel):
events: list[dict[str, Any]]
__all__ = [
"FormDraftUpdateRequest",
"FormHandoffRequest",
"FormInstanceCreateRequest",
"FormInstanceEventsResponse",
"FormInstanceHistoryResponse",
"FormInstanceListResponse",
"FormSubmitRequest",
"FormTransitionRequest",
]
File diff suppressed because it is too large Load Diff
+418
View File
@@ -0,0 +1,418 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import (
CAPABILITY_FORM_DEFINITIONS,
FormDefinition,
FormFieldDefinition,
InstitutionalReference,
ServiceBinding,
ServiceDefinition,
ServiceLaunchRequest,
TemporalRevision,
)
from govoplan_forms.backend.db.models import FormDefinitionRevision
from govoplan_forms.backend.service import SqlFormDefinitionProvider, record_form_definition
from govoplan_forms_runtime.backend.db.models import (
FormInstanceEvent,
FormInstanceIdentity,
FormInstanceRevision,
)
from govoplan_forms_runtime.backend.service import (
FormRuntimeError,
FormRuntimeService,
FormsServiceLauncher,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@dataclass
class Principal:
tenant_id: str = "tenant-1"
account_id: str = "account-1"
class Registry:
def __init__(self, provider: object) -> None:
self.provider = provider
def has_capability(self, name: str) -> bool:
return name == CAPABILITY_FORM_DEFINITIONS
def require_capability(self, name: str) -> object:
if name != CAPABILITY_FORM_DEFINITIONS:
raise KeyError(name)
return self.provider
def form_definition(
*,
form_id: str = "permit-form",
revision: str = "1",
policy_refs: tuple[str, ...] = (),
) -> FormDefinition:
return FormDefinition(
reference=InstitutionalReference(
kind="form",
owner_module="forms",
object_id=form_id,
tenant_id="tenant-1",
version=revision,
),
key=form_id,
temporal=TemporalRevision(
revision=revision,
recorded_at=NOW + timedelta(minutes=int(revision) - 2),
change_reason=(
"Initial schema." if revision == "1" else "Revise schema."
),
),
title="Permit form",
fields=(
FormFieldDefinition(
key="name",
label="Name",
required=True,
constraints={"min_length": 2},
),
FormFieldDefinition(
key="delivery",
label="Delivery",
value_type="choice",
options=("portal", "mail"),
),
),
publication_state="published",
allow_drafts=True,
handoff_kinds=("case",),
policy_refs=policy_refs,
)
class FormsRuntimeTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
for table in (
FormDefinitionRevision.__table__,
FormInstanceIdentity.__table__,
FormInstanceRevision.__table__,
FormInstanceEvent.__table__,
):
table.create(self.engine)
self.session = Session(self.engine)
self.principal = Principal()
self.definition = record_form_definition(
self.session,
self.principal,
definition=form_definition(),
)
self.registry = Registry(SqlFormDefinitionProvider())
self.runtime = FormRuntimeService(self.registry)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_draft_submit_occ_replay_and_status_history(self) -> None:
draft = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={},
idempotency_key="create-1",
recorded_at=NOW,
)
self.assertEqual("draft", draft.status)
self.assertEqual("warning", draft.validation_results[0]["severity"])
saved = self.runtime.update_draft(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values={"name": "Ada", "delivery": "portal"},
attachment_refs=(),
signature_refs=(),
idempotency_key="save-1",
recorded_at=NOW + timedelta(minutes=1),
change_reason="Complete required values.",
)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=2,
values=saved.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="submit-1",
recorded_at=NOW + timedelta(minutes=2),
)
replay = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=2,
values=saved.values,
attachment_refs=(),
signature_refs=(),
idempotency_key="submit-1",
recorded_at=NOW + timedelta(minutes=2),
)
self.assertEqual("submitted", submitted.status)
self.assertIsNotNone(submitted.receipt_id)
self.assertTrue(replay.replayed)
self.assertEqual(
[3, 2, 1],
[
item.revision
for item in self.runtime.history(
self.session,
self.principal,
instance_id=draft.instance_id,
)
],
)
with self.assertRaisesRegex(FormRuntimeError, "stale"):
self.runtime.transition_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=2,
status="validated",
idempotency_key="transition-stale",
recorded_at=NOW + timedelta(minutes=3),
change_reason="Review complete.",
)
def test_validation_policy_tenant_and_handoff_fail_closed(self) -> None:
draft = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={},
idempotency_key="create-2",
recorded_at=NOW,
)
with self.assertRaisesRegex(FormRuntimeError, "failed validation"):
self.runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values={"name": "A"},
attachment_refs=(),
signature_refs=(),
idempotency_key="invalid-submit",
recorded_at=NOW + timedelta(minutes=1),
)
with self.assertRaisesRegex(PermissionError, "denied"):
self.runtime.get_instance(
self.session,
Principal(account_id="account-2"),
instance_id=draft.instance_id,
)
submitted = self.runtime.submit_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
expected_revision=1,
values={"name": "Ada"},
attachment_refs=(),
signature_refs=(),
idempotency_key="valid-submit",
recorded_at=NOW + timedelta(minutes=2),
)
with self.assertRaisesRegex(FormRuntimeError, "cross tenants"):
self.runtime.handoff_instance(
self.session,
self.principal,
instance_id=submitted.instance_id,
expected_revision=2,
target_ref=InstitutionalReference(
kind="case",
owner_module="cases",
object_id="case-1",
tenant_id="tenant-2",
),
idempotency_key="handoff-invalid",
recorded_at=NOW + timedelta(minutes=3),
change_reason="Create case.",
)
current = self.runtime.get_instance(
self.session,
self.principal,
instance_id=draft.instance_id,
)
self.assertEqual(2, current.revision if current else None)
protected = record_form_definition(
self.session,
self.principal,
definition=form_definition(
form_id="protected-form",
policy_refs=("policy:protected-intake",),
),
)
with self.assertRaisesRegex(PermissionError, "policy evaluator"):
self.runtime.create_instance(
self.session,
self.principal,
definition_ref=protected.reference,
values={"name": "Ada"},
idempotency_key="protected-start",
recorded_at=NOW + timedelta(minutes=4),
)
def test_service_launcher_retains_exact_service_form_and_replay(self) -> None:
binding = ServiceBinding(kind="form", reference="permit-form/1")
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 form entry.",
),
title="Apply for permit",
audience=("resident",),
bindings=(binding,),
publication_state="published",
)
request = ServiceLaunchRequest(
service_ref=service.reference,
binding=binding,
idempotency_key="portal-1",
requested_at=NOW,
parameters={},
)
launcher = FormsServiceLauncher(self.registry)
first = launcher.launch_service(
self.session,
self.principal,
definition=service,
request=request,
)
replay = launcher.launch_service(
self.session,
self.principal,
definition=service,
request=request,
)
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:
first = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="create-replay",
recorded_at=NOW,
)
record_form_definition(
self.session,
self.principal,
definition=form_definition(revision="2"),
expected_revision="1",
)
replay = self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="create-replay",
recorded_at=NOW,
)
self.assertTrue(replay.replayed)
self.assertEqual(first.instance_id, replay.instance_id)
with self.assertRaisesRegex(FormRuntimeError, "another actor"):
self.runtime.create_instance(
self.session,
Principal(account_id="account-2"),
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="create-replay",
recorded_at=NOW,
)
def test_runtime_rejects_a_provider_returning_another_exact_revision(self) -> None:
class MismatchedProvider:
def get_form_definition(
self,
session,
principal,
*,
reference,
effective_at=None,
):
return form_definition(revision="2")
def list_form_definitions(
self,
session,
principal,
*,
tenant_id,
query="",
limit=100,
):
return ()
runtime = FormRuntimeService(Registry(MismatchedProvider()))
with self.assertRaisesRegex(FormRuntimeError, "different definition"):
runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="mismatched-provider",
recorded_at=NOW,
)
def test_client_supplied_instance_id_cannot_replace_existing_state(self) -> None:
self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Ada"},
idempotency_key="fixed-instance-first",
recorded_at=NOW,
instance_id="fixed-instance",
)
with self.assertRaisesRegex(FormRuntimeError, "instance id is already"):
self.runtime.create_instance(
self.session,
self.principal,
definition_ref=self.definition.reference,
values={"name": "Grace"},
idempotency_key="fixed-instance-second",
recorded_at=NOW,
instance_id="fixed-instance",
)
if __name__ == "__main__":
unittest.main()
+31 -11
View File
@@ -2,22 +2,42 @@ from __future__ import annotations
import unittest
from govoplan_forms_runtime.backend.manifest import ADMIN_SCOPE, READ_SCOPE, WRITE_SCOPE, get_manifest
from govoplan_forms_runtime.backend.manifest import (
ADMIN_SCOPE,
PARTICIPATE_SCOPE,
READ_SCOPE,
WRITE_SCOPE,
get_manifest,
)
class ManifestSeedTests(unittest.TestCase):
def test_manifest_registers_seed_contract(self) -> None:
class ManifestTests(unittest.TestCase):
def test_manifest_registers_definition_aware_runtime(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "forms_runtime")
self.assertEqual(manifest.name, "Forms Runtime")
self.assertEqual(manifest.dependencies, ("access",))
self.assertEqual({permission.scope for permission in manifest.permissions}, {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE})
self.assertEqual({role.slug for role in manifest.role_templates}, {"forms_runtime_manager", "forms_runtime_viewer"})
self.assertTrue(manifest.documentation)
self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec)
self.assertIsNone(manifest.frontend)
self.assertEqual(manifest.dependencies, ("access", "forms"))
self.assertEqual(
{permission.scope for permission in manifest.permissions},
{PARTICIPATE_SCOPE, READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE},
)
participant = next(
item
for item in manifest.role_templates
if item.slug == "forms_runtime_participant"
)
self.assertTrue(participant.default_authenticated)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertIn(
"forms_runtime.service_launcher",
manifest.capability_factories,
)
self.assertEqual(
"@govoplan/forms-runtime-webui",
manifest.frontend.package_name if manifest.frontend else None,
)
if __name__ == "__main__":
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from pathlib import Path
import tempfile
import unittest
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_forms.backend.manifest import get_manifest as get_forms_manifest
from govoplan_forms_runtime.backend.manifest import get_manifest as get_runtime_manifest
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:
url = f"sqlite:///{Path(directory) / 'forms-runtime.db'}"
migrate_database(
database_url=url,
enabled_modules=("forms", "forms_runtime"),
manifest_factories=(get_forms_manifest, get_runtime_manifest),
)
engine = create_engine(url)
try:
self.assertTrue(
{
"form_definition_revisions",
"form_instance_identities",
"form_instance_revisions",
"form_instance_events",
}.issubset(inspect(engine).get_table_names())
)
with engine.connect() as connection:
self.assertIn(
"f2a3b4c5d6e7",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@govoplan/forms-runtime-webui",
"version": "0.1.14",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/forms-runtime.css": "./src/styles/forms-runtime.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
+171
View File
@@ -0,0 +1,171 @@
import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui";
export type InstitutionalReference = {
kind: string;
owner_module: string;
object_id: string;
tenant_id: string;
version?: string | null;
valid_at?: string | null;
label?: string | null;
};
export type EvidenceReference = {
kind: string;
owner_module: string;
evidence_id: string;
tenant_id: string;
version?: string | null;
};
export type FormFieldDefinition = {
key: string;
label: string;
value_type: "text" | "multiline_text" | "integer" | "number" | "boolean" | "date" | "datetime" | "email" | "choice" | "multi_choice" | "object" | "list";
required: boolean;
help_text?: string | null;
options: string[];
constraints: Record<string, unknown>;
default_value?: unknown;
};
export type FormDefinition = {
reference: InstitutionalReference;
key: string;
temporal: { revision: string; recorded_at?: string | null };
title: string;
description?: string | null;
fields: FormFieldDefinition[];
publication_state: "draft" | "published" | "retired";
allow_drafts: boolean;
max_attachments: number;
signature_requirement: "none" | "optional" | "required";
policy_refs: string[];
handoff_kinds: string[];
};
export type ValidationResult = {
field?: string | null;
severity: "warning" | "error";
code: string;
message: string;
};
export type FormInstance = {
reference: InstitutionalReference;
tenant_id: string;
instance_id: string;
revision: number;
status: string;
definition_ref: InstitutionalReference;
values: Record<string, unknown>;
validation_results: ValidationResult[];
attachment_refs: EvidenceReference[];
signature_refs: EvidenceReference[];
handoff_refs: InstitutionalReference[];
service_ref?: InstitutionalReference | null;
receipt_id?: string | null;
recorded_at: string;
change_reason: string;
created_by: string;
changed_by: string;
replayed: boolean;
};
export type FormInstanceEvent = {
event_id: string;
event_type: string;
instance_revision: number;
status: string;
occurred_at: string;
actor_id: string;
payload: Record<string, unknown>;
};
export function listFormInstances(
settings: ApiSettings,
options: { statuses?: string[]; definitionId?: string; offset?: number; limit?: number } = {},
signal?: AbortSignal
): Promise<{ instances: FormInstance[]; total: number; offset: number; limit: number }> {
return apiFetch(settings, apiPath("/api/v1/forms-runtime/instances", {
status: options.statuses,
definition_id: options.definitionId,
offset: options.offset ?? 0,
limit: options.limit ?? 100
}), { signal });
}
export function getFormInstance(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}`, { signal });
}
export function getFormDefinition(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<FormDefinition> {
return apiFetch(
settings,
`/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/definition`,
{ signal }
);
}
export function getFormInstanceHistory(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ revisions: FormInstance[] }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/history`, { signal });
}
export function getFormInstanceEvents(
settings: ApiSettings,
instanceId: string,
signal?: AbortSignal
): Promise<{ events: FormInstanceEvent[] }> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instanceId)}/events`, { signal });
}
export function saveFormDraft(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>,
changeReason: string
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}`, {
method: "PATCH",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString(),
change_reason: changeReason
})
});
}
export function submitFormInstance(
settings: ApiSettings,
instance: FormInstance,
values: Record<string, unknown>
): Promise<FormInstance> {
return apiFetch(settings, `/api/v1/forms-runtime/instances/${encodeURIComponent(instance.instance_id)}/submit`, {
method: "POST",
body: JSON.stringify({
expected_revision: instance.revision,
values,
attachment_refs: instance.attachment_refs,
signature_refs: instance.signature_refs,
idempotency_key: crypto.randomUUID(),
recorded_at: new Date().toISOString()
})
});
}
@@ -0,0 +1,384 @@
import { ArrowLeft, Save, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router";
import {
Button,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
ToggleSwitch,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import {
getFormDefinition,
getFormInstance,
getFormInstanceEvents,
getFormInstanceHistory,
saveFormDraft,
submitFormInstance,
type FormDefinition,
type FormFieldDefinition,
type FormInstance,
type FormInstanceEvent,
type ValidationResult
} from "../../api/formsRuntime";
export default function FormInstancePage({ settings }: PlatformRouteContext) {
const { instanceId = "" } = useParams();
const navigate = useGuardedNavigate();
const [instance, setInstance] = useState<FormInstance | null>(null);
const [definition, setDefinition] = useState<FormDefinition | null>(null);
const [history, setHistory] = useState<FormInstance[]>([]);
const [events, setEvents] = useState<FormInstanceEvent[]>([]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [changeReason, setChangeReason] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError("");
try {
const nextInstance = await getFormInstance(settings, instanceId, signal);
const [nextDefinition, nextHistory, nextEvents] = await Promise.all([
getFormDefinition(settings, instanceId, signal),
getFormInstanceHistory(settings, instanceId, signal),
getFormInstanceEvents(settings, instanceId, signal)
]);
setInstance(nextInstance);
setDefinition(nextDefinition);
setHistory(nextHistory.revisions);
setEvents(nextEvents.events);
setValues(nextInstance.values);
setChangeReason("");
} finally {
setLoading(false);
}
}, [instanceId, settings]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "The Form could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
const editable = instance?.status === "started" || instance?.status === "draft";
const canSave = instance?.status === "draft" && definition?.allow_drafts;
const changed = useMemo(
() => Boolean(instance && JSON.stringify(values) !== JSON.stringify(instance.values)),
[instance, values]
);
const diagnostics = useMemo(() => {
const grouped = new Map<string, ValidationResult[]>();
for (const item of instance?.validation_results ?? []) {
const key = item.field ?? "";
grouped.set(key, [...(grouped.get(key) ?? []), item]);
}
return grouped;
}, [instance]);
async function save() {
if (!instance || !canSave || !changed || !changeReason.trim()) return;
setSaving(true);
setError("");
try {
await saveFormDraft(settings, instance, values, changeReason.trim());
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The draft could not be saved.");
} finally {
setSaving(false);
}
}
async function submit() {
if (!instance || !editable) return;
setSaving(true);
setError("");
try {
await submitFormInstance(settings, instance, values);
await load();
} catch (reason) {
setError(reason instanceof Error ? reason.message : "The Form could not be submitted.");
} finally {
setSaving(false);
}
}
return (
<main className="forms-runtime-page">
<div className="form-instance-shell">
<div className="form-instance-toolbar">
<Button onClick={() => navigate("/forms-runtime")}>
<ArrowLeft size={16} aria-hidden="true" />
Forms
</Button>
{definition && <strong>{definition.title}</strong>}
{instance && <StatusBadge status={editable ? "active" : "inactive"} label={humanize(instance.status)} />}
</div>
<PageScrollViewport className="form-instance-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading Form" />}
{!loading && instance && definition &&
<div className="form-instance-content">
<section className="form-instance-main">
<header>
<h1>{definition.title}</h1>
{definition.description && <p>{definition.description}</p>}
</header>
<div className="form-fields">
{definition.fields.map((field) =>
<FormField
key={field.key}
field={field}
value={values[field.key]}
disabled={!editable || saving}
diagnostics={diagnostics.get(field.key) ?? []}
onChange={(value) => setValues((current) => {
const next = { ...current };
if (value === undefined || value === "") delete next[field.key];
else next[field.key] = value;
return next;
})}
/>
)}
</div>
{(instance.attachment_refs.length > 0 || instance.signature_refs.length > 0) &&
<div className="form-evidence-summary">
<span>{instance.attachment_refs.length} attachments</span>
<span>{instance.signature_refs.length} signatures</span>
</div>
}
{editable &&
<div className="form-instance-actions">
{canSave &&
<label className="form-change-reason">
<span>Change reason</span>
<input
value={changeReason}
onChange={(event) => setChangeReason(event.target.value)}
disabled={saving}
/>
</label>
}
{canSave &&
<Button
onClick={() => void save()}
disabled={!changed || !changeReason.trim() || saving}>
<Save size={16} aria-hidden="true" />
Save draft
</Button>
}
<Button variant="primary" onClick={() => void submit()} disabled={saving}>
<Send size={16} aria-hidden="true" />
Submit
</Button>
</div>
}
{!editable && instance.receipt_id &&
<div className="form-receipt">
<span>Submission receipt</span>
<code>{instance.receipt_id}</code>
</div>
}
</section>
<aside className="form-instance-aside">
<section>
<h2>Status history</h2>
<ol>
{events.map((event) =>
<li key={event.event_id}>
<strong>{humanize(event.status)}</strong>
<span>{humanize(event.event_type)}</span>
<time>{formatDateTime(event.occurred_at)}</time>
</li>
)}
</ol>
</section>
<section>
<h2>Revisions</h2>
<ol>
{history.map((item) =>
<li key={item.revision}>
<strong>Revision {item.revision}</strong>
<span>{item.change_reason}</span>
<time>{formatDateTime(item.recorded_at)}</time>
</li>
)}
</ol>
</section>
</aside>
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function FormField({
field,
value,
disabled,
diagnostics,
onChange
}: {
field: FormFieldDefinition;
value: unknown;
disabled: boolean;
diagnostics: ValidationResult[];
onChange: (value: unknown) => void;
}) {
const describedBy = diagnostics.length > 0 ? `form-field-${field.key}-messages` : undefined;
if (field.value_type === "boolean") {
return (
<div className="form-field form-field-toggle">
<ToggleSwitch
label={`${field.label}${field.required ? " (required)" : ""}`}
checked={Boolean(value)}
onChange={onChange}
disabled={disabled}
help={field.help_text ?? undefined}
/>
<FieldMessages id={describedBy} diagnostics={diagnostics} />
</div>
);
}
return (
<label className="form-field">
<span>{field.label}{field.required && <b aria-hidden="true"> *</b>}</span>
{field.help_text && <small>{field.help_text}</small>}
{renderInput(field, value, disabled, describedBy, onChange)}
<FieldMessages id={describedBy} diagnostics={diagnostics} />
</label>
);
}
function renderInput(
field: FormFieldDefinition,
value: unknown,
disabled: boolean,
describedBy: string | undefined,
onChange: (value: unknown) => void
) {
const common = { disabled, required: field.required, "aria-describedby": describedBy };
if (field.value_type === "multiline_text" || field.value_type === "object" || field.value_type === "list") {
return (
<textarea
{...common}
rows={field.value_type === "multiline_text" ? 5 : 7}
value={structuredValue(value)}
onChange={(event) => onChange(field.value_type === "multiline_text" ? event.target.value : parseStructured(event.target.value))}
/>
);
}
if (field.value_type === "choice") {
return (
<select {...common} value={typeof value === "string" ? value : ""} onChange={(event) => onChange(event.target.value)}>
<option value="">Select</option>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
if (field.value_type === "multi_choice") {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
{...common}
multiple
value={selected}
onChange={(event) => onChange(Array.from(event.target.selectedOptions, (option) => option.value))}>
{field.options.map((option) => <option key={option} value={option}>{option}</option>)}
</select>
);
}
const type = field.value_type === "integer" || field.value_type === "number"
? "number"
: field.value_type === "email"
? "email"
: field.value_type === "date"
? "date"
: field.value_type === "datetime"
? "datetime-local"
: "text";
return (
<input
{...common}
type={type}
step={field.value_type === "integer" ? 1 : field.value_type === "number" ? "any" : undefined}
min={numericConstraint(field.constraints.minimum)}
max={numericConstraint(field.constraints.maximum)}
minLength={numericConstraint(field.constraints.min_length)}
maxLength={numericConstraint(field.constraints.max_length)}
value={inputValue(field, value)}
onChange={(event) => onChange(inputChangeValue(field, event.target.value))}
/>
);
}
function FieldMessages({ id, diagnostics }: { id?: string; diagnostics: ValidationResult[] }) {
if (diagnostics.length === 0) return null;
return (
<span id={id} className="form-field-messages" aria-live="polite">
{diagnostics.map((item) => <small key={item.code} data-severity={item.severity}>{item.message}</small>)}
</span>
);
}
function inputValue(field: FormFieldDefinition, value: unknown): string | number {
if (value === undefined || value === null) return "";
if (field.value_type === "datetime" && typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.valueOf())) {
const local = new Date(parsed.getTime() - parsed.getTimezoneOffset() * 60_000);
return local.toISOString().slice(0, 16);
}
}
return typeof value === "number" || typeof value === "string" ? value : String(value);
}
function inputChangeValue(field: FormFieldDefinition, value: string): unknown {
if (!value) return undefined;
if (field.value_type === "integer") return Number.parseInt(value, 10);
if (field.value_type === "number") return Number(value);
if (field.value_type === "datetime") return new Date(value).toISOString();
return value;
}
function structuredValue(value: unknown): string {
if (value === undefined || value === null) return "";
return typeof value === "string" ? value : JSON.stringify(value, null, 2);
}
function parseStructured(value: string): unknown {
if (!value.trim()) return undefined;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function numericConstraint(value: unknown): number | undefined {
return typeof value === "number" ? value : undefined;
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
@@ -0,0 +1,118 @@
import { RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
Button,
DismissibleAlert,
LoadingIndicator,
PageScrollViewport,
StatusBadge,
useGuardedNavigate,
type PlatformRouteContext
} from "@govoplan/core-webui";
import { listFormInstances, type FormInstance } from "../../api/formsRuntime";
const OPEN_STATUSES = ["started", "draft", "submitted", "validated", "needs_review"];
export default function FormsRuntimePage({ settings }: PlatformRouteContext) {
const navigate = useGuardedNavigate();
const [items, setItems] = useState<FormInstance[]>([]);
const [total, setTotal] = useState(0);
const [status, setStatus] = useState("open");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback((signal?: AbortSignal) => {
setLoading(true);
setError("");
return listFormInstances(settings, {
statuses: status === "open" ? OPEN_STATUSES : status ? [status] : undefined,
limit: 200
}, signal).
then((result) => {
setItems(result.instances);
setTotal(result.total);
}).
finally(() => setLoading(false));
}, [settings, status]);
useEffect(() => {
const controller = new AbortController();
load(controller.signal).catch((reason) => {
if ((reason as Error).name !== "AbortError") {
setError(reason instanceof Error ? reason.message : "Forms could not be loaded.");
}
});
return () => controller.abort();
}, [load]);
return (
<main className="forms-runtime-page">
<div className="forms-runtime-shell">
<div className="forms-runtime-toolbar">
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw size={16} aria-hidden="true" />
Refresh
</Button>
<label>
<span>Status</span>
<select value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="open">Open</option>
<option value="">All</option>
<option value="draft">Draft</option>
<option value="submitted">Submitted</option>
<option value="needs_review">Needs review</option>
<option value="accepted">Accepted</option>
<option value="rejected">Rejected</option>
<option value="handed_off">Handed off</option>
<option value="archived">Archived</option>
</select>
</label>
<span className="forms-runtime-count">{total} forms</span>
</div>
<PageScrollViewport className="forms-runtime-list-viewport">
{error &&
<DismissibleAlert tone="danger" resetKey={error}>
{error}
</DismissibleAlert>
}
{loading && <LoadingIndicator label="Loading forms" />}
{!loading && !error && items.length === 0 &&
<div className="forms-runtime-empty">No matching Forms.</div>
}
{!loading && items.length > 0 &&
<div className="forms-runtime-list" role="list">
{items.map((item) =>
<button
type="button"
role="listitem"
className="forms-runtime-row"
key={item.instance_id}
onClick={() => navigate(`/forms-runtime/${encodeURIComponent(item.instance_id)}`)}>
<span className="forms-runtime-row-main">
<strong>{item.definition_ref.label ?? humanize(item.definition_ref.object_id)}</strong>
<span>Revision {item.definition_ref.version ?? "-"}</span>
</span>
<span>{formatDateTime(item.recorded_at)}</span>
<StatusBadge status={isOpen(item.status) ? "active" : "inactive"} label={humanize(item.status)} />
</button>
)}
</div>
}
</PageScrollViewport>
</div>
</main>
);
}
function isOpen(status: string): boolean {
return OPEN_STATUSES.includes(status);
}
function formatDateTime(value: string): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
function humanize(value: string): string {
return value.replace(/[_:.-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
+2
View File
@@ -0,0 +1,2 @@
export { default, formsRuntimeModule } from "./module";
export * from "./api/formsRuntime";
+52
View File
@@ -0,0 +1,52 @@
import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui";
import "./styles/forms-runtime.css";
const FormsRuntimePage = lazy(() => import("./features/forms/FormsRuntimePage"));
const FormInstancePage = lazy(() => import("./features/forms/FormInstancePage"));
const routeScopes = [
"forms_runtime:submission:participate",
"forms_runtime:workspace:read"
];
export const formsRuntimeModule: PlatformWebModule = {
id: "forms_runtime",
label: "Forms",
version: "0.1.14",
dependencies: ["access", "forms"],
optionalDependencies: ["files", "approvals", "workflow_engine", "portal", "cases", "policy", "audit"],
routes: [
{
path: "/forms-runtime",
anyOf: routeScopes,
order: 37,
surfaceId: "forms_runtime.workspace",
render: (context) => createElement(FormsRuntimePage, context)
},
{
path: "/forms-runtime/:instanceId",
anyOf: routeScopes,
order: 38,
surfaceId: "forms_runtime.instance",
render: (context) => createElement(FormInstancePage, context)
}
],
navItems: [
{
to: "/forms-runtime",
label: "Forms",
iconName: "form",
anyOf: routeScopes,
order: 37,
surfaceId: "forms_runtime.navigation"
}
],
viewSurfaces: [
{ id: "forms_runtime.navigation", moduleId: "forms_runtime", kind: "navigation", label: "Forms navigation", order: 10 },
{ id: "forms_runtime.workspace", moduleId: "forms_runtime", kind: "route", label: "Forms workspace", order: 20 },
{ id: "forms_runtime.instance", moduleId: "forms_runtime", kind: "route", label: "Form instance", order: 30 }
]
};
export default formsRuntimeModule;
+299
View File
@@ -0,0 +1,299 @@
.forms-runtime-page,
.forms-runtime-shell,
.form-instance-shell {
height: 100%;
min-height: 0;
overflow: hidden;
}
.forms-runtime-shell,
.form-instance-shell {
display: flex;
flex-direction: column;
background: var(--surface);
}
.forms-runtime-toolbar,
.form-instance-toolbar {
display: flex;
align-items: center;
gap: 14px;
min-height: 58px;
padding: 10px 18px;
border-bottom: 1px solid var(--border);
background: var(--surface-raised);
}
.forms-runtime-toolbar label {
display: flex;
align-items: center;
gap: 7px;
}
.forms-runtime-toolbar label > span,
.forms-runtime-count {
color: var(--text-soft);
font-size: 0.82rem;
}
.forms-runtime-count,
.form-instance-toolbar .status-badge {
margin-left: auto;
}
.forms-runtime-list-viewport,
.form-instance-viewport {
flex: 1;
min-height: 0;
padding: 16px 18px 24px;
}
.forms-runtime-list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface-raised);
}
.forms-runtime-row {
display: grid;
grid-template-columns: minmax(260px, 1fr) minmax(170px, auto) auto;
align-items: center;
gap: 16px;
width: 100%;
min-height: 64px;
padding: 10px 14px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.forms-runtime-row:last-child {
border-bottom: 0;
}
.forms-runtime-row:hover,
.forms-runtime-row:focus-visible {
background: var(--hover-bg);
}
.forms-runtime-row-main {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
}
.forms-runtime-row-main strong,
.forms-runtime-row-main span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forms-runtime-row-main span,
.forms-runtime-row > span:not(.status-badge) {
color: var(--text-soft);
font-size: 0.84rem;
}
.forms-runtime-empty {
padding: 36px 0;
color: var(--text-soft);
text-align: center;
}
.form-instance-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 28px;
max-width: 1280px;
margin: 0 auto;
}
.form-instance-main,
.form-instance-aside {
min-width: 0;
}
.form-instance-main > header {
padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.form-instance-main h1 {
margin: 0;
font-size: 1.45rem;
letter-spacing: 0;
}
.form-instance-main > header p {
max-width: 70ch;
margin: 7px 0 0;
color: var(--text-soft);
}
.form-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px 18px;
padding: 20px 0;
}
.form-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.form-field:has(textarea),
.form-field:has(select[multiple]) {
grid-column: 1 / -1;
}
.form-field > span:first-child {
font-size: 0.84rem;
font-weight: 600;
}
.form-field > span b {
color: var(--danger);
}
.form-field > small {
color: var(--text-soft);
font-size: 0.78rem;
}
.form-field textarea {
resize: vertical;
}
.form-field select[multiple] {
min-height: 110px;
}
.form-field-toggle {
justify-content: flex-end;
min-height: 58px;
}
.form-field-messages {
display: flex;
flex-direction: column;
gap: 3px;
}
.form-field-messages [data-severity="error"] {
color: var(--danger);
}
.form-field-messages [data-severity="warning"] {
color: var(--warning);
}
.form-evidence-summary,
.form-receipt {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
padding: 12px 0;
border-top: 1px solid var(--border);
color: var(--text-soft);
font-size: 0.84rem;
}
.form-receipt {
flex-direction: column;
}
.form-receipt code {
overflow-wrap: anywhere;
color: var(--text);
}
.form-instance-actions {
display: flex;
align-items: end;
justify-content: flex-end;
gap: 10px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.form-change-reason {
display: flex;
min-width: min(360px, 100%);
flex: 1;
flex-direction: column;
gap: 5px;
}
.form-change-reason span {
color: var(--text-soft);
font-size: 0.8rem;
}
.form-instance-aside section + section {
margin-top: 24px;
}
.form-instance-aside h2 {
margin: 0 0 8px;
font-size: 0.95rem;
letter-spacing: 0;
}
.form-instance-aside ol {
margin: 0;
padding: 0;
list-style: none;
border-top: 1px solid var(--border);
}
.form-instance-aside li {
display: flex;
flex-direction: column;
gap: 3px;
padding: 10px 0 10px 12px;
border-bottom: 1px solid var(--border);
border-left: 2px solid var(--accent);
}
.form-instance-aside li span,
.form-instance-aside li time {
color: var(--text-soft);
font-size: 0.78rem;
}
@media (max-width: 860px) {
.forms-runtime-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.forms-runtime-row > span:not(.forms-runtime-row-main, .status-badge) {
display: none;
}
.form-instance-content {
grid-template-columns: 1fr;
}
.form-fields {
grid-template-columns: 1fr;
}
.form-field:has(textarea),
.form-field:has(select[multiple]) {
grid-column: auto;
}
.form-instance-actions {
align-items: stretch;
flex-direction: column;
}
}