feat(fit-connect): govern inbound acknowledgement plans
Module Package Release / publish-packages (push) Successful in 10s

This commit is contained in:
2026-08-23 17:53:23 +02:00
parent 8f7c57403e
commit ded8509ae7
10 changed files with 1323 additions and 0 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+7
View File
@@ -3,3 +3,10 @@
<!-- govoplan-repository-type:start -->
**Repository type:** connector (standard).
<!-- govoplan-repository-type:end -->
Provider-neutral, effect-free contracts for FIT-Connect inbound submission
receipts and technical accept, reject, or defer acknowledgement plans. No
journey, environment, destination, API version, or credential is activated by
default.
See [the inbound receipt and acknowledgement contract](docs/INBOUND_RECEIPT_AND_ACKNOWLEDGEMENT.md).
@@ -0,0 +1,68 @@
# FIT-Connect inbound receipt and acknowledgement
This slice models the receiving system's safe boundary around a FIT-Connect
submission. It does not choose a journey, environment, destination, API
profile, or credential, and it does not call FIT-Connect.
FIT-Connect distinguishes its technical receipt confirmation from subsequent
business review. An `accept-submission` event confirms that the receiver could
download, decrypt, and validate the submission. It must carry the matching
authentication tags. Successful acknowledgement can transition and remove the
service-side submission, so GovOPlaN must durably hand all content to its owning
workflow first.
## Ingress receipt
The configured profile binds the exact destination, Submission API version,
metadata-schema version, profile revision, and references to connection,
decryption, and event-signing credentials. Credential values never enter the
contract.
The submission evidence records SHA-256 values and authentication tags for
metadata, business data, and at most 100 uniquely identified attachments. The
receipt binds these values to submission, transaction, public service, region,
destination, profile, and receive time. It starts with both `acknowledged` and
`business_accepted` set to false.
## Acknowledgement decision
An acceptance plan requires all of the following:
- every component was downloaded;
- decryption succeeded;
- metadata and business-data schemas validated;
- all authentication tags were verified;
- an owning service, form, or case workflow durably stored the exact handoff.
Failed local download or durability always produces `defer`, leaving recovery
possible. A `reject` plan additionally requires a complete download, explicit
technical rejection classification, and bounded problem codes. Problem details
are operator evidence and are not suitable as applicant-facing messages.
The internal event request and plan are canonical and digest-bound. The request
is not a protocol-conformant SET wire payload. Plans always have
`dispatch_allowed=False`: a later target adapter must translate the request,
create and sign the Security Event Token, send it, reconcile the event log after
unknown outcomes, and prove recovery against the selected FIT-Connect
environment.
## Datenschutz und Betrieb
Das Modul speichert derzeit weder Antragsdaten noch Anlagen, Eingangsbelege,
Pläne oder Schlüssel. Das fachlich verantwortliche Service-, Forms- oder
Cases-Modul übernimmt Daten und Aufbewahrung, bevor eine technische Bestätigung
zulässig wird. Eine technische Eingangsbestätigung ist keine fachliche Annahme
oder positive Entscheidung über den Antrag.
Für den Zieltest werden Verwaltungsleistung, Zustellpunkt, Umgebung,
API-/Metadatenschemaversion, Client und Schlüssel, Anlagengrenzen,
Fachschema, Rückkanal, Ereignisprotokoll sowie Verfahren für Zeitüberschreitung,
Doppelabruf und Wiederherstellung benötigt.
Official orientation:
- [Receiving overview](https://docs.fitko.de/fit-connect/docs/receiving/overview/)
- [Download a submission](https://docs.fitko.de/fit-connect/docs/receiving/download-submission/)
- [Verify a submission](https://docs.fitko.de/fit-connect/docs/receiving/verification/)
- [Technical receipt acknowledgement](https://docs.fitko.de/fit-connect/docs/receiving/process-and-acknowledge/)
- [FIT-Connect events](https://docs.fitko.de/fit-connect/docs/getting-started/event-log/events/)
+31
View File
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-fit-connect"
version = "0.1.19"
description = "GovOPlaN FIT-Connect inbound receipt and acknowledgement connector."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.37",
"govoplan-access>=0.1.18",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_fit_connect = ["py.typed"]
[project.entry-points."govoplan.modules"]
fit_connect = "govoplan_fit_connect.backend.manifest:get_manifest"
[tool.pytest.ini_options]
pythonpath = ["src"]
[tool.ruff]
line-length = 100
target-version = "py312"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN FIT-Connect integration module."""
__all__: list[str] = []
@@ -0,0 +1 @@
"""Governed FIT-Connect inbound contracts."""
+453
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime
import json
import pytest
from govoplan_fit_connect.backend.inbound import (
FIT_CONNECT_ACCEPT_EVENT,
FIT_CONNECT_REJECT_EVENT,
FitConnectAttachmentEvidence,
FitConnectInboundError,
FitConnectProfile,
FitConnectSubmission,
FitConnectSubmissionEvidence,
FitConnectVerification,
build_acknowledgement_plan,
create_ingress_receipt,
)
from govoplan_fit_connect.backend.manifest import get_manifest
DESTINATION_ID = "736c4581-da80-4710-9384-d19ebe1ff2bc"
SUBMISSION_ID = "c82585d8-e49d-4e27-abd6-be3dbf61a76f"
def _profile() -> FitConnectProfile:
return FitConnectProfile(
profile_id="fit-connect-test-binding",
destination_id=DESTINATION_ID,
submission_api_version="configured-exact-version",
metadata_schema_version="configured-exact-version",
profile_revision="revision-4",
connection_ref="core-credential:fit-connect-client",
decryption_key_ref="core-key:fit-connect-decryption",
event_signing_key_ref="core-key:fit-connect-event-signing",
)
def _submission() -> FitConnectSubmission:
return FitConnectSubmission(
destination_id=DESTINATION_ID,
submission_id=SUBMISSION_ID,
transaction_reference="case:transaction-7",
public_service_identifier="urn:de:fim:leika:leistung:99102013104000",
region="DE094750156156",
received_at=datetime(2026, 8, 23, 12, tzinfo=UTC),
evidence=FitConnectSubmissionEvidence(
metadata_sha256="a" * 64,
data_sha256="b" * 64,
metadata_authentication_tag="metadata-tag",
data_authentication_tag="data-tag",
attachments=(
FitConnectAttachmentEvidence(
attachment_id="19ffb5ed-0a9d-02b2-9bfb-e271a8474c61",
content_sha256="c" * 64,
authentication_tag="attachment-tag",
),
),
),
)
def _verification(**changes) -> FitConnectVerification:
values = {
"downloaded_complete": True,
"decryption_succeeded": True,
"metadata_schema_valid": True,
"data_schema_valid": True,
"authentication_tags_verified": True,
"verified_at": datetime(2026, 8, 23, 12, 5, tzinfo=UTC),
"durable_handoff_reference": "cases:case-4:submission-1",
"durable_handoff_sha256": "d" * 64,
}
values.update(changes)
return FitConnectVerification(**values)
def test_ingress_receipt_is_exact_and_claims_no_acknowledgement() -> None:
receipt = create_ingress_receipt(_profile(), _submission())
assert receipt.acknowledged is False
assert receipt.business_accepted is False
assert len(receipt.receipt_sha256) == 64
def test_complete_durable_verification_builds_effect_free_accept_plan() -> None:
profile = _profile()
submission = _submission()
receipt = create_ingress_receipt(profile, submission)
plan = build_acknowledgement_plan(
profile,
submission,
receipt,
_verification(),
)
assert plan.disposition == "accept"
assert plan.event_type == FIT_CONNECT_ACCEPT_EVENT
assert plan.dispatch_allowed is False
assert plan.technical_receipt_only is True
assert plan.business_accepted is False
payload = json.loads(plan.event_request_json)
assert payload["authentication_tags"]["metadata"] == "metadata-tag"
assert payload["durable_handoff_sha256"] == "d" * 64
def test_complete_verification_without_durable_handoff_defers() -> None:
profile = _profile()
submission = _submission()
receipt = create_ingress_receipt(profile, submission)
verification = _verification(
durable_handoff_reference=None,
durable_handoff_sha256=None,
)
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
assert plan.disposition == "defer"
assert plan.event_type is None
assert plan.event_request_json is None
def test_incomplete_download_cannot_be_turned_into_rejection() -> None:
profile = _profile()
submission = _submission()
receipt = create_ingress_receipt(profile, submission)
verification = _verification(
downloaded_complete=False,
durable_handoff_reference=None,
durable_handoff_sha256=None,
failure_disposition="reject",
problem_codes=("download-incomplete",),
)
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
assert plan.disposition == "defer"
def test_explicit_permanent_technical_failure_builds_reject_plan() -> None:
profile = _profile()
submission = _submission()
receipt = create_ingress_receipt(profile, submission)
verification = _verification(
authentication_tags_verified=False,
durable_handoff_reference=None,
durable_handoff_sha256=None,
failure_disposition="reject",
problem_codes=("authentication-tags-invalid",),
)
plan = build_acknowledgement_plan(profile, submission, receipt, verification)
assert plan.disposition == "reject"
assert plan.event_type == FIT_CONNECT_REJECT_EVENT
assert json.loads(plan.event_request_json)["problem_codes"] == [
"authentication-tags-invalid"
]
def test_receipt_from_another_submission_is_rejected() -> None:
profile = _profile()
submission = _submission()
receipt = create_ingress_receipt(profile, submission)
wrong_receipt = replace(
receipt,
submission_id="02bf1d9f-282d-4abf-810a-c4104baf0afe",
)
with pytest.raises(FitConnectInboundError, match="receipt submission"):
build_acknowledgement_plan(profile, submission, wrong_receipt, _verification())
def test_manifest_declares_no_active_target_and_has_german_documentation() -> None:
manifest = get_manifest()
assert manifest.version == "0.1.19"
assert manifest.external_providers[0].id == "fit_connect.submission_api"
assert manifest.architecture is not None
assert manifest.architecture.target_tested_providers == ()
assert manifest.documentation[0].translations["de"]["title"]