feat(fit-connect): govern inbound acknowledgement plans
Module Package Release / publish-packages (push) Successful in 10s
Module Package Release / publish-packages (push) Successful in 10s
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""GovOPlaN FIT-Connect integration module."""
|
||||
|
||||
__all__: list[str] = []
|
||||
@@ -0,0 +1 @@
|
||||
"""Governed FIT-Connect inbound contracts."""
|
||||
@@ -0,0 +1,453 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
FIT_CONNECT_ACCEPT_EVENT = "https://schema.fitko.de/fit-connect/events/accept-submission"
|
||||
FIT_CONNECT_REJECT_EVENT = "https://schema.fitko.de/fit-connect/events/reject-submission"
|
||||
|
||||
AcknowledgementDisposition = Literal["accept", "reject", "defer"]
|
||||
FailureDisposition = Literal["retry", "reject"]
|
||||
|
||||
|
||||
class FitConnectInboundError(RuntimeError):
|
||||
"""Stable FIT-Connect receipt error without submission contents or secrets."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectProfile:
|
||||
"""Exact non-secret subscriber binding; no environment is selected by default."""
|
||||
|
||||
profile_id: str
|
||||
destination_id: str
|
||||
submission_api_version: str
|
||||
metadata_schema_version: str
|
||||
profile_revision: str
|
||||
connection_ref: str
|
||||
decryption_key_ref: str
|
||||
event_signing_key_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"profile_id",
|
||||
"submission_api_version",
|
||||
"metadata_schema_version",
|
||||
"profile_revision",
|
||||
"connection_ref",
|
||||
"decryption_key_ref",
|
||||
"event_signing_key_ref",
|
||||
):
|
||||
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"destination_id",
|
||||
_uuid(self.destination_id, "destination_id"),
|
||||
)
|
||||
|
||||
@property
|
||||
def profile_sha256(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"profile_id": self.profile_id,
|
||||
"destination_id": self.destination_id,
|
||||
"submission_api_version": self.submission_api_version,
|
||||
"metadata_schema_version": self.metadata_schema_version,
|
||||
"profile_revision": self.profile_revision,
|
||||
"connection_ref": self.connection_ref,
|
||||
"decryption_key_ref": self.decryption_key_ref,
|
||||
"event_signing_key_ref": self.event_signing_key_ref,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectAttachmentEvidence:
|
||||
attachment_id: str
|
||||
content_sha256: str
|
||||
authentication_tag: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "attachment_id", _uuid(self.attachment_id, "attachment_id"))
|
||||
object.__setattr__(
|
||||
self,
|
||||
"content_sha256",
|
||||
_sha256(self.content_sha256, "attachment_content_sha256"),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"authentication_tag",
|
||||
_text(self.authentication_tag, "attachment_authentication_tag", maximum=500),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectSubmissionEvidence:
|
||||
metadata_sha256: str
|
||||
data_sha256: str
|
||||
metadata_authentication_tag: str
|
||||
data_authentication_tag: str
|
||||
attachments: tuple[FitConnectAttachmentEvidence, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata_sha256",
|
||||
_sha256(self.metadata_sha256, "metadata_sha256"),
|
||||
)
|
||||
object.__setattr__(self, "data_sha256", _sha256(self.data_sha256, "data_sha256"))
|
||||
for name in ("metadata_authentication_tag", "data_authentication_tag"):
|
||||
object.__setattr__(
|
||||
self,
|
||||
name,
|
||||
_text(getattr(self, name), name, maximum=500),
|
||||
)
|
||||
if len(self.attachments) > 100:
|
||||
raise ValueError("FIT-Connect submission evidence is limited to 100 attachments.")
|
||||
attachment_ids = [item.attachment_id for item in self.attachments]
|
||||
if len(attachment_ids) != len(set(attachment_ids)):
|
||||
raise ValueError("FIT-Connect attachment evidence identifiers must be unique.")
|
||||
|
||||
@property
|
||||
def evidence_sha256(self) -> str:
|
||||
return _digest(self.to_payload())
|
||||
|
||||
def to_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"metadata_sha256": self.metadata_sha256,
|
||||
"data_sha256": self.data_sha256,
|
||||
"metadata_authentication_tag": self.metadata_authentication_tag,
|
||||
"data_authentication_tag": self.data_authentication_tag,
|
||||
"attachments": [
|
||||
{
|
||||
"attachment_id": item.attachment_id,
|
||||
"content_sha256": item.content_sha256,
|
||||
"authentication_tag": item.authentication_tag,
|
||||
}
|
||||
for item in self.attachments
|
||||
],
|
||||
}
|
||||
|
||||
def authentication_tags_payload(self) -> dict[str, object]:
|
||||
return {
|
||||
"metadata": self.metadata_authentication_tag,
|
||||
"data": self.data_authentication_tag,
|
||||
"attachments": {
|
||||
item.attachment_id: item.authentication_tag for item in self.attachments
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectSubmission:
|
||||
destination_id: str
|
||||
submission_id: str
|
||||
transaction_reference: str
|
||||
public_service_identifier: str
|
||||
region: str | None
|
||||
received_at: datetime
|
||||
evidence: FitConnectSubmissionEvidence
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"destination_id",
|
||||
_uuid(self.destination_id, "destination_id"),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"submission_id",
|
||||
_uuid(self.submission_id, "submission_id"),
|
||||
)
|
||||
for name in ("transaction_reference", "public_service_identifier"):
|
||||
object.__setattr__(self, name, _text(getattr(self, name), name))
|
||||
if self.region is not None:
|
||||
object.__setattr__(self, "region", _text(self.region, "region", maximum=100))
|
||||
_aware(self.received_at, "received_at")
|
||||
|
||||
@property
|
||||
def submission_sha256(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"destination_id": self.destination_id,
|
||||
"submission_id": self.submission_id,
|
||||
"transaction_reference": self.transaction_reference,
|
||||
"public_service_identifier": self.public_service_identifier,
|
||||
"region": self.region,
|
||||
"received_at": self.received_at.isoformat(),
|
||||
"evidence_sha256": self.evidence.evidence_sha256,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectIngressReceipt:
|
||||
destination_id: str
|
||||
submission_id: str
|
||||
profile_id: str
|
||||
profile_sha256: str
|
||||
submission_sha256: str
|
||||
evidence_sha256: str
|
||||
received_at: datetime
|
||||
receipt_sha256: str
|
||||
acknowledged: bool = False
|
||||
business_accepted: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectVerification:
|
||||
downloaded_complete: bool
|
||||
decryption_succeeded: bool
|
||||
metadata_schema_valid: bool
|
||||
data_schema_valid: bool
|
||||
authentication_tags_verified: bool
|
||||
verified_at: datetime
|
||||
durable_handoff_reference: str | None = None
|
||||
durable_handoff_sha256: str | None = None
|
||||
failure_disposition: FailureDisposition | None = None
|
||||
problem_codes: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_aware(self.verified_at, "verified_at")
|
||||
paired = (self.durable_handoff_reference is None) == (
|
||||
self.durable_handoff_sha256 is None
|
||||
)
|
||||
if not paired:
|
||||
raise ValueError("FIT-Connect durable handoff reference and digest must be supplied together.")
|
||||
if self.durable_handoff_reference is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"durable_handoff_reference",
|
||||
_text(self.durable_handoff_reference, "durable_handoff_reference"),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"durable_handoff_sha256",
|
||||
_sha256(self.durable_handoff_sha256, "durable_handoff_sha256"),
|
||||
)
|
||||
if len(self.problem_codes) > 50:
|
||||
raise ValueError("FIT-Connect verification is limited to 50 problem codes.")
|
||||
problems = tuple(_text(item, "problem_code", maximum=255) for item in self.problem_codes)
|
||||
if len(problems) != len(set(problems)):
|
||||
raise ValueError("FIT-Connect problem codes must be unique.")
|
||||
object.__setattr__(self, "problem_codes", problems)
|
||||
complete = self.technically_complete
|
||||
if complete and (self.failure_disposition is not None or problems):
|
||||
raise ValueError("Complete FIT-Connect verification cannot carry a failure disposition.")
|
||||
if not complete and self.failure_disposition == "reject" and not problems:
|
||||
raise ValueError("FIT-Connect rejection requires bounded technical problem codes.")
|
||||
|
||||
@property
|
||||
def technically_complete(self) -> bool:
|
||||
return all(
|
||||
(
|
||||
self.downloaded_complete,
|
||||
self.decryption_succeeded,
|
||||
self.metadata_schema_valid,
|
||||
self.data_schema_valid,
|
||||
self.authentication_tags_verified,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def durable(self) -> bool:
|
||||
return self.durable_handoff_reference is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FitConnectAcknowledgementPlan:
|
||||
destination_id: str
|
||||
submission_id: str
|
||||
disposition: AcknowledgementDisposition
|
||||
event_type: str | None
|
||||
receipt_sha256: str
|
||||
profile_sha256: str
|
||||
submission_sha256: str
|
||||
durable_handoff_reference: str | None
|
||||
durable_handoff_sha256: str | None
|
||||
problem_codes: tuple[str, ...]
|
||||
event_request_json: bytes | None
|
||||
event_request_sha256: str | None
|
||||
plan_sha256: str
|
||||
dispatch_allowed: bool = False
|
||||
technical_receipt_only: bool = True
|
||||
business_accepted: bool = False
|
||||
|
||||
|
||||
def create_ingress_receipt(
|
||||
profile: FitConnectProfile,
|
||||
submission: FitConnectSubmission,
|
||||
) -> FitConnectIngressReceipt:
|
||||
if submission.destination_id != profile.destination_id:
|
||||
raise FitConnectInboundError("FIT-Connect submission belongs to another destination.")
|
||||
payload = {
|
||||
"destination_id": submission.destination_id,
|
||||
"submission_id": submission.submission_id,
|
||||
"profile_id": profile.profile_id,
|
||||
"profile_sha256": profile.profile_sha256,
|
||||
"submission_sha256": submission.submission_sha256,
|
||||
"evidence_sha256": submission.evidence.evidence_sha256,
|
||||
"received_at": submission.received_at.isoformat(),
|
||||
}
|
||||
return FitConnectIngressReceipt(
|
||||
destination_id=submission.destination_id,
|
||||
submission_id=submission.submission_id,
|
||||
profile_id=profile.profile_id,
|
||||
profile_sha256=profile.profile_sha256,
|
||||
submission_sha256=submission.submission_sha256,
|
||||
evidence_sha256=submission.evidence.evidence_sha256,
|
||||
received_at=submission.received_at,
|
||||
receipt_sha256=_digest(payload),
|
||||
)
|
||||
|
||||
|
||||
def build_acknowledgement_plan(
|
||||
profile: FitConnectProfile,
|
||||
submission: FitConnectSubmission,
|
||||
receipt: FitConnectIngressReceipt,
|
||||
verification: FitConnectVerification,
|
||||
) -> FitConnectAcknowledgementPlan:
|
||||
_verify_binding(profile, submission, receipt)
|
||||
if verification.technically_complete and verification.durable:
|
||||
disposition: AcknowledgementDisposition = "accept"
|
||||
event_type = FIT_CONNECT_ACCEPT_EVENT
|
||||
event_request: dict[str, object] | None = {
|
||||
"event": event_type,
|
||||
"submission_id": submission.submission_id,
|
||||
"transaction_reference": submission.transaction_reference,
|
||||
"authentication_tags": submission.evidence.authentication_tags_payload(),
|
||||
"durable_handoff_reference": verification.durable_handoff_reference,
|
||||
"durable_handoff_sha256": verification.durable_handoff_sha256,
|
||||
}
|
||||
problems: tuple[str, ...] = ()
|
||||
elif (
|
||||
not verification.technically_complete
|
||||
and verification.failure_disposition == "reject"
|
||||
and verification.problem_codes
|
||||
and verification.downloaded_complete
|
||||
):
|
||||
disposition = "reject"
|
||||
event_type = FIT_CONNECT_REJECT_EVENT
|
||||
problems = verification.problem_codes
|
||||
event_request = {
|
||||
"event": event_type,
|
||||
"submission_id": submission.submission_id,
|
||||
"transaction_reference": submission.transaction_reference,
|
||||
"problem_codes": list(problems),
|
||||
}
|
||||
else:
|
||||
disposition = "defer"
|
||||
event_type = None
|
||||
event_request = None
|
||||
problems = verification.problem_codes
|
||||
event_request_json = _canonical_json(event_request) if event_request is not None else None
|
||||
event_request_sha256 = (
|
||||
hashlib.sha256(event_request_json).hexdigest() if event_request_json is not None else None
|
||||
)
|
||||
plan_payload = {
|
||||
"destination_id": submission.destination_id,
|
||||
"submission_id": submission.submission_id,
|
||||
"disposition": disposition,
|
||||
"event_type": event_type,
|
||||
"receipt_sha256": receipt.receipt_sha256,
|
||||
"profile_sha256": profile.profile_sha256,
|
||||
"submission_sha256": submission.submission_sha256,
|
||||
"verified_at": verification.verified_at.isoformat(),
|
||||
"durable_handoff_reference": verification.durable_handoff_reference,
|
||||
"durable_handoff_sha256": verification.durable_handoff_sha256,
|
||||
"problem_codes": list(problems),
|
||||
"event_request_sha256": event_request_sha256,
|
||||
}
|
||||
return FitConnectAcknowledgementPlan(
|
||||
destination_id=submission.destination_id,
|
||||
submission_id=submission.submission_id,
|
||||
disposition=disposition,
|
||||
event_type=event_type,
|
||||
receipt_sha256=receipt.receipt_sha256,
|
||||
profile_sha256=profile.profile_sha256,
|
||||
submission_sha256=submission.submission_sha256,
|
||||
durable_handoff_reference=verification.durable_handoff_reference,
|
||||
durable_handoff_sha256=verification.durable_handoff_sha256,
|
||||
problem_codes=problems,
|
||||
event_request_json=event_request_json,
|
||||
event_request_sha256=event_request_sha256,
|
||||
plan_sha256=_digest(plan_payload),
|
||||
)
|
||||
|
||||
|
||||
def _verify_binding(
|
||||
profile: FitConnectProfile,
|
||||
submission: FitConnectSubmission,
|
||||
receipt: FitConnectIngressReceipt,
|
||||
) -> None:
|
||||
checks = (
|
||||
(submission.destination_id == profile.destination_id, "submission destination"),
|
||||
(receipt.destination_id == profile.destination_id, "receipt destination"),
|
||||
(receipt.submission_id == submission.submission_id, "receipt submission"),
|
||||
(receipt.profile_id == profile.profile_id, "receipt profile"),
|
||||
(receipt.profile_sha256 == profile.profile_sha256, "receipt profile digest"),
|
||||
(receipt.submission_sha256 == submission.submission_sha256, "receipt submission digest"),
|
||||
(
|
||||
receipt.evidence_sha256 == submission.evidence.evidence_sha256,
|
||||
"receipt evidence digest",
|
||||
),
|
||||
)
|
||||
mismatch = next((label for valid, label in checks if not valid), None)
|
||||
if mismatch is not None:
|
||||
raise FitConnectInboundError(f"FIT-Connect {mismatch} does not match the exact ingress.")
|
||||
|
||||
|
||||
def _uuid(value: object, label: str) -> str:
|
||||
try:
|
||||
return str(UUID(str(value or "").strip()))
|
||||
except (ValueError, AttributeError) as exc:
|
||||
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be a UUID.") from exc
|
||||
|
||||
|
||||
def _text(value: object, label: str, *, maximum: int = 255) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized or len(normalized) > maximum or any(ord(char) < 32 for char in normalized):
|
||||
raise ValueError(
|
||||
f"FIT-Connect {label.replace('_', ' ')} is required, bounded, and must not contain controls."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _sha256(value: object, label: str) -> str:
|
||||
normalized = str(value or "").strip().lower().removeprefix("sha256:")
|
||||
if len(normalized) != 64 or any(char not in "0123456789abcdef" for char in normalized):
|
||||
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be a SHA-256 digest.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _aware(value: datetime, label: str) -> None:
|
||||
if value.tzinfo is None or value.utcoffset() is None:
|
||||
raise ValueError(f"FIT-Connect {label.replace('_', ' ')} must be timezone-aware.")
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FIT_CONNECT_ACCEPT_EVENT",
|
||||
"FIT_CONNECT_REJECT_EVENT",
|
||||
"FitConnectAcknowledgementPlan",
|
||||
"FitConnectAttachmentEvidence",
|
||||
"FitConnectInboundError",
|
||||
"FitConnectIngressReceipt",
|
||||
"FitConnectProfile",
|
||||
"FitConnectSubmission",
|
||||
"FitConnectSubmissionEvidence",
|
||||
"FitConnectVerification",
|
||||
"build_acknowledgement_plan",
|
||||
"create_ingress_receipt",
|
||||
]
|
||||
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ExternalProviderDeclaration,
|
||||
ExternalProviderRuntimeState,
|
||||
ExternalProviderStateContext,
|
||||
ExternalProviderStateProviderRegistration,
|
||||
ProviderBehaviorDeclaration,
|
||||
ProviderObjectDeclaration,
|
||||
declared_module_architecture,
|
||||
)
|
||||
|
||||
|
||||
MODULE_ID = "fit_connect"
|
||||
MODULE_VERSION = "0.1.19"
|
||||
READ_SCOPE = "fit_connect:submissions:read"
|
||||
VERIFY_SCOPE = "fit_connect:submissions:verify"
|
||||
ACK_SCOPE = "fit_connect:acknowledgements:plan"
|
||||
ADMIN_SCOPE = "fit_connect:integration:admin"
|
||||
FIT_CONNECT_PROVIDER_ID = "fit_connect.submission_api"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="FIT-Connect",
|
||||
level="tenant",
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
FIT_CONNECT_PROVIDER = ExternalProviderDeclaration(
|
||||
id=FIT_CONNECT_PROVIDER_ID,
|
||||
module_id=MODULE_ID,
|
||||
label="FIT-Connect Submission API subscriber",
|
||||
maturity="read",
|
||||
operations=("read", "preview", "dry_run"),
|
||||
objects=(
|
||||
ProviderObjectDeclaration(
|
||||
object_type="inbound_submission",
|
||||
field_groups=("transport_identity", "metadata", "data", "attachments", "authentication_tags"),
|
||||
authority_modes=("external_authoritative",),
|
||||
default_authority_mode="external_authoritative",
|
||||
),
|
||||
ProviderObjectDeclaration(
|
||||
object_type="technical_acknowledgement_plan",
|
||||
field_groups=("receipt", "verification", "event", "handoff"),
|
||||
authority_modes=("native_authoritative",),
|
||||
default_authority_mode="native_authoritative",
|
||||
),
|
||||
),
|
||||
behavior=ProviderBehaviorDeclaration(
|
||||
revision_tokens=(
|
||||
"Destination profile, API version, metadata schema version, submission id, "
|
||||
"transaction reference, and content evidence digests are retained."
|
||||
),
|
||||
concurrency=(
|
||||
"Acknowledgement planning is bound to one exact ingress receipt and verification result."
|
||||
),
|
||||
freshness=(
|
||||
"Ingress and verification timestamps are timezone-aware; remote availability must be observed separately."
|
||||
),
|
||||
health=(
|
||||
"Retrieval, decryption, schema validation, authentication tags, durable handoff, "
|
||||
"event signing, submission, and reconciliation are separate gates."
|
||||
),
|
||||
max_read_items=1000,
|
||||
idempotency=(
|
||||
"Submission id, destination id, receipt digest, and acknowledgement-plan digest form the correlation."
|
||||
),
|
||||
retry=(
|
||||
"Deferred acknowledgements retain the remote submission; event retry requires event-log reconciliation."
|
||||
),
|
||||
timeout_seconds=30,
|
||||
conflicts=(
|
||||
"Destination, profile, submission, content, receipt, or authentication-tag mismatches block acknowledgement."
|
||||
),
|
||||
outcome_unknown=(
|
||||
"An event timeout is unknown until the FIT-Connect event log is checked by exact submission correlation."
|
||||
),
|
||||
outcome_unknown_supported=True,
|
||||
evidence=(
|
||||
"Submission, content, authentication-tag, receipt, handoff, event-request, and plan digests form evidence."
|
||||
),
|
||||
correction=(
|
||||
"Retry local verification or create a new plan; never relabel technical receipt as business approval."
|
||||
),
|
||||
rollback=(
|
||||
"Acceptance or rejection may transition or delete the service-side submission and is not assumed reversible."
|
||||
),
|
||||
compensation=(
|
||||
"Use the module-owned case and a governed reply channel for later business correction."
|
||||
),
|
||||
reconciliation=(
|
||||
"Read the signed event log and verify event identity, submission binding, issuer, and authentication tags before retry."
|
||||
),
|
||||
outage=(
|
||||
"Do not acknowledge until all content is durably handed to its owning GovOPlaN workflow."
|
||||
),
|
||||
classifications=("confidential", "restricted"),
|
||||
purposes=("application receipt", "technical receipt acknowledgement"),
|
||||
retention=(
|
||||
"The owning service or case module retains application data; this connector retains only governed transport evidence when persistence is added."
|
||||
),
|
||||
secret_handling=(
|
||||
"OAuth, decryption, and event-signing material are credential references and never enter plans or diagnostics."
|
||||
),
|
||||
),
|
||||
documentation_topic_ids=("fit-connect.inbound-receipt",),
|
||||
)
|
||||
|
||||
|
||||
def _provider_states(
|
||||
context: ExternalProviderStateContext,
|
||||
) -> tuple[ExternalProviderRuntimeState, ...]:
|
||||
del context
|
||||
return (
|
||||
ExternalProviderRuntimeState(
|
||||
provider_id=FIT_CONNECT_PROVIDER_ID,
|
||||
observed_at=datetime.now(UTC),
|
||||
configured=False,
|
||||
active=False,
|
||||
health="inactive",
|
||||
freshness="not_applicable",
|
||||
conflict="not_applicable",
|
||||
recovery="unsupported",
|
||||
detail="No target-tested FIT-Connect destination binding is configured; event dispatch is disabled.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id=MODULE_ID,
|
||||
name="FIT-Connect",
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("access",),
|
||||
optional_dependencies=("portal", "forms_runtime", "services", "cases", "files", "audit", "policy"),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
permissions=(
|
||||
_permission(READ_SCOPE, "View FIT-Connect ingress", "Read bounded transport receipts and non-secret verification state."),
|
||||
_permission(VERIFY_SCOPE, "Verify FIT-Connect submission", "Record bounded download, decryption, schema, and authentication-tag evidence."),
|
||||
_permission(ACK_SCOPE, "Plan FIT-Connect acknowledgement", "Create an effect-free technical accept, reject, or defer plan."),
|
||||
_permission(ADMIN_SCOPE, "Administer FIT-Connect integration", "Configure and test destination, key, event, and recovery bindings."),
|
||||
),
|
||||
role_templates=(
|
||||
RoleTemplate(
|
||||
slug="fit_connect_receiver",
|
||||
name="FIT-Connect receiver",
|
||||
description="Verify inbound submissions and plan technical acknowledgements.",
|
||||
permissions=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="fit_connect_administrator",
|
||||
name="FIT-Connect administrator",
|
||||
description="Configure and verify governed FIT-Connect destination bindings.",
|
||||
permissions=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
),
|
||||
external_providers=(FIT_CONNECT_PROVIDER,),
|
||||
external_provider_state_providers=(
|
||||
ExternalProviderStateProviderRegistration(
|
||||
module_id=MODULE_ID,
|
||||
provider_id=FIT_CONNECT_PROVIDER_ID,
|
||||
provider=_provider_states,
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="fit-connect.boundary",
|
||||
title="FIT-Connect integration boundary",
|
||||
summary="Receive public-service transport evidence while service, form, and case modules retain business ownership.",
|
||||
body=(
|
||||
"FIT-Connect owns destination and subscriber profiles, bounded transport receipts, verification evidence, and technical acknowledgement plans. It does not own application semantics, case decisions, applicant communication, or business acceptance. A technical accept-submission event proves receipt and technical processability only."
|
||||
),
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("portal", "forms_runtime", "services", "cases", "audit"),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="FIT-Connect integration boundary",
|
||||
href="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Integrationsgrenze des FIT-Connect-Moduls",
|
||||
"summary": "Nachweise des Verwaltungsleistungstransports empfangen, während Service-, Formular- und Fallmodule die fachliche Verantwortung behalten.",
|
||||
"body": "FIT-Connect verantwortet Zustellpunkt- und Abonnentenprofile, begrenzte Transportbelege, Prüfnachweise und Pläne für technische Bestätigungen. Antragssemantik, Fallentscheidungen, Kommunikation mit Antragstellenden und fachliche Annahme gehören nicht zum Modul. Ein technisches accept-submission-Ereignis belegt ausschließlich Empfang und technische Verarbeitbarkeit.",
|
||||
}
|
||||
},
|
||||
order=90,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="fit-connect.inbound-receipt",
|
||||
title="Receive and technically acknowledge a FIT-Connect submission",
|
||||
summary=(
|
||||
"Bind the exact downloaded submission and authentication tags to a receipt, "
|
||||
"then accept only after complete verification and durable handoff."
|
||||
),
|
||||
body=(
|
||||
"A configured subscriber profile names the exact destination, Submission API and "
|
||||
"metadata-schema versions, profile revision, and non-secret references to connection, "
|
||||
"decryption, and event-signing material. The ingress receipt binds submission, "
|
||||
"transaction, public service, region, metadata, data, attachments, and authentication "
|
||||
"tags without claiming acknowledgement or business approval. An accept-submission plan "
|
||||
"requires complete download, successful decryption, valid metadata and business-data "
|
||||
"schemas, verified authentication tags, and a durable handoff to an owning module. A "
|
||||
"local durability failure always defers. A rejection requires explicit bounded technical "
|
||||
"problem codes and a complete download. Every plan remains non-dispatchable until signed "
|
||||
"SET creation, target submission, event-log reconciliation, and recovery are tested."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("user", "operator", "module_admin", "auditor"),
|
||||
related_modules=("portal", "forms_runtime", "services", "cases", "files", "audit"),
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
any_scopes=(READ_SCOPE, VERIFY_SCOPE, ACK_SCOPE, ADMIN_SCOPE),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="FIT-Connect inbound receipt contract",
|
||||
href="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "FIT-Connect-Einreichung empfangen und technisch bestätigen",
|
||||
"summary": "Die exakt heruntergeladene Einreichung und ihre Authentication-Tags an einen Eingangsbeleg binden und erst nach vollständiger Prüfung und dauerhafter Übergabe bestätigen.",
|
||||
"body": "Ein Abonnentenprofil bezeichnet Zustellpunkt, Versionen von Submission API und Metadatenschema, Profilrevision sowie nicht geheime Referenzen auf Verbindung, Entschlüsselung und Ereignissignatur. Der Eingangsbeleg bindet Einreichung, Transaktion, Verwaltungsleistung, Region, Metadaten, Fachdaten, Anlagen und Authentication-Tags, ohne eine Bestätigung oder fachliche Annahme zu behaupten. Ein Plan für accept-submission setzt vollständigen Abruf, erfolgreiche Entschlüsselung, gültige Meta- und Fachdaten, geprüfte Authentication-Tags sowie die dauerhafte Übergabe an ein fachlich verantwortliches Modul voraus. Lokale Speicherfehler führen immer zum Aufschub. Eine Zurückweisung braucht ausdrücklich geprüfte technische Problemcodes und einen vollständigen Abruf. Jeder Plan bleibt wirkungslos, bis SET-Signatur, Zielversand, Ereignisprotokollabgleich und Wiederherstellung getestet sind.",
|
||||
}
|
||||
},
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"prerequisites": [
|
||||
"A destination and subscriber client exist in an approved FIT-Connect environment.",
|
||||
"The deployment can resolve decryption and event-signing key references.",
|
||||
"An owning service, form, or case workflow can durably accept the exact submission.",
|
||||
],
|
||||
"steps": [
|
||||
"Retrieve every encrypted component and attachment for the exact submission id.",
|
||||
"Decrypt and validate metadata, business data, attachments, and authentication tags.",
|
||||
"Persist a digest-bound handoff in the owning workflow before acknowledgement.",
|
||||
"Plan accept, reject, or defer and reconcile any dispatched SET in the event log.",
|
||||
],
|
||||
"limitations": [
|
||||
"No environment, journey, destination, API profile, or credentials are activated by default.",
|
||||
"This release creates receipts and acknowledgement plans but does not create or send signed SETs.",
|
||||
"Technical acknowledgement is never business acceptance of the application.",
|
||||
],
|
||||
"consequences": [
|
||||
"Successful accept or reject processing may remove the submission from the delivery service.",
|
||||
"Incomplete local durability defers the event and preserves recovery options.",
|
||||
"Technical problem details are operator evidence and must not be exposed directly to applicants.",
|
||||
],
|
||||
},
|
||||
order=100,
|
||||
),
|
||||
),
|
||||
architecture=declared_module_architecture(
|
||||
layer="data_reporting_integration",
|
||||
kind="integration",
|
||||
maturity="vertical_slice",
|
||||
documentation_ref="docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",
|
||||
test_ref="tests/test_inbound.py",
|
||||
known_limits=(
|
||||
"A concrete FIT-Connect environment, destination, journey, credentials, signed-SET adapter, and target recovery test are required before event dispatch.",
|
||||
),
|
||||
supported_authority_modes=("external_authoritative", "native_authoritative"),
|
||||
owned_concepts=("FIT-Connect subscriber profile", "ingress receipt", "technical acknowledgement plan"),
|
||||
non_owned_concepts=("application", "case", "business acceptance", "submission file storage"),
|
||||
recovery_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||
security_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||
operations_docs=("docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md",),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Reference in New Issue
Block a user