Compare commits
78
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b19fbead | ||
|
|
845dcbafdb | ||
|
|
58d320d9b3 | ||
|
|
9554657bb5 | ||
|
|
88b685ff5e | ||
|
|
be57a1823a | ||
|
|
6bcb75f577 | ||
|
|
32fe4b7238 | ||
|
|
6a8f53b87d | ||
|
|
1cec4ee1d8 | ||
|
|
29d03aa2ca | ||
|
|
6fb928d6cf | ||
|
|
6edaaadf37 | ||
|
|
0b171fbdd4 | ||
|
|
ed6790c057 | ||
|
|
3766e26377 | ||
|
|
6c2b36af0f | ||
|
|
3f75ca8e48 | ||
|
|
a886a9b3de | ||
|
|
fe83290d56 | ||
|
|
c50f699399 | ||
|
|
59b45a0829 | ||
|
|
861abcc573 | ||
|
|
5e995fed88 | ||
|
|
41ca242004 | ||
|
|
85caa8d337 | ||
|
|
64640327ae | ||
|
|
cc7c2a91ee | ||
|
|
7c92565d9d | ||
|
|
23bfe5e2f8 | ||
|
|
cf2f7f6890 | ||
|
|
23b601bc0d | ||
|
|
99c52c2153 | ||
|
|
ca68d98806 | ||
|
|
79c4cb067a | ||
|
|
cd498dc1d8 | ||
|
|
e07b3487e3 | ||
|
|
72279de2c0 | ||
|
|
88543ab115 | ||
|
|
81fe0f4680 | ||
|
|
fe784cc562 | ||
|
|
9fe7ad2cb4 | ||
|
|
f1eebd849c | ||
|
|
4eb90079d5 | ||
|
|
628714804b | ||
|
|
ff9fa37a88 | ||
|
|
4c7552f0dd | ||
|
|
a20e02291d | ||
|
|
b6452c6f53 | ||
|
|
75103d49af | ||
|
|
a60b8b0752 | ||
|
|
7f2f896a0f | ||
|
|
60e04a324c | ||
|
|
6a74e53a1c | ||
|
|
6517b6ac27 | ||
|
|
26a66814b4 | ||
|
|
d08f9f0f2d | ||
|
|
47c90400af | ||
|
|
5d4535f7b5 | ||
|
|
83ccb7f198 | ||
|
|
f407419d25 | ||
|
|
e88dceb639 | ||
|
|
ef8fd45457 | ||
|
|
d0ff2f1510 | ||
|
|
f8b06887d2 | ||
|
|
69519a92b4 | ||
|
|
e2f505eeab | ||
|
|
a1b80eda27 | ||
|
|
5bb8028147 | ||
|
|
5efb0eea6f | ||
|
|
5e9234d4b6 | ||
|
|
b76581a89a | ||
|
|
bec62f38d1 | ||
|
|
c66e1b768d | ||
|
|
209a43592f | ||
|
|
50b81c9ca7 | ||
|
|
612a44bc8e | ||
|
|
dce725636d |
@@ -21,6 +21,8 @@ CELERY_ENABLED=true
|
|||||||
REDIS_URL=redis://127.0.0.1:6379/0
|
REDIS_URL=redis://127.0.0.1:6379/0
|
||||||
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,events,default
|
||||||
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90
|
||||||
|
SCHEDULING_PUBLIC_SELF_ENROLLMENT_ENABLED=true
|
||||||
|
SCHEDULING_PUBLIC_SELF_ENROLLMENT_MAX_CAPACITY=10000
|
||||||
|
|
||||||
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
GOVOPLAN_CONNECTOR_ALLOW_PRIVATE_NETWORKS=false
|
||||||
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES=16777216
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ jobs:
|
|||||||
- name: Test declarative deployment bundle
|
- name: Test declarative deployment bundle
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: python -m unittest -v tests.test_deployment_installer
|
run: python -m unittest -v tests.test_deployment_installer
|
||||||
|
- name: Test WebUI installer retry failures
|
||||||
|
working-directory: govoplan
|
||||||
|
run: python -m unittest -v tests.test_webui_release_dependency_retries
|
||||||
- name: Build single-file deployer artifact
|
- name: Build single-file deployer artifact
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ Synchronize module package workflows and inspect the registry release contract:
|
|||||||
|
|
||||||
Package publication, exact artifact locking, and the optional `govoplan`
|
Package publication, exact artifact locking, and the optional `govoplan`
|
||||||
developer meta-package are documented in
|
developer meta-package are documented in
|
||||||
[Package Registry Releases](docs/PACKAGE_REGISTRY_RELEASES.md).
|
[Package Registry Releases](docs/operations/PACKAGE_REGISTRY_RELEASES.md).
|
||||||
|
|
||||||
For reproducible release artifacts, set `SOURCE_DATE_EPOCH` to the release
|
For reproducible release artifacts, set `SOURCE_DATE_EPOCH` to the release
|
||||||
commit timestamp (or pass an explicit timezone-qualified `--timestamp`):
|
commit timestamp (or pass an explicit timezone-qualified `--timestamp`):
|
||||||
@@ -170,19 +170,19 @@ Create and validate a private, declarative installation bundle:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The current executable slice and remaining production gates are documented in
|
The current executable slice and remaining production gates are documented in
|
||||||
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
[Installation and Deployment Architecture](docs/operations/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
||||||
The canonical distinction between local source development, split source
|
The canonical distinction between local source development, split source
|
||||||
integration, immutable single-host rehearsal, one-host production and
|
integration, immutable single-host rehearsal, one-host production and
|
||||||
multi-host Kubernetes production is in
|
multi-host Kubernetes production is in
|
||||||
[Deployment Profiles](docs/DEPLOYMENT_PROFILES.md).
|
[Deployment Profiles](docs/operations/DEPLOYMENT_PROFILES.md).
|
||||||
Same-host replica balancing and the multi-host promotion boundary are documented
|
Same-host replica balancing and the multi-host promotion boundary are documented
|
||||||
in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
in [Scaling and Multi-Host Deployment](docs/operations/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
||||||
Create, update, pause, resume, verify and remove a local or multi-hypervisor K3s
|
Create, update, pause, resume, verify and remove a local or multi-hypervisor K3s
|
||||||
VM target with the guarded lifecycle documented in
|
VM target with the guarded lifecycle documented in
|
||||||
[Kubernetes VM Test Lab](docs/KUBERNETES_TEST_LAB.md).
|
[Kubernetes VM Test Lab](docs/operations/KUBERNETES_TEST_LAB.md).
|
||||||
The recovery state machine, migration rollback boundary, and required restore
|
The recovery state machine, migration rollback boundary, and required restore
|
||||||
drills are documented in
|
drills are documented in
|
||||||
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
[Recovery and Rollback Guarantees](docs/operations/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -195,47 +195,14 @@ such as `~/.config/gitea/gitea.env` and be passed with `--env-file`.
|
|||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
The repository categories are documented in
|
Start with the [documentation map](docs/README.md). It separates stable
|
||||||
`docs/REPOSITORY_STRUCTURE.md`. The machine-readable list lives in
|
strategy, architecture, operations, project reference, pinned evidence, and
|
||||||
`repositories.json`; the clickable human-readable index is
|
historical records and identifies the canonical source for each question.
|
||||||
`docs/REPOSITORY_INDEX.md`.
|
|
||||||
|
|
||||||
Meta ownership and module install/contract boundaries are documented in
|
The machine-readable repository list lives in `repositories.json`; the
|
||||||
`docs/META_REPO_SCAN.md` and `docs/MODULE_CONTRACTS_AND_INSTALLS.md`.
|
clickable directory is the
|
||||||
Frontend layout principles for module pages are documented in
|
[Repository Index](docs/project/REPOSITORY_INDEX.md), and ownership boundaries
|
||||||
`docs/FRONTEND_LAYOUT_PRINCIPLES.md`.
|
are in [Repository Structure](docs/project/REPOSITORY_STRUCTURE.md).
|
||||||
The provider-neutral datasource boundary and reusable Dataflow/Workflow graph
|
|
||||||
contract are documented in
|
|
||||||
`docs/DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md`.
|
|
||||||
The cross-product destination, stakeholder visions, configuration archetypes,
|
|
||||||
connected outcome stories, and capability horizons are documented in
|
|
||||||
the [Connected Governance Platform Roadmap](docs/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md).
|
|
||||||
The reconciled institutional semantics, source-authority modes, module layers,
|
|
||||||
candidate Mandates/Services/Parties/Decisions boundaries, and migration
|
|
||||||
sequence are documented in the
|
|
||||||
[Institutional Governance Target Architecture](docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
|
||||||
The selected Campaign-to-Postbox-to-data-to-collaboration implementation path,
|
|
||||||
including stage gates and shared documentation expectations, is in the
|
|
||||||
[Reference Journey Program](docs/REFERENCE_JOURNEY_PROGRAM.md).
|
|
||||||
The administrator journey from a Core-baseline bootstrap through online module
|
|
||||||
installation, scale-out, and reversible environment promotion is defined in
|
|
||||||
[System Administrator Lifecycle User Story](docs/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
|
||||||
The corresponding host deployment compiler, managed/external component choices,
|
|
||||||
reconfiguration semantics, and safe Web update boundary are defined in
|
|
||||||
[Installation and Deployment Architecture](docs/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md).
|
|
||||||
The concrete replica, worker-node, load-balancer, and shared-state topology is
|
|
||||||
defined in [Scaling and Multi-Host Deployment](docs/SCALING_AND_MULTI_HOST_DEPLOYMENT.md).
|
|
||||||
Durable deployment journals, Core recovery evidence, and the distinction
|
|
||||||
between pre-migration configuration restore and post-migration forward recovery
|
|
||||||
are defined in
|
|
||||||
[Recovery and Rollback Guarantees](docs/RECOVERY_AND_ROLLBACK_GUARANTEES.md).
|
|
||||||
The first Campaign-centric capability and infrastructure fit assessment is in
|
|
||||||
`docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and
|
|
||||||
verify a bounded installed composition; target, provider and production claims
|
|
||||||
remain separate, expiring attestations signed by independently scoped proof
|
|
||||||
authorities. The operational issuance, target-run, recovery-measurement, key
|
|
||||||
custody, and promotion-gate procedure is in
|
|
||||||
[Target Maturity Evidence Runbook](docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md).
|
|
||||||
|
|
||||||
# GovOPlaN Docker
|
# GovOPlaN Docker
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
This profile runs the shared services that production depends on while keeping
|
This profile runs the shared services that production depends on while keeping
|
||||||
API, worker, scheduler, and WebUI code in the editable local repositories.
|
API, worker, scheduler, and WebUI code in the editable local repositories.
|
||||||
It is the **split source integration** profile defined in
|
It is the **split source integration** profile defined in
|
||||||
[`docs/DEPLOYMENT_PROFILES.md`](../../docs/DEPLOYMENT_PROFILES.md). It does not
|
[`docs/operations/DEPLOYMENT_PROFILES.md`](../../docs/operations/DEPLOYMENT_PROFILES.md). It does not
|
||||||
exercise signed application images. Use an installer-generated evaluation
|
exercise signed application images. Use an installer-generated evaluation
|
||||||
Compose bundle for an immutable Dockerized whole-product rehearsal.
|
Compose bundle for an immutable Dockerized whole-product rehearsal.
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
# GovOPlaN Frontend Layout Principles
|
|
||||||
|
|
||||||
GovOPlaN modules should choose their page layout by the kind of work the user is
|
|
||||||
doing, not by the repository that owns the feature.
|
|
||||||
|
|
||||||
These concise layout choices are one canonical input to the broader
|
|
||||||
[`INTERFACE_PATTERN_LANGUAGE.md`](INTERFACE_PATTERN_LANGUAGE.md). The current
|
|
||||||
route and rollout evidence lives in
|
|
||||||
[`INTERFACE_SURFACE_INVENTORY.md`](INTERFACE_SURFACE_INVENTORY.md).
|
|
||||||
|
|
||||||
## Structured Data Directories
|
|
||||||
|
|
||||||
Use a full-available-space workspace for structured data directories: files,
|
|
||||||
addresses, calendars, records, mailboxes, document stores, and similar domains
|
|
||||||
where the primary task is browsing, selecting, filtering, inspecting, and acting
|
|
||||||
on related objects.
|
|
||||||
|
|
||||||
Principles:
|
|
||||||
|
|
||||||
- The module route should use the full available content area.
|
|
||||||
- Do not add a separate page heading row above the main workspace.
|
|
||||||
- Prefer persistent navigation panes, such as tree panels, source panels, folder
|
|
||||||
panels, calendar list panels, or mailbox folder panels.
|
|
||||||
- Keep collection navigation and collection-level actions close to the relevant
|
|
||||||
pane header.
|
|
||||||
- In a list-detail workspace such as Scheduling, keep related lists stacked in
|
|
||||||
the left pane and use the remaining main pane for view/create/edit. A single
|
|
||||||
Add action stays in the relevant list-pane header and opens the common main
|
|
||||||
editor; it does not create an additional menu or launcher.
|
|
||||||
- Use bounded widths for navigation/list panes and let the main detail/content
|
|
||||||
pane take the remaining space.
|
|
||||||
- Keep filtering controls inside the pane they affect.
|
|
||||||
- Use overlays, toasts, or floating alerts for transient messages so the
|
|
||||||
workspace height does not change.
|
|
||||||
|
|
||||||
This pattern is appropriate when the user is working inside one coherent data
|
|
||||||
domain and needs spatial continuity.
|
|
||||||
|
|
||||||
## Workflow And Configuration Surfaces
|
|
||||||
|
|
||||||
Use the standard heading/menu/card visual language for workflow structures,
|
|
||||||
settings, administration, dashboards, and pages that collect essentially
|
|
||||||
unrelated areas.
|
|
||||||
|
|
||||||
Principles:
|
|
||||||
|
|
||||||
- A page heading and subnavigation are appropriate when the page explains a
|
|
||||||
task, workflow stage, or administrative area.
|
|
||||||
- Cards are appropriate for repeated independent panels, settings groups,
|
|
||||||
summaries, and dashboard widgets.
|
|
||||||
- Collapsible panels and segmented controls are appropriate when a dense
|
|
||||||
configuration area needs controlled disclosure.
|
|
||||||
- A collapsible card whose sole content is a table gives that table the full
|
|
||||||
available card body; avoid nested cards, duplicate padding, inner max-widths,
|
|
||||||
and nested scrolling.
|
|
||||||
- Avoid forcing workflow/configuration pages into a file-explorer style unless
|
|
||||||
the primary interaction is genuinely directory browsing.
|
|
||||||
|
|
||||||
This pattern is appropriate when the user is comparing or configuring separate
|
|
||||||
concerns rather than navigating one structured object space.
|
|
||||||
|
|
||||||
## Shared Components
|
|
||||||
|
|
||||||
Reusable layout components belong in `govoplan-core` WebUI. Modules may consume
|
|
||||||
shared components from core, but must not import another module's private UI
|
|
||||||
components directly.
|
|
||||||
|
|
||||||
When a module-specific component becomes generally useful, promote it to core
|
|
||||||
with a parameterized API before reusing it elsewhere.
|
|
||||||
|
|
||||||
Non-self-explanatory fields use Core `FieldLabel`; documented omissions must
|
|
||||||
name their accessible-label source. Explicit Discard and dirty navigation use
|
|
||||||
the same Core unsaved-changes dialog. Table action sets retain unavailable row
|
|
||||||
actions as disabled controls and reserve empty-state slots so Add remains
|
|
||||||
aligned. Use central feedback/dialog components; `window.alert` is not an
|
|
||||||
authorized product surface unless a product-owner-approved exception is first
|
|
||||||
recorded in the Core decision ledger.
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
# Package Registry Releases
|
|
||||||
|
|
||||||
GovOPlaN publishes reusable module artifacts through Gitea's native PyPI and
|
|
||||||
npm registries. These packages improve developer installation, release
|
|
||||||
resolution, cacheability, and artifact inspection. They do not replace the
|
|
||||||
signed runtime distribution: the signed manifest and digest-pinned OCI images
|
|
||||||
remain the production deployment authority.
|
|
||||||
|
|
||||||
## Publication boundary
|
|
||||||
|
|
||||||
Every repository with a `pyproject.toml` contains
|
|
||||||
`.gitea/workflows/module-package-release.yml`. The meta repository owns the
|
|
||||||
canonical template and installs it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/repo/sync-module-package-workflows.py --write
|
|
||||||
python tools/repo/sync-module-package-workflows.py --check
|
|
||||||
```
|
|
||||||
|
|
||||||
The workflow runs for `v*` tags and may be dispatched manually for an existing
|
|
||||||
tag. The organization preflight verifies that every package repository protects
|
|
||||||
the `v*` namespace. Before building, the workflow itself verifies that:
|
|
||||||
|
|
||||||
- the tagged commit is contained in `main`;
|
|
||||||
- the tag, Python project version, and optional WebUI package version agree;
|
|
||||||
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
|
|
||||||
|
|
||||||
The workflow binds the repository explicitly from the Gitea Actions context.
|
|
||||||
Do not rely on GitHub-compatible environment variables being injected by the
|
|
||||||
runner image; Gitea runners may expose only the context values. Gitea 1.24 job
|
|
||||||
tokens cannot read repository tag-protection settings, so package jobs must not
|
|
||||||
receive a broad administrator token merely to repeat the organization preflight.
|
|
||||||
Run the following before the first publication and after repository or tag-rule
|
|
||||||
changes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/gitea/gitea-configure-package-releases.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Preview and dispatch the exact wheel/WebUI versions selected by the developer
|
|
||||||
meta-package with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/gitea/gitea-dispatch-package-set.py \
|
|
||||||
--env-file ~/.config/gitea/gitea.env
|
|
||||||
python tools/gitea/gitea-dispatch-package-set.py \
|
|
||||||
--env-file ~/.config/gitea/gitea.env \
|
|
||||||
--apply
|
|
||||||
```
|
|
||||||
|
|
||||||
The dispatcher reads exact versions from `packages/govoplan-meta/pyproject.toml`,
|
|
||||||
inspects the selected tag to determine whether a WebUI package is expected,
|
|
||||||
skips complete registry pairs and does not duplicate an active workflow. Use
|
|
||||||
`--repository govoplan-core` for a bounded dispatch or `--verify-existing` to
|
|
||||||
rebuild and hash-verify versions already present in both registries.
|
|
||||||
|
|
||||||
For coordinated lockstep tags, `push-release-tag.sh` pushes module tags first,
|
|
||||||
Core next, and the meta tag last. This is a dependency guarantee for a
|
|
||||||
single-capacity Actions runner: the developer package cannot run before its
|
|
||||||
exact Core and module versions have entered the queue.
|
|
||||||
|
|
||||||
The same release entry point first validates the migration graph, then records
|
|
||||||
the reviewed current Alembic heads under the target release version and reruns
|
|
||||||
the strict migration audit before it changes package versions, commits, or
|
|
||||||
tags. The default preflight intentionally does not require those heads to exist
|
|
||||||
in the previous release baseline. A failed candidate-baseline check therefore
|
|
||||||
cannot produce a protected package release.
|
|
||||||
|
|
||||||
The source gate validates `pyproject.toml`, the module version declaration
|
|
||||||
(`MODULE_VERSION` or the top-level `ModuleManifest.version`), public package
|
|
||||||
`__version__`, and WebUI metadata before creating tags. Release-tag artifact
|
|
||||||
checks run only after the candidate tags and immutable WebUI lock have been
|
|
||||||
created locally.
|
|
||||||
|
|
||||||
Release-lock regeneration resolves a fresh immutable lock from the reviewed
|
|
||||||
candidate manifests; it does not seed resolution from the previous release
|
|
||||||
lock. This prevents removed transitive packages and stale peer metadata from
|
|
||||||
blocking or contaminating the new release. Candidate resolution also uses an
|
|
||||||
isolated temporary npm cache, so a locally replaced tag cannot reuse metadata
|
|
||||||
from a failed, unpushed release attempt.
|
|
||||||
|
|
||||||
Modules that retain the same WebUI package identity in both a root publish
|
|
||||||
manifest and `webui/package.json` use the WebUI manifest as the canonical peer
|
|
||||||
contract. The coordinated release synchronizes `peerDependencies` and
|
|
||||||
`peerDependenciesMeta` into the publish manifest before creating the module
|
|
||||||
tag, then synchronizes each lockfile root from the final package metadata. A
|
|
||||||
distinct root package remains independent.
|
|
||||||
|
|
||||||
It builds one wheel and, where applicable, one npm tarball. The workflow records
|
|
||||||
the source tag, source commit, filename, size, and SHA-256 in
|
|
||||||
`package-artifacts.json` before publishing. Gitea rejects a second upload of the
|
|
||||||
same package version, so correction requires a new version rather than artifact
|
|
||||||
replacement.
|
|
||||||
|
|
||||||
A retry after partial publication is safe. Before upload, the workflow reads the
|
|
||||||
native package registry file record and compares its SHA-256 with the artifact
|
|
||||||
rebuilt from the protected tag. An exact existing artifact is skipped; a
|
|
||||||
same-version artifact with another digest or an unexpected file set fails
|
|
||||||
closed. This permits a failed npm publication to resume without weakening
|
|
||||||
package immutability or accepting `--skip-existing` blindly.
|
|
||||||
|
|
||||||
The npm tarball is always published through an explicit local `./dist/...`
|
|
||||||
path. Without that prefix, npm may interpret a relative tarball name as a Git
|
|
||||||
package shorthand before it ever contacts the configured registry.
|
|
||||||
|
|
||||||
Published WebUI packages contain registry-compatible dependencies only. The
|
|
||||||
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
|
|
||||||
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
|
|
||||||
dependencies. Repository development metadata may therefore keep local or Git
|
|
||||||
references without leaking them into the published package contract.
|
|
||||||
Historical `add-ideas` and current `GovOPlaN` organization URLs are accepted
|
|
||||||
for immutable tagged releases; both normalize to the same exact registry
|
|
||||||
dependency and no branch or unversioned Git reference is accepted.
|
|
||||||
|
|
||||||
## One-time Gitea setup
|
|
||||||
|
|
||||||
Protect `v*` tags in every package repository and the meta repository. Allow
|
|
||||||
only the `Owners` team to create or delete those tags.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
set -a
|
|
||||||
. ~/.config/gitea/gitea.env
|
|
||||||
set +a
|
|
||||||
python tools/gitea/gitea-configure-package-releases.py --apply
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a dedicated personal access token with only `write:package` scope and
|
|
||||||
store these organization-level Actions secrets on `GovOPlaN`:
|
|
||||||
|
|
||||||
- `GOVOPLAN_PACKAGE_USERNAME`: account owning the package token;
|
|
||||||
- `GOVOPLAN_PACKAGE_TOKEN`: dedicated package-write token.
|
|
||||||
|
|
||||||
Do not use an administrator or general release token. Gitea 1.24 does not grant
|
|
||||||
package publication to the automatic Actions job token. Organization secrets
|
|
||||||
allow the same least-privilege credential to serve every module workflow.
|
|
||||||
|
|
||||||
## Exact release consumption
|
|
||||||
|
|
||||||
`tools/release/generate-release-package-set.py` supports two explicit package
|
|
||||||
profiles. `base` translates the reviewed roots in `requirements-release.txt`;
|
|
||||||
`full` reads the exact `govoplan[full]` dependency set from the developer
|
|
||||||
meta-package. Both profiles resolve every version tag to its commit and verify
|
|
||||||
the package metadata from that exact Git tree. The official module directory
|
|
||||||
and immutable runtime distribution use `full`, so every publicly released
|
|
||||||
module can be discovered without rebuilding the application image.
|
|
||||||
|
|
||||||
`tools/release/resolve-package-artifacts.py` then downloads exactly those wheel
|
|
||||||
and WebUI versions from Gitea. It reads the identity embedded in every wheel and
|
|
||||||
npm tarball, rejects missing, duplicate, unexpected, or oversized artifacts,
|
|
||||||
and writes `package-artifacts.lock.json` with credential-free HTTPS download
|
|
||||||
URLs, SHA-256 values, and npm registry integrity values. The resolver verifies
|
|
||||||
that the bytes downloaded by `npm pack` match the registry's own integrity
|
|
||||||
record. Credentials are accepted only through environment variables and are
|
|
||||||
never written to the lock. Python resolution ignores ambient pip configuration
|
|
||||||
and extra indexes for GovOPlaN roots, preventing an internal package name from
|
|
||||||
being selected from an undeclared registry.
|
|
||||||
|
|
||||||
The runtime distribution workflow uses the verified full-profile wheelhouse
|
|
||||||
directly and installs every selected module WebUI tarball only after matching
|
|
||||||
it to the lock. It publishes the package set, package lock, and hash-locked
|
|
||||||
requirements as release assets.
|
|
||||||
The WebUI installer receives the absolute runtime-build interpreter path so its
|
|
||||||
directory changes cannot escape the isolated release environment.
|
|
||||||
Gitea 1.24 dispatches this workflow from a branch, but that branch is only the
|
|
||||||
workflow implementation. The job fetches and peels the protected `v<version>`
|
|
||||||
tag explicitly and materializes both `requirements-release.txt` and the
|
|
||||||
developer meta-package from that Git tree. It then binds the signed distribution
|
|
||||||
source and Gitea release assets to the same exact commit. A post-tag workflow
|
|
||||||
repair can therefore retry publication without changing the released package
|
|
||||||
composition or relabelling the later branch commit as released source.
|
|
||||||
The package-lock SHA-256 is part of the signed distribution manifest. Runtime
|
|
||||||
finalization also requires the lock's package versions and hashes to match the
|
|
||||||
wheel composition embedded in the images. OCI assembly remains network-free
|
|
||||||
after package and third-party dependency resolution.
|
|
||||||
|
|
||||||
The source refs remain in the module catalog for source provenance and release
|
|
||||||
planning. Production installation consumes the signed runtime images rather
|
|
||||||
than invoking `pip`, `npm`, or Git on the target host.
|
|
||||||
|
|
||||||
## Public module directory
|
|
||||||
|
|
||||||
`tools/release/publish-release-catalog.sh` resolves the selected package set and
|
|
||||||
registry lock before it creates a catalog. Catalog entries are synthesized from
|
|
||||||
the exact tagged module manifests, never from a hand-maintained module list or
|
|
||||||
the current workspace. Each entry binds its Python wheel and optional WebUI
|
|
||||||
tarball to the registry URL, filename, size, SHA-256, package identity, source
|
|
||||||
tag, and source commit before the complete catalog is signed.
|
|
||||||
|
|
||||||
The same publication transaction regenerates and prunes the browsable static
|
|
||||||
directory under `public/catalogs/v1/modules/`. It writes a global
|
|
||||||
`modules/index.json`, one `<module>/index.json`, and one
|
|
||||||
`<module>/<version>/manifest.json` for every entry in the signed channel.
|
|
||||||
These files are derived from that exact signed payload and keyring; stale JSON
|
|
||||||
from an older partial catalog is removed while unrelated static assets are left
|
|
||||||
untouched. The signed channel remains the trust anchor, while the module
|
|
||||||
directory provides stable discovery URLs for browsers and external tooling.
|
|
||||||
|
|
||||||
Official GovOPlaN modules are open-source directory entries and do not require
|
|
||||||
license entitlements. The generic `license_features` contract remains available
|
|
||||||
for third-party package directories, support/configuration packages, or future
|
|
||||||
deployment-specific presets. A catalog entry is gated only when that entry
|
|
||||||
explicitly declares such features.
|
|
||||||
|
|
||||||
Core carries the public stable catalog URL and its independently pinned trust
|
|
||||||
anchor. In the absence of an operator-configured catalog, Admin discovers the
|
|
||||||
official directory automatically. Selecting an entry creates a reviewed
|
|
||||||
install/update plan; the trusted installer downloads the exact signed artifacts
|
|
||||||
into a private digest cache, verifies size and hash, and installs only from that
|
|
||||||
cache. A saved plan is rejected if any package ref, artifact identity, catalog
|
|
||||||
channel, sequence, or signing-key identity differs from the currently validated
|
|
||||||
catalog.
|
|
||||||
|
|
||||||
The Admin directory can be searched by module, package, repository, or tag and
|
|
||||||
filtered by available, installed, update, and blocked/withdrawn states. It
|
|
||||||
shows the source revision, artifact digest, release notes, and configuration
|
|
||||||
requirements. Missing dependency/interface providers and unsupported update
|
|
||||||
windows are surfaced before an operator adds the entry to a plan; installer
|
|
||||||
preflight remains authoritative.
|
|
||||||
|
|
||||||
Package lifecycle and availability are intentionally separate:
|
|
||||||
|
|
||||||
- install, update, and uninstall change the instance-wide package composition;
|
|
||||||
- enable and disable change the active instance runtime graph;
|
|
||||||
- tenant module entitlements define unavailable, available, and forced modules;
|
|
||||||
- group/user presentation is governed through Views and Policy; and
|
|
||||||
- enabling a capability module does not opt data into that capability.
|
|
||||||
|
|
||||||
Single-process or single-host installations may execute a supervised package
|
|
||||||
plan locally. Shared-state and Kubernetes profiles reject node-local package
|
|
||||||
mutation: operators compose and roll out a new signed full-profile runtime image
|
|
||||||
instead. This prevents replicas from drifting while retaining the same Admin
|
|
||||||
catalog and preflight experience.
|
|
||||||
|
|
||||||
## Developer meta-package
|
|
||||||
|
|
||||||
`packages/govoplan-meta` builds the optional `govoplan` package. Its default
|
|
||||||
dependencies mirror the reviewed runtime roots; `govoplan[full]` adds all
|
|
||||||
currently packageable workspace modules. Regenerate it after changing release
|
|
||||||
requirements or package versions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/release/generate-developer-meta-package.py
|
|
||||||
python tools/release/generate-developer-meta-package.py --check
|
|
||||||
```
|
|
||||||
|
|
||||||
`push-release-tag.sh` performs this synchronization before release commits and
|
|
||||||
tags. The meta-package is for editable/developer setup and composition tests. It
|
|
||||||
does not enable modules, apply migrations, provision services, or establish
|
|
||||||
backup and recovery evidence.
|
|
||||||
|
|
||||||
If the tag-triggered developer meta-package job fails before publication, rerun
|
|
||||||
`publish-developer-meta-package.yml` with the existing protected version. The
|
|
||||||
manual path validates that tag against `main`, checks out its exact commit, and
|
|
||||||
publishes only when the registry does not already contain the same wheel hash.
|
|
||||||
|
|
||||||
Generic Packages are intentionally not used. Add that transport only when a
|
|
||||||
consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or
|
|
||||||
the OCI registry.
|
|
||||||
+108
-61
@@ -1,80 +1,127 @@
|
|||||||
# GovOPlaN Documentation Map
|
# GovOPlaN Documentation
|
||||||
|
|
||||||
This directory contains cross-repository product, architecture, release, and
|
This directory contains cross-repository product, architecture, delivery, and
|
||||||
operational documentation. The map below defines which document answers which
|
project documentation. Start here instead of browsing every file.
|
||||||
question. A document not listed as the current status source must not present
|
|
||||||
volatile repository, issue, release, or maturity counts as current facts.
|
## Read First
|
||||||
|
|
||||||
|
| Need | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Understand the platform in ten minutes | [Platform Core Ideas](strategy/PLATFORM_CORE_IDEAS.md) |
|
||||||
|
| See the intended product sequence | [Roadmap](strategy/ROADMAP.md) |
|
||||||
|
| Check the reconciled state and material gaps | [Strategy Status](strategy/STRATEGY_STATUS.md) |
|
||||||
|
| Find active work, priority, or ownership | [Gitea issue workflow](project/GITEA_ISSUES.md) and Gitea issues |
|
||||||
|
| Understand the selected end-to-end proofs | [Reference Journey Program](strategy/REFERENCE_JOURNEY_PROGRAM.md) |
|
||||||
|
|
||||||
|
The first three documents are the normal entry points. Detailed architecture,
|
||||||
|
runbooks, evidence, and historical assessments support them; they are not
|
||||||
|
parallel roadmaps.
|
||||||
|
|
||||||
## Strategy
|
## Strategy
|
||||||
|
|
||||||
| Question | Canonical source |
|
| Document | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| What are the stable ideas and boundaries of the platform? | [Platform Core Ideas](PLATFORM_CORE_IDEAS.md) |
|
| [Platform Core Ideas](strategy/PLATFORM_CORE_IDEAS.md) | Stable purpose, principles, planes, distinctions, and non-goals |
|
||||||
| What product outcomes should GovOPlaN pursue? | [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md) |
|
| [Roadmap](strategy/ROADMAP.md) | Concise product outcomes, horizons, and current sequence |
|
||||||
| Which institutional concepts and owners form the target architecture? | [Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md) |
|
| [Strategy Status](strategy/STRATEGY_STATUS.md) | Only prose source for current cross-product status |
|
||||||
| Which end-to-end proofs should guide implementation? | [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md) |
|
| [Reference Journey Program](strategy/REFERENCE_JOURNEY_PROGRAM.md) | Acceptance journeys and their gates |
|
||||||
| What is the reconciled state now? | [Strategy Status](STRATEGY_STATUS.md) |
|
| [Product Input Register](strategy/PRODUCT_INPUT_REGISTER.md) | Normalized ideas and user-story source material |
|
||||||
| Which collected ideas and user stories inform the product direction? | [Product Input Register](PRODUCT_INPUT_REGISTER.md) |
|
| [System Administrator Lifecycle](strategy/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md) | Installation and lifecycle outcome story |
|
||||||
|
| [Detailed Connected-Platform Vision](strategy/reference/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md) | Long-form stakeholder, configuration, and outcome catalogue |
|
||||||
|
|
||||||
The dated [Strategic Review](STRATEGIC_REVIEW_2026-08-05.md) explains why the
|
## Architecture
|
||||||
current reset and sequencing were chosen. It is an assessment record, not a
|
|
||||||
second live status page.
|
|
||||||
|
|
||||||
## Product Architecture
|
|
||||||
|
|
||||||
| Topic | Canonical source |
|
| Topic | Canonical source |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Product-facing experience and hiding technical module boundaries | [Product Experience and Module Boundaries](PRODUCT_EXPERIENCE_AND_MODULE_BOUNDARIES.md) |
|
| Institutional model and ownership | [Institutional Governance Target Architecture](architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md) |
|
||||||
| Configurable product areas and task-local tools | [Quick Access and Product Areas](QUICK_ACCESS_AND_PRODUCT_AREAS.md) |
|
| Product experience and technical boundaries | [Product Experience and Module Boundaries](architecture/PRODUCT_EXPERIENCE_AND_MODULE_BOUNDARIES.md) |
|
||||||
| Federation between autonomous installations | [Federated GovOPlaN Architecture](FEDERATED_GOVOPLAN_ARCHITECTURE.md) |
|
| Shared interface and layout rules | [Interface Pattern Language](architecture/INTERFACE_PATTERN_LANGUAGE.md) |
|
||||||
| Institutional digital twin and continuous assurance | [Institutional Digital Twin](INSTITUTIONAL_DIGITAL_TWIN.md) |
|
| Focused task views | [Views Architecture](architecture/VIEWS_ARCHITECTURE.md) |
|
||||||
| Assisted and non-digital channels | [Assisted and Non-Digital Channels](ASSISTED_AND_NON_DIGITAL_CHANNELS.md) |
|
| Product areas and task-local tools | [Quick Access and Product Areas](architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md) |
|
||||||
| Cross-module temporal, purpose, retention, and institutional-context adoption | `govoplan-core/docs/INFORMATION_GOVERNANCE_ADOPTION.md` |
|
| Platform self-description and configuration | [Platform Control Plane](architecture/PLATFORM_CONTROL_PLANE.md) |
|
||||||
| eAkte and digital-record ownership | `govoplan-records/docs/EAKTE_ARCHITECTURE.md` |
|
| Data sources, definitions, and graph execution | [Datasource and Definition Graph Architecture](architecture/DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md) |
|
||||||
| Data source, definition, and transformation graph | [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md) |
|
| Federation between autonomous installations | [Federated GovOPlaN Architecture](architecture/FEDERATED_GOVOPLAN_ARCHITECTURE.md) |
|
||||||
| Focused task views | [Views Architecture](VIEWS_ARCHITECTURE.md) |
|
| Institutional digital twin | [Institutional Digital Twin](architecture/INSTITUTIONAL_DIGITAL_TWIN.md) |
|
||||||
| Shared interface patterns | [Interface Pattern Language](INTERFACE_PATTERN_LANGUAGE.md) |
|
| Assisted and non-digital participation | [Assisted and Non-Digital Channels](architecture/ASSISTED_AND_NON_DIGITAL_CHANNELS.md) |
|
||||||
|
|
||||||
## Runtime And Delivery
|
Module-specific architecture remains in the owning repository. In particular,
|
||||||
|
information-governance adoption is in
|
||||||
|
`govoplan-core/docs/INFORMATION_GOVERNANCE_ADOPTION.md`, and the eAkte model is
|
||||||
|
in `govoplan-records/docs/EAKTE_ARCHITECTURE.md`.
|
||||||
|
|
||||||
- [Module Contracts and Installs](MODULE_CONTRACTS_AND_INSTALLS.md)
|
## Operations
|
||||||
- [Platform Control Plane](PLATFORM_CONTROL_PLANE.md)
|
|
||||||
- [Installation and Deployment Architecture](INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md)
|
|
||||||
- [Kubernetes VM Test Lab](KUBERNETES_TEST_LAB.md)
|
|
||||||
- [Deployment Profiles](DEPLOYMENT_PROFILES.md)
|
|
||||||
- [Scaling and Multi-Host Deployment](SCALING_AND_MULTI_HOST_DEPLOYMENT.md)
|
|
||||||
- [Recovery and Rollback Guarantees](RECOVERY_AND_ROLLBACK_GUARANTEES.md)
|
|
||||||
- [Recovery Ledger Adoption](RECOVERY_LEDGER_ADOPTION.md)
|
|
||||||
- [Package Registry Releases](PACKAGE_REGISTRY_RELEASES.md)
|
|
||||||
|
|
||||||
## Evidence And Snapshots
|
| Need | Source |
|
||||||
|
| --- | --- |
|
||||||
|
| Installation model and managed components | [Installation and Deployment Architecture](operations/INSTALLATION_AND_DEPLOYMENT_ARCHITECTURE.md) |
|
||||||
|
| Supported operating modes | [Deployment Profiles](operations/DEPLOYMENT_PROFILES.md) |
|
||||||
|
| Horizontal scaling and multi-host topology | [Scaling and Multi-Host Deployment](operations/SCALING_AND_MULTI_HOST_DEPLOYMENT.md) |
|
||||||
|
| Local Kubernetes evidence target | [Kubernetes VM Test Lab](operations/KUBERNETES_TEST_LAB.md) |
|
||||||
|
| Recovery guarantees and state machine | [Recovery and Rollback Guarantees](operations/RECOVERY_AND_ROLLBACK_GUARANTEES.md) |
|
||||||
|
| Recovery-ledger rollout | [Recovery Ledger Adoption](operations/RECOVERY_LEDGER_ADOPTION.md) |
|
||||||
|
| Backup evidence contract | [Backup and Restore Evidence](operations/BACKUP_AND_RESTORE_EVIDENCE.md) |
|
||||||
|
| Target handoff and independent evidence | [Production Target Handoff](operations/PRODUCTION_TARGET_HANDOFF.md) |
|
||||||
|
| Evidence collection and promotion | [Target Maturity Evidence Runbook](operations/TARGET_MATURITY_EVIDENCE_RUNBOOK.md) |
|
||||||
|
| Package publication and consumption | [Package Registry Releases](operations/PACKAGE_REGISTRY_RELEASES.md) |
|
||||||
|
| Release-console operation | [Release Console](operations/RELEASE_CONSOLE.md) |
|
||||||
|
| Module compatibility and install behavior | [Module Contracts and Installs](operations/MODULE_CONTRACTS_AND_INSTALLS.md) |
|
||||||
|
| Security-audit toolchain | [Security Audit](operations/SECURITY_AUDIT.md) |
|
||||||
|
|
||||||
These documents are intentionally dated or pinned. They may remain useful even
|
## Project Reference
|
||||||
after the product changes, but they do not override `STRATEGY_STATUS.md`.
|
|
||||||
|
|
||||||
- [Capability and Infrastructure Fit Assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md), pinned to the 2026-07-22 Campaign composition
|
- [Repository Index](project/REPOSITORY_INDEX.md) is the human-readable module
|
||||||
- [Strategic Review 2026-08-05](STRATEGIC_REVIEW_2026-08-05.md)
|
and repository directory; `../repositories.json` is authoritative for tools.
|
||||||
- [Backup and Restore Evidence](BACKUP_AND_RESTORE_EVIDENCE.md)
|
- [Repository Structure](project/REPOSITORY_STRUCTURE.md) defines ownership of
|
||||||
- [Production Target Handoff](PRODUCTION_TARGET_HANDOFF.md)
|
meta, module, deployment, and website content.
|
||||||
- [Target Maturity Evidence Runbook](TARGET_MATURITY_EVIDENCE_RUNBOOK.md)
|
- [Gitea Issues](project/GITEA_ISSUES.md) defines labels, templates, import, and
|
||||||
|
state-update conventions.
|
||||||
|
|
||||||
Machine-readable schemas and evidence files belong beside the document that
|
## Evidence And Archive
|
||||||
defines them. Generated inventories belong in `audit-reports/` and should not
|
|
||||||
be edited manually.
|
Pinned evidence is retained under `evidence/`; completed reviews and migration
|
||||||
|
inventories are under `archive/`. They explain or prove a dated state and must
|
||||||
|
not be read as current product status.
|
||||||
|
|
||||||
|
- [Generated Campaign capability and infrastructure fit, 2026-07-22](evidence/snapshots/CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md)
|
||||||
|
- [Supporting narrative for the 2026-07-22 assessment](evidence/snapshots/CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
||||||
|
- [Interface surface inventory, 2026-08-03](evidence/snapshots/INTERFACE_SURFACE_INVENTORY.md)
|
||||||
|
- [Strategic review, 2026-08-05](archive/2026-08/STRATEGIC_REVIEW_2026-08-05.md)
|
||||||
|
- [Meta repository scan, 2026-07-13](archive/2026-07/META_REPO_SCAN.md)
|
||||||
|
- [Meta repository migration audit](archive/2026-07/META_REPOSITORY_MIGRATION_AUDIT.md)
|
||||||
|
|
||||||
|
The JSON files at the root of this directory are machine-readable schemas,
|
||||||
|
evidence inputs, and project configuration. Their paths are intentionally
|
||||||
|
stable because tools and published schema identifiers consume them; they are
|
||||||
|
not additional reading-list entries.
|
||||||
|
|
||||||
|
Regenerate and verify the human fit report from its JSON input with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python tools/assessments/generate-capability-fit-report.py
|
||||||
|
./.venv/bin/python tools/assessments/generate-capability-fit-report.py --check
|
||||||
|
```
|
||||||
|
|
||||||
## Maintenance Rules
|
## Maintenance Rules
|
||||||
|
|
||||||
1. Gitea issues are the only live work-state source.
|
1. Gitea issues are the only live source for work state, priority, and owner.
|
||||||
2. `STRATEGY_STATUS.md` is the only prose reconciliation of current portfolio
|
2. `strategy/STRATEGY_STATUS.md` is the only prose reconciliation of current
|
||||||
state. Refresh it from manifests, inventories, tests, and Gitea; do not copy
|
portfolio state. Do not copy its volatile counts into durable documents.
|
||||||
its counts into durable architecture pages.
|
|
||||||
3. Durable documents state decisions, invariants, ownership, and acceptance
|
3. Durable documents state decisions, invariants, ownership, and acceptance
|
||||||
gates. They link to status and issues for implementation depth.
|
gates. They link to Gitea for implementation detail.
|
||||||
4. Dated assessments retain their original composition and conclusion. Add a
|
4. Dated evidence and archive documents retain their original composition and
|
||||||
snapshot notice rather than silently updating their claims.
|
conclusion. Add a snapshot notice instead of silently modernizing them.
|
||||||
5. Module-specific behavior and user/admin documentation remain in the owning
|
5. Module-specific behavior and user/admin documentation stay in the owning
|
||||||
repository. Meta documentation defines cross-module outcomes and contracts.
|
repository. Meta documentation covers cross-module outcomes and contracts.
|
||||||
6. A new strategy document must replace, narrow, or link an existing source;
|
6. Do not add another top-level Markdown file. Place new content in the
|
||||||
it must not introduce a parallel roadmap.
|
appropriate directory and add it to this map only when it has a distinct
|
||||||
7. The Product Input Register preserves external idea and story notes, but only
|
canonical purpose.
|
||||||
Gitea issues carry live priority, ownership, and implementation state.
|
7. A new strategy document must replace, narrow, or become a reference for an
|
||||||
|
existing source; it must not introduce a parallel roadmap.
|
||||||
|
8. The Product Input Register preserves source ideas. Only a named journey,
|
||||||
|
package, or Gitea issue turns an idea into implementation work.
|
||||||
|
|
||||||
|
After moving or adding documentation, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./.venv/bin/python -m unittest tests.test_documentation_structure
|
||||||
|
```
|
||||||
|
|||||||
+10
@@ -72,6 +72,16 @@ An assisted session is a resumable work item, not a privileged bypass. It:
|
|||||||
8. creates follow-up tasks when original documents, signatures, translation,
|
8. creates follow-up tasks when original documents, signatures, translation,
|
||||||
or verification remain outstanding.
|
or verification remain outstanding.
|
||||||
|
|
||||||
|
The first executable slice is implemented in Forms Runtime for authenticated
|
||||||
|
assisted sessions. Administrators enable an exact published Form revision;
|
||||||
|
operators then record channel, party and representation references, authority,
|
||||||
|
purpose, notice, responsible function, language, accessibility needs, and
|
||||||
|
field-level source/confidence provenance. Read-back outcomes are append-only and
|
||||||
|
payload-bound. A draft correction changes the Form revision and invalidates the
|
||||||
|
prior confirmation for submission. The resident-parking-permit fixture proves
|
||||||
|
resume and submission enforcement; browser accessibility and target archive
|
||||||
|
evidence remain acceptance work.
|
||||||
|
|
||||||
The helper's normal account and represented function remain in the audit
|
The helper's normal account and represented function remain in the audit
|
||||||
chain. Assistance never grants access to unrelated records about the person.
|
chain. Assistance never grants access to unrelated records about the person.
|
||||||
|
|
||||||
+10
-10
@@ -12,21 +12,21 @@ The source concepts describe GovOPlaN as an operational governance platform for
|
|||||||
public institutions. This document is the canonical repository version of that
|
public institutions. This document is the canonical repository version of that
|
||||||
durable architectural direction. Its implementation table records the accepted
|
durable architectural direction. Its implementation table records the accepted
|
||||||
2026-08-01 baseline; it is not a rolling status report. Current reconciliation
|
2026-08-01 baseline; it is not a rolling status report. Current reconciliation
|
||||||
lives in [Strategy Status](STRATEGY_STATUS.md), and Gitea issues remain the
|
lives in [Strategy Status](../strategy/STRATEGY_STATUS.md), and Gitea issues remain the
|
||||||
source of truth for delivery state.
|
source of truth for delivery state.
|
||||||
|
|
||||||
Read this together with:
|
Read this together with:
|
||||||
|
|
||||||
- [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
- [Connected Governance Platform Roadmap](../strategy/reference/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
- [Platform Core Ideas](PLATFORM_CORE_IDEAS.md)
|
- [Platform Core Ideas](../strategy/PLATFORM_CORE_IDEAS.md)
|
||||||
- [Strategy Status](STRATEGY_STATUS.md)
|
- [Strategy Status](../strategy/STRATEGY_STATUS.md)
|
||||||
- [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md)
|
- [Reference Journey Program](../strategy/REFERENCE_JOURNEY_PROGRAM.md)
|
||||||
- [Module Contracts and Install Boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- [Module Contracts and Install Boundaries](../operations/MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
- [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md)
|
- [Datasource and Definition Graph Architecture](DATASOURCE_AND_DEFINITION_GRAPH_ARCHITECTURE.md)
|
||||||
- [Capability and Infrastructure Fit](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
- [Generated Capability and Infrastructure Fit](../evidence/snapshots/CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md)
|
||||||
- [Core Module Architecture](../../govoplan-core/docs/MODULE_ARCHITECTURE.md)
|
- [Core Module Architecture](../../../govoplan-core/docs/MODULE_ARCHITECTURE.md)
|
||||||
- [Core External References and Integration Maturity](../../govoplan-core/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md)
|
- [Core External References and Integration Maturity](../../../govoplan-core/docs/EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md)
|
||||||
- [Core Action, Effect, and Automation Layer](../../govoplan-core/docs/ACTION_EFFECT_AUTOMATION_LAYER.md)
|
- [Core Action, Effect, and Automation Layer](../../../govoplan-core/docs/ACTION_EFFECT_AUTOMATION_LAYER.md)
|
||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
+162
-14
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
This document is the cross-repository pattern language for GovOPlaN user
|
This document is the cross-repository pattern language for GovOPlaN user
|
||||||
interfaces. It turns the existing ethical doctrine, binding UI/UX decisions,
|
interfaces. It turns the existing ethical doctrine, binding UI/UX decisions,
|
||||||
layout principles, and module boundary into a common composition and review
|
layout rules, and module boundary into a common composition and review grammar.
|
||||||
grammar. It does not replace those sources.
|
This document also owns the former standalone frontend-layout principles.
|
||||||
|
|
||||||
The companion [interface surface inventory](INTERFACE_SURFACE_INVENTORY.md)
|
The dated [interface surface inventory](../evidence/snapshots/INTERFACE_SURFACE_INVENTORY.md)
|
||||||
records which surfaces the current code contributes and where each surface
|
records the 2026-08-03 rollout snapshot. Current implementation state belongs
|
||||||
enters the rollout.
|
in Gitea and generated inventory evidence, not in this durable pattern
|
||||||
|
language.
|
||||||
|
|
||||||
## Source Of Truth And Precedence
|
## Source Of Truth And Precedence
|
||||||
|
|
||||||
@@ -19,14 +20,11 @@ Use the narrowest owning document when changing a rule:
|
|||||||
2. `govoplan-core/docs/UI_UX_DECISION_LEDGER.md` owns accepted product decisions
|
2. `govoplan-core/docs/UI_UX_DECISION_LEDGER.md` owns accepted product decisions
|
||||||
such as progressive disclosure, adaptive forms, blocker language, guided
|
such as progressive disclosure, adaptive forms, blocker language, guided
|
||||||
operations, and the platform theme contract.
|
operations, and the platform theme contract.
|
||||||
3. `docs/FRONTEND_LAYOUT_PRINCIPLES.md` owns the high-level choice between a
|
3. `govoplan-core/docs/MODULE_ARCHITECTURE.md` owns the shell, route, navigation,
|
||||||
full-space structured-data workspace and a heading/menu/card workflow or
|
|
||||||
configuration surface.
|
|
||||||
4. `govoplan-core/docs/MODULE_ARCHITECTURE.md` owns the shell, route, navigation,
|
|
||||||
UI-capability, and shared-component boundaries.
|
UI-capability, and shared-component boundaries.
|
||||||
5. This document owns the common pattern names, placement grammar, wording and
|
4. This document owns the high-level layout choice, common pattern names,
|
||||||
state conventions, focused-view composition, and definition of done across
|
placement grammar, wording and state conventions, focused-view composition,
|
||||||
those sources.
|
and definition of done across those sources.
|
||||||
|
|
||||||
If two rules appear to conflict, do not create a third local convention. Record
|
If two rules appear to conflict, do not create a third local convention. Record
|
||||||
the conflict in the owning decision ledger, resolve it there, and update the
|
the conflict in the owning decision ledger, resolve it there, and update the
|
||||||
@@ -53,6 +51,148 @@ rules:
|
|||||||
- Preserve a stable way back to the containing object and the broader system.
|
- Preserve a stable way back to the containing object and the broader system.
|
||||||
- Do not let navigation, selection, or a view switch imply consent.
|
- Do not let navigation, selection, or a view switch imply consent.
|
||||||
|
|
||||||
|
## Shared Component And Layout Architecture
|
||||||
|
|
||||||
|
Core owns the reusable WebUI vocabulary; modules own domain composition and
|
||||||
|
behavior. Centralization follows four layers:
|
||||||
|
|
||||||
|
| Layer | Owner | Examples | Rule |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Foundation | Core | theme tokens, spacing, typography, focus and responsive breakpoints | Modules consume the contract and do not redefine it. |
|
||||||
|
| Primitives | Core | buttons, fields, dialogs, alerts, cards, tables, loading, empty and blocked states | A matching primitive is reused rather than copied locally. |
|
||||||
|
| Structural layouts | Core | page frame and header, action region, workspace panes, toolbars, grids, form sections and dialog anatomy | Layout owns geometry, scroll, responsive collapse and accessibility, but no domain decisions. |
|
||||||
|
| Domain compositions | Owning module | a campaign review, mailbox, records explorer or operations dashboard | Modules select shared pieces, bind data and permissions, and retain domain wording and consequences. |
|
||||||
|
|
||||||
|
A component belongs in Core when it is used or expected in more than one
|
||||||
|
module and central ownership materially protects accessibility, responsive
|
||||||
|
behavior, localization, contextual help, theming, or interaction consistency.
|
||||||
|
A component stays module-owned when its API would otherwise encode a domain
|
||||||
|
entity, permission, workflow state, endpoint, or policy decision. Reuse does
|
||||||
|
not justify moving domain semantics into Core.
|
||||||
|
|
||||||
|
`PageLayout` is the standard frame for headed workflow, dashboard,
|
||||||
|
configuration, monitoring and explanatory pages. It owns the content inset,
|
||||||
|
sticky responsive header, title and rich-description geometry, route-action
|
||||||
|
placement, transient and custom notices, loading boundary and page help
|
||||||
|
identity. Its modes make scroll ownership explicit: `standalone` owns a page
|
||||||
|
viewport, `workspace` defers scrolling to a full-canvas content pane while
|
||||||
|
retaining the standard inset, and `embedded` owns neither scroll nor inset.
|
||||||
|
|
||||||
|
`WorkspaceLayout` is the standard full-canvas shell. Its `navigation` variant
|
||||||
|
owns module/resource subnavigation plus content; its `split` variant owns
|
||||||
|
collection/detail panes. It centralizes pane sizing, internal scroll,
|
||||||
|
responsive collapse/stacking, accessible pane labels and workspace help
|
||||||
|
identity. `WorkspaceFrame` is the outer full-height module frame and owns
|
||||||
|
container or application-viewport height, overflow, surface, landmark, help,
|
||||||
|
and accessible-name behavior. `PageHeader` remains available when an
|
||||||
|
exceptional canvas needs only the shared heading. Specialized layouts such as
|
||||||
|
`AdminPageLayout` compose these lower-level Core contracts; they do not repeat
|
||||||
|
markup or responsive CSS.
|
||||||
|
|
||||||
|
`PageActionBar` is the semantic action contract for headed pages;
|
||||||
|
`WorkspaceActionBar` applies the identical ordering and lifecycle rules to a
|
||||||
|
full canvas and its collection, detail, and editor panes. Reload is always the
|
||||||
|
leading action on a refreshable projection. Help and ordinary task actions
|
||||||
|
follow contextual controls; Create is the far-right collection action;
|
||||||
|
destructive actions occupy a named separated group; an editor ends with
|
||||||
|
Discard and Save, with Save at the far right. Editor state is explicit:
|
||||||
|
`clean`, `dirty`, `invalid`, `saving`, `save-failed`, or `conflict`. Lower-level
|
||||||
|
`ActionToolbar` remains appropriate for a section-local view switch or compact
|
||||||
|
control group, but it must not recreate page or pane action placement.
|
||||||
|
|
||||||
|
Composite workspaces whose selected contribution supplies its own semantic
|
||||||
|
heading may use `PageLayout` with its visible header delegated. This preserves
|
||||||
|
the central inset, loading boundary, help identity, and content frame without
|
||||||
|
adding a duplicate heading. It is not permission to recreate the page header
|
||||||
|
locally on ordinary headed pages.
|
||||||
|
|
||||||
|
Module CSS may arrange domain content inside a shared layout. It must not
|
||||||
|
override Core layout internals or copy the outer page, dialog, toolbar, form or
|
||||||
|
state skeleton under a module-prefixed name. If an archetype cannot be
|
||||||
|
expressed by the central API, extend the central contract or record a bounded
|
||||||
|
exception before introducing local structure.
|
||||||
|
|
||||||
|
Migration is incremental and enforceable:
|
||||||
|
|
||||||
|
1. inventory copied structures and register existing debt;
|
||||||
|
2. introduce the smallest domain-neutral Core contract with accessibility,
|
||||||
|
help, localization, theme and narrow-layout tests;
|
||||||
|
3. migrate representative Core and optional-module consumers;
|
||||||
|
4. reject new copies while removing registered debt in bounded module batches;
|
||||||
|
5. promote the next repeated structure only after its variants and extension
|
||||||
|
points are understood.
|
||||||
|
|
||||||
|
The current page-frame and workspace migration has no legacy exceptions. New
|
||||||
|
raw frames fail the focused layout contract instead of entering a new baseline.
|
||||||
|
|
||||||
|
The current structural vocabulary is:
|
||||||
|
|
||||||
|
- `ActionToolbar`, `ToolbarGroup`, and `ToolbarSpacer` own action alignment,
|
||||||
|
distribution, density, grouping, panel/section surfaces, accessible toolbar
|
||||||
|
naming, help identity, and responsive wrapping. Modules may add
|
||||||
|
domain-specific presentation; they do not recreate the flex/wrap skeleton.
|
||||||
|
- `PageActionBar` and `WorkspaceActionBar` own semantic ordering, Reload,
|
||||||
|
editor persistence state, destructive separation, and page/pane scope. A
|
||||||
|
module supplies action behavior, authority, blocker reasons, and wording;
|
||||||
|
it does not assemble another panel-header action convention.
|
||||||
|
- `WorkspaceFrame` and `WorkspaceLayout` own application-viewport framing,
|
||||||
|
surfaces, overflow, list/detail and navigation/content pane geometry,
|
||||||
|
accessible region identity, and responsive pane behavior. Modules own only
|
||||||
|
the domain regions placed inside those contracts.
|
||||||
|
- `FilterBar` owns submitted or live filter/search arrangement, wrapping,
|
||||||
|
width and surface. `SelectionList`, `SelectionListItem`, and
|
||||||
|
`SelectionListItemContent` own selectable resource navigation and its
|
||||||
|
title/description/leading-icon geometry. `CountBadge` owns compact numeric
|
||||||
|
emphasis. Modules retain filter behavior, selection state, and count meaning.
|
||||||
|
- `StatePanel` owns whole-surface, compact, inline and fill state presentation
|
||||||
|
for empty, unavailable, blocked, warning and recoverable-error compositions.
|
||||||
|
Modules provide the cause, consequence, permitted action and authority.
|
||||||
|
- `ContentGrid`, `FormGrid`, `FormLayout`, and `GridItem` own equal-column
|
||||||
|
geometry, standard gaps, alignment, spans, native form semantics, and named
|
||||||
|
responsive collapse points. A module-local grid remains appropriate only
|
||||||
|
when unequal tracks or domain visualization semantics are material.
|
||||||
|
- `ContentSection` owns repeated bordered or subtle content-section surfaces,
|
||||||
|
density, stacked flow and surrounding rhythm without prescribing a domain
|
||||||
|
heading or body schema.
|
||||||
|
- `FormSection` owns form-section heading, description, actions, content flow,
|
||||||
|
separation, and panel presentation. It does not own field values,
|
||||||
|
validation, permissions, or domain wording.
|
||||||
|
- `MetricGrid` owns the responsive grouping around `MetricCard`: fixed one-to-five
|
||||||
|
columns or auto-fit, minimum card width, density, surrounding rhythm, and a
|
||||||
|
named collapse point. `MetricCard.drilldown` provides an explicit link or
|
||||||
|
in-page action when an authorized underlying detail helps the user act; it
|
||||||
|
names that destination and preserves the current scope and filters. The card
|
||||||
|
itself is never the hidden click target. Derived, privacy-suppressed,
|
||||||
|
non-enumerable, and purely informational aggregates remain inert. Modules
|
||||||
|
provide the metric, tone, destination, and consequence; they do not recreate
|
||||||
|
the group grid or reach across module CSS to size it.
|
||||||
|
- `DescriptionList` and `DescriptionItem` own semantic property presentation.
|
||||||
|
The stacked variant supports compact multi-column facts; the inline variant
|
||||||
|
supports one-column term/value rows with a standard term width. Both own
|
||||||
|
density, wrapping, and responsive collapse while modules retain the terms,
|
||||||
|
values, provenance, and actions.
|
||||||
|
- `Dialog` owns size and administration variants, body padding, description,
|
||||||
|
notices, and fixed footer placement. `DialogActions`, `DialogForm`, and
|
||||||
|
`DialogSection` own the footer action flow, native form flow, and body
|
||||||
|
grouping used inside it. Modules compose fields and consequences rather than
|
||||||
|
recreating dialog anatomy.
|
||||||
|
- `DefinitionPalette`, `DefinitionPaletteGroup`, `DefinitionPaletteItem`, and
|
||||||
|
`DefinitionNodeIcon`, together with the shared definition-canvas classes,
|
||||||
|
own reusable graph-editor palette, canvas-control, node-icon, port and empty
|
||||||
|
overlay visuals. Workflow/Dataflow retain node types, shapes, edges,
|
||||||
|
validation and execution semantics. `FloatingStatus` owns the common
|
||||||
|
non-shifting activity overlay.
|
||||||
|
|
||||||
|
Raw toolbar tags, the former generic grid and property-list classes, retired
|
||||||
|
module-local shells/states/metrics/badges, raw dialog-form wrappers, and
|
||||||
|
module-local definitions of these contracts are rejected by the focused
|
||||||
|
workspace checks. Dialog widths matching the Core size scale must use `Dialog
|
||||||
|
size`; other local widths require a reviewed exception and may only decrease.
|
||||||
|
Remaining local layout is acceptable only for unequal-track domain editors,
|
||||||
|
visualizations, trees, timelines, data tables, or domain-specific multi-pane
|
||||||
|
interaction. Generic resemblance alone is not a reason to create one oversized
|
||||||
|
page template, while exact repeated structural anatomy must be promoted.
|
||||||
|
|
||||||
## Surface Archetypes
|
## Surface Archetypes
|
||||||
|
|
||||||
Choose an archetype from the task, then specialize it for the domain. A route
|
Choose an archetype from the task, then specialize it for the domain. A route
|
||||||
@@ -95,15 +235,23 @@ one.
|
|||||||
|
|
||||||
- Structured directories use the full available content space and persistent
|
- Structured directories use the full available content space and persistent
|
||||||
panes. They do not add a decorative heading row that reduces working height.
|
panes. They do not add a decorative heading row that reduces working height.
|
||||||
|
Give navigation and list panes bounded widths and let the main content or
|
||||||
|
detail pane consume the remaining space.
|
||||||
|
- In a list-detail workspace, related lists may be stacked in the left pane
|
||||||
|
while the main pane owns view, create, and edit. Keep one create action in
|
||||||
|
the relevant list heading instead of adding a second launcher or permanent
|
||||||
|
creation panel.
|
||||||
- Workflow, configuration, dashboard, and explanatory pages may use a heading.
|
- Workflow, configuration, dashboard, and explanatory pages may use a heading.
|
||||||
The heading names the task or scoped object and contains only route-level
|
The heading names the task or scoped object and contains only route-level
|
||||||
actions.
|
actions. Use the Core `PageLayout` contract for the frame and `PageHeader`
|
||||||
|
only when a full-canvas archetype owns its own scrolling.
|
||||||
- Put a collection-wide create action in the heading of the collection it
|
- Put a collection-wide create action in the heading of the collection it
|
||||||
affects. Use a short, specific label such as `Add` when the heading already
|
affects. Use a short, specific label such as `Add` when the heading already
|
||||||
names the object. Do not duplicate that action in a permanently visible side
|
names the object. Do not duplicate that action in a permanently visible side
|
||||||
panel. A side panel used as the creation surface appears for creation and is
|
panel. A side panel used as the creation surface appears for creation and is
|
||||||
otherwise absent or returns to its documented non-creation purpose.
|
otherwise absent or returns to its documented non-creation purpose.
|
||||||
- Put filters beside the list or pane they affect. Put bulk actions immediately
|
- Put filters beside the list or pane they affect. Put collection, detail, and
|
||||||
|
editor-pane actions in `WorkspaceActionBar` with the matching scope. Put bulk actions immediately
|
||||||
above or beside the current selection. Put object actions with the object
|
above or beside the current selection. Put object actions with the object
|
||||||
detail, not in the global title bar.
|
detail, not in the global title bar.
|
||||||
- Full-page create and edit surfaces put their persistent action cluster in the
|
- Full-page create and edit surfaces put their persistent action cluster in the
|
||||||
+44
-6
@@ -72,6 +72,37 @@ Each WebUI module should be able to announce:
|
|||||||
The contract references surfaces. It does not permit Core or a product package
|
The contract references surfaces. It does not permit Core or a product package
|
||||||
to import their implementation.
|
to import their implementation.
|
||||||
|
|
||||||
|
The versioned `product_surfaces` slice is implemented in Core. It
|
||||||
|
binds a stable product identity and entry path to one or more owner routes,
|
||||||
|
View surfaces, presentations, capabilities, search sources, help contexts and
|
||||||
|
documentation topics. It also carries standard unavailable/degraded
|
||||||
|
explanations and migration aliases. Mail and Postbox contribute the first
|
||||||
|
shared identity, `communication.messages`: `/messages` and the migration alias
|
||||||
|
`/inbox` select the first currently authorized, View-visible owner while the
|
||||||
|
underlying `/mail` and `/postbox` deep links, custody and permissions remain
|
||||||
|
unchanged. Tasks, Calendar and Files contribute the corresponding single-owner
|
||||||
|
identities:
|
||||||
|
|
||||||
|
| Product identity | Stable destination | Compatible owner route |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Work | `/work` | `/tasks` |
|
||||||
|
| Calendar | `/agenda` | `/calendar` |
|
||||||
|
| Messages | `/messages` (`/inbox` alias) | `/mail`, `/postbox` |
|
||||||
|
| Files | `/documents` | `/files` |
|
||||||
|
|
||||||
|
Core replaces those owner entries in the ordinary rail with the stable product
|
||||||
|
destinations. A collapsed **All available tools** catalogue retains every
|
||||||
|
authorized technical owner route independently of View focus; unauthorized
|
||||||
|
entries are never disclosed. The original deep links remain valid, and all
|
||||||
|
contributing owner paths keep the corresponding product entry active. Alias
|
||||||
|
resolution emits a bounded client telemetry event before the redirect.
|
||||||
|
|
||||||
|
Core's `ProductAvailabilityState` is the shared presentation primitive for
|
||||||
|
authorization, Policy, configuration, disabled, missing-capability, offline and
|
||||||
|
provider-degraded states. Product language is primary; exact module,
|
||||||
|
capability, provider and correlation provenance is available only in an
|
||||||
|
expandable technical section.
|
||||||
|
|
||||||
## Navigation Model
|
## Navigation Model
|
||||||
|
|
||||||
The default shell should prioritize:
|
The default shell should prioritize:
|
||||||
@@ -88,10 +119,11 @@ People and Responsibility. They are configurable system/tenant defaults and
|
|||||||
Views projections, not hard-coded repository groups. Empty areas disappear;
|
Views projections, not hard-coded repository groups. Empty areas disappear;
|
||||||
single-destination areas may link directly; familiar tools may remain pinned.
|
single-destination areas may link directly; familiar tools may remain pinned.
|
||||||
|
|
||||||
The complete permission-derived module rail remains available as **All
|
The complete permission-derived module rail is available as the collapsed
|
||||||
available tools**. Its ability to scroll is useful and is not itself the
|
**All available tools** escape. It is deliberately independent of the active
|
||||||
product defect. The defect is requiring people to infer a task or outcome from
|
View while still enforcing authorization. Its ability to scroll is useful and
|
||||||
repository topology.
|
is not itself the product defect. The defect is requiring people to infer a
|
||||||
|
task or outcome from repository topology.
|
||||||
|
|
||||||
Task-local Work, Calendar, Messages and Files tools may be contributed to the
|
Task-local Work, Calendar, Messages and Files tools may be contributed to the
|
||||||
optional `govoplan-quick-access` rail. Messages composes Mail, Postbox and
|
optional `govoplan-quick-access` rail. Messages composes Mail, Postbox and
|
||||||
@@ -108,6 +140,12 @@ sections, commands, widgets, and fields. A view must not grant a permission or
|
|||||||
change data semantics. Policy can force, allow, or prohibit a surface at system,
|
change data semantics. Policy can force, allow, or prohibit a surface at system,
|
||||||
tenant, group, or user scope.
|
tenant, group, or user scope.
|
||||||
|
|
||||||
|
Core browser conformance exercises the German Anwohnerparkausweis reference
|
||||||
|
context with Work, Calendar, Messages and Files entries, verifies that package
|
||||||
|
owner labels are absent from the primary rail, expands the technical catalogue,
|
||||||
|
and runs WCAG 2 A/AA checks over the result. Unit permutations cover two-owner,
|
||||||
|
one-owner, unauthorized-owner and focused-View compositions.
|
||||||
|
|
||||||
## Error And Provenance Language
|
## Error And Provenance Language
|
||||||
|
|
||||||
Normal errors answer:
|
Normal errors answer:
|
||||||
@@ -132,9 +170,9 @@ first rail slice.
|
|||||||
|
|
||||||
### Slice 1: inventory and aliases
|
### Slice 1: inventory and aliases
|
||||||
|
|
||||||
- classify every route, navigation item, widget, setting, search object, and
|
- continue classifying every route, navigation item, widget, setting, search object, and
|
||||||
help context by product area and object type;
|
help context by product area and object type;
|
||||||
- add product aliases without removing existing deep links;
|
- extend the implemented product-surface aliases without removing existing deep links;
|
||||||
- flag raw module IDs in ordinary-user labels and errors.
|
- flag raw module IDs in ordinary-user labels and errors.
|
||||||
|
|
||||||
### Slice 2: work-first shell
|
### Slice 2: work-first shell
|
||||||
+63
-15
@@ -39,14 +39,25 @@ The first production-shaped slice is implemented:
|
|||||||
order and optional labels. Scoped Views therefore configure product
|
order and optional labels. Scoped Views therefore configure product
|
||||||
presentation for system, tenant, group, user and Workflow contexts;
|
presentation for system, tenant, group, user and Workflow contexts;
|
||||||
- the expanded left rail groups classified destinations while retaining
|
- the expanded left rail groups classified destinations while retaining
|
||||||
Dashboard and every authorized unclassified destination under More tools.
|
Dashboard and every authorized unclassified destination under More tools;
|
||||||
|
- Core promotes Work (`/work`), Calendar (`/agenda`), Messages (`/messages`)
|
||||||
|
and Files (`/documents`) into stable primary destinations and collapses the
|
||||||
|
compatible owner routes under **All available tools**;
|
||||||
|
- **All available tools** is permission-derived but independent of the active
|
||||||
|
View, providing a deliberate escape without granting access or discarding
|
||||||
|
the original `/tasks`, `/calendar`, `/mail`, `/postbox` and `/files` links.
|
||||||
|
|
||||||
The remaining rollout is classification rather than a missing boundary: other
|
The baseline classification and the four initial stable destinations are now
|
||||||
user-facing modules must announce their product areas and future bounded tools,
|
manifest-declared. The area classification covers every ordinary user-facing
|
||||||
reference journeys need browser accessibility evidence, and richer active-object
|
module and is enforced by the workspace manifest check. A separately
|
||||||
context should be added only through a separately versioned launch-context
|
versioned launch-context contract carries bounded active-object, acting,
|
||||||
contract. Until classification is complete, authorized unclassified routes
|
temporal, View and return references into full-page Quick Access fallbacks;
|
||||||
remain visible rather than disappearing.
|
Cases publishes the first active-object reference. The remaining rollout is to
|
||||||
|
add useful bounded tools and active-object publishers only where a maintained
|
||||||
|
journey benefits. The pinned German Anwohnerparkausweis browser composition
|
||||||
|
verifies stable product labels, technical escape, keyboard access and WCAG
|
||||||
|
conformance. Authorized global and technical routes remain visible through
|
||||||
|
their dedicated shell entry or **All available tools**.
|
||||||
|
|
||||||
## Quick Access Boundary
|
## Quick Access Boundary
|
||||||
|
|
||||||
@@ -75,10 +86,11 @@ matters.
|
|||||||
|
|
||||||
A Quick Access contribution declares:
|
A Quick Access contribution declares:
|
||||||
|
|
||||||
- a stable id, category and human label;
|
- contract version 1, a stable id, category and human label;
|
||||||
- icon, order and optional badge/summary provider;
|
- icon, order and optional badge/summary provider;
|
||||||
- required permissions and optional dependencies;
|
- required permissions and optional dependencies;
|
||||||
- accepted context references and produced return references;
|
- global or active-object availability, accepted context-reference kinds and
|
||||||
|
produced result-reference kinds;
|
||||||
- an owner-rendered bounded WebUI surface and full-page fallback route;
|
- an owner-rendered bounded WebUI surface and full-page fallback route;
|
||||||
- View surface, help context and availability explanation;
|
- View surface, help context and availability explanation;
|
||||||
- whether the contribution supports preview, create, select or resume.
|
- whether the contribution supports preview, create, select or resume.
|
||||||
@@ -89,6 +101,22 @@ resources and a safe return location. The owner reauthorizes every read and
|
|||||||
effect. Credentials, protected content and permission decisions are never
|
effect. Credentials, protected content and permission decisions are never
|
||||||
embedded in launch context.
|
embedded in launch context.
|
||||||
|
|
||||||
|
Launch-context version 2 identifies reference contract version 1 and carries
|
||||||
|
the exact resolved View revision plus optional recommended and focused tool
|
||||||
|
ids. Recommendations affect order and emphasis only. Focus narrows the rail
|
||||||
|
only when at least one focused contribution survives module enablement,
|
||||||
|
configuration, context compatibility and authorization; otherwise the normal
|
||||||
|
effective rail remains available. Workflow gets the same behavior by resolving
|
||||||
|
the exact View revision instead of acquiring separate presentation authority.
|
||||||
|
|
||||||
|
An owner-rendered tool explicitly returns result contract version 1 as either
|
||||||
|
`completed` with an action and typed owner reference, or `cancelled` with a
|
||||||
|
reason. The shell correlates the result with the source and tool, rejects
|
||||||
|
cross-tenant or undeclared reference kinds, and does not interpret closing the
|
||||||
|
drawer as completion. Owner modules validate, persist, recover and audit their
|
||||||
|
own effects. The overlay leaves the host route mounted, so unsaved host-page
|
||||||
|
state is preserved; the full-page route remains the bounded-work fallback.
|
||||||
|
|
||||||
## Effective Configuration
|
## Effective Configuration
|
||||||
|
|
||||||
The effective rail is resolved from:
|
The effective rail is resolved from:
|
||||||
@@ -122,9 +150,9 @@ workspace layouts do not resize unexpectedly; a later explicit pinned mode may
|
|||||||
reserve layout width on sufficiently wide screens.
|
reserve layout width on sufficiently wide screens.
|
||||||
|
|
||||||
The drawer preserves host-page state, has a deterministic focus return, closes
|
The drawer preserves host-page state, has a deterministic focus return, closes
|
||||||
with Escape, supports keyboard traversal, and provides an explicit full-page
|
with Escape, supports keyboard traversal, and provides explicit completion,
|
||||||
open action. Mobile and narrow layouts use the same category/configuration
|
cancellation and full-page actions. Mobile and narrow layouts use the same
|
||||||
semantics in a bottom sheet or compact menu.
|
category/configuration semantics in a bottom sheet or compact menu.
|
||||||
|
|
||||||
## Product Areas
|
## Product Areas
|
||||||
|
|
||||||
@@ -146,9 +174,29 @@ areas, and users may personalize them within Policy ceilings. An empty area is
|
|||||||
omitted. An area with one destination may open it directly. A multi-destination
|
omitted. An area with one destination may open it directly. A multi-destination
|
||||||
area provides a useful work/recent/action surface rather than another menu.
|
area provides a useful work/recent/action surface rather than another menu.
|
||||||
|
|
||||||
Familiar product nouns such as Calendar, Mail or Files may remain directly
|
Familiar product nouns such as Calendar or Files remain direct product
|
||||||
pinned. The objective is not to hide every module name; it is to prevent
|
destinations. The objective is not to hide every implementation name from
|
||||||
repository topology from determining a person's workflow.
|
administrators; it is to prevent repository topology from determining a
|
||||||
|
person's workflow.
|
||||||
|
|
||||||
|
The initial module classification is deliberately outcome-oriented:
|
||||||
|
|
||||||
|
| Product area | Contributing user-facing modules |
|
||||||
|
| --- | --- |
|
||||||
|
| Work | Approvals, Projects, Tasks, Workflow |
|
||||||
|
| Services and Cases | Cases, Forms, Forms Runtime, Portal |
|
||||||
|
| Records and Documents | Files, Records, Templates |
|
||||||
|
| Communication | Campaigns, Distribution Lists, Mail, Notifications, Postbox |
|
||||||
|
| Meetings and Decisions | Calendar, Committee, Scheduling, Voting |
|
||||||
|
| Data and Assurance | Dataflow, Datasources, Reporting, Risk Compliance |
|
||||||
|
| People and Responsibility | Address Book, IDM, Organizations |
|
||||||
|
|
||||||
|
Dashboard, Search, Documentation and Quick Access remain global shell
|
||||||
|
affordances. Access, Administration, Audit, Encryption, Identity Trust,
|
||||||
|
Operations, Policy, Tenancy and Views remain administrative or platform
|
||||||
|
surfaces available through their dedicated entry point or **All available
|
||||||
|
tools**. The manifest-shape check enforces both this explicit exception set and
|
||||||
|
the shared label, icon, description and ordering of every canonical area.
|
||||||
|
|
||||||
## Full Access And Provenance
|
## Full Access And Provenance
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@ Still intentionally separate:
|
|||||||
Quick Access ordering and availability remain owned by
|
Quick Access ordering and availability remain owned by
|
||||||
`govoplan-quick-access`; Views only narrow its declared surfaces for the active
|
`govoplan-quick-access`; Views only narrow its declared surfaces for the active
|
||||||
task. Neither contract permits arbitrary layout or styling. See
|
task. Neither contract permits arbitrary layout or styling. See
|
||||||
`docs/QUICK_ACCESS_AND_PRODUCT_AREAS.md` in the meta repository.
|
`docs/architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md` in the meta repository.
|
||||||
|
|
||||||
## Gitea Work Packages
|
## Gitea Work Packages
|
||||||
|
|
||||||
+5
-1
@@ -1,5 +1,9 @@
|
|||||||
# Meta Repository Migration Audit
|
# Meta Repository Migration Audit
|
||||||
|
|
||||||
|
> **Archived migration record:** The ownership migration described here is
|
||||||
|
> complete. Current boundaries are defined by Repository Structure, module
|
||||||
|
> manifests, and the owning repositories.
|
||||||
|
|
||||||
This audit records which existing GovOPlaN files should move toward the
|
This audit records which existing GovOPlaN files should move toward the
|
||||||
`govoplan` meta repository and which should remain with their current runtime
|
`govoplan` meta repository and which should remain with their current runtime
|
||||||
owner.
|
owner.
|
||||||
@@ -148,7 +152,7 @@ It should not own:
|
|||||||
Known references reviewed after the server-side rename:
|
Known references reviewed after the server-side rename:
|
||||||
|
|
||||||
- `govoplan/repositories.json`
|
- `govoplan/repositories.json`
|
||||||
- `govoplan/docs/REPOSITORY_STRUCTURE.md`
|
- `govoplan/docs/project/REPOSITORY_STRUCTURE.md`
|
||||||
- `govoplan/docker/README.md`
|
- `govoplan/docker/README.md`
|
||||||
- `govoplan-core/docs/RELEASE_DEPENDENCIES.md`
|
- `govoplan-core/docs/RELEASE_DEPENDENCIES.md`
|
||||||
- `govoplan-core/docs/MODULE_ARCHITECTURE.md`
|
- `govoplan-core/docs/MODULE_ARCHITECTURE.md`
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# Meta Repository Scan
|
# Meta Repository Scan
|
||||||
|
|
||||||
|
> **Archived assessment:** This file records the 2026-07-13 repository state.
|
||||||
|
> Use `repositories.json` and the current documentation map for present state.
|
||||||
|
|
||||||
Scan date: 2026-07-13.
|
Scan date: 2026-07-13.
|
||||||
|
|
||||||
This scan checked local repositories under `/mnt/DATA/git` listed in
|
This scan checked local repositories under `/mnt/DATA/git` listed in
|
||||||
@@ -13,7 +16,7 @@ Checked-out repositories not listed in `repositories.json`: none.
|
|||||||
|
|
||||||
Repositories listed in `repositories.json` but not checked out locally: none.
|
Repositories listed in `repositories.json` but not checked out locally: none.
|
||||||
|
|
||||||
The human-readable link index is `docs/REPOSITORY_INDEX.md`; the JSON file
|
The human-readable link index is `docs/project/REPOSITORY_INDEX.md`; the JSON file
|
||||||
remains the machine-readable source of truth.
|
remains the machine-readable source of truth.
|
||||||
|
|
||||||
## Meta-Owned Content
|
## Meta-Owned Content
|
||||||
+6
-3
@@ -1,5 +1,8 @@
|
|||||||
# Strategic Review - 2026-08-05
|
# Strategic Review - 2026-08-05
|
||||||
|
|
||||||
|
> **Archived assessment:** This review explains the 2026-08-05 strategy reset.
|
||||||
|
> It is not updated with later implementation or portfolio state.
|
||||||
|
|
||||||
## Assessment
|
## Assessment
|
||||||
|
|
||||||
GovOPlaN has not lost its central direction. The architecture now expresses a
|
GovOPlaN has not lost its central direction. The architecture now expresses a
|
||||||
@@ -9,8 +12,8 @@ need is convergence: fewer simultaneous fronts, stronger cross-cutting
|
|||||||
adoption, and end-to-end reference journeys that non-developers can complete.
|
adoption, and end-to-end reference journeys that non-developers can complete.
|
||||||
|
|
||||||
This is a dated review. Current status belongs in
|
This is a dated review. Current status belongs in
|
||||||
[Strategy Status](STRATEGY_STATUS.md); stable direction belongs in
|
[Strategy Status](../../strategy/STRATEGY_STATUS.md); stable direction belongs in
|
||||||
[Platform Core Ideas](PLATFORM_CORE_IDEAS.md).
|
[Platform Core Ideas](../../strategy/PLATFORM_CORE_IDEAS.md).
|
||||||
|
|
||||||
## What Is Already Strong
|
## What Is Already Strong
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ already reduce navigation and the complete technical rail remains useful for
|
|||||||
power users. The correction is configurable product areas, task-focused Views
|
power users. The correction is configurable product areas, task-focused Views
|
||||||
and a bounded Quick Access rail, while preserving deliberate access to every
|
and a bounded Quick Access rail, while preserving deliberate access to every
|
||||||
authorized tool and technical provenance. The accepted design is maintained in
|
authorized tool and technical provenance. The accepted design is maintained in
|
||||||
[Quick Access And Product Areas](QUICK_ACCESS_AND_PRODUCT_AREAS.md).
|
[Quick Access And Product Areas](../../architecture/QUICK_ACCESS_AND_PRODUCT_AREAS.md).
|
||||||
|
|
||||||
### Status became duplicated
|
### Status became duplicated
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./capability-fit.schema.json",
|
"$schema": "./capability-fit.schema.json",
|
||||||
"schema_version": "0.1.0",
|
"schema_version": "0.2.0",
|
||||||
"assessment_id": "campaign-reference-2026-07-22",
|
"assessment_id": "campaign-reference-2026-07-22",
|
||||||
"assessed_at": "2026-07-22",
|
"assessed_at": "2026-07-22",
|
||||||
"scope": {
|
"scope": {
|
||||||
@@ -13,16 +13,30 @@
|
|||||||
"Workflow and workflow-driven user stories"
|
"Workflow and workflow-driven user stories"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"facts": [
|
||||||
|
"The assessment is pinned to signed stable catalog sequence 202607220843 and the exact module commits listed below.",
|
||||||
|
"The Campaign authoring, validation, build, mock-delivery, managed-file, local-access, and local-audit paths have direct test or contract evidence.",
|
||||||
|
"The production-like development profile runs PostgreSQL and Redis in containers while application processes use editable source trees.",
|
||||||
|
"No installed-target, external-provider, reference-readiness, recovery, or production-approval evidence bundle is attached to this assessment."
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
"Use Campaign as the first reference journey and flagship pilot scenario.",
|
||||||
|
"Keep Workflow and workflow-driven user stories planned and explicitly postponed for this assessment.",
|
||||||
|
"Use local GovOPlaN accounts for the bounded pilot; do not claim federated identity support.",
|
||||||
|
"Do not approve small production until installed-artifact, target mail, monitoring, backup/restore, and recovery proof checks pass."
|
||||||
|
],
|
||||||
"release": {
|
"release": {
|
||||||
"kind": "tagged_release",
|
"kind": "tagged_release",
|
||||||
"ref": "stable-catalog-202607220843",
|
"ref": "stable-catalog-202607220843",
|
||||||
"meta_commit": "5447299289a1",
|
"meta_commit": "5447299289a1",
|
||||||
"reproducible": true,
|
"reproducible": true,
|
||||||
"configuration_packages": [],
|
"configuration_packages": [
|
||||||
|
"none: environment-profile basis only"
|
||||||
|
],
|
||||||
"notes": [
|
"notes": [
|
||||||
"The live stable catalog has a valid Ed25519 signature trusted through release-key-1.",
|
"The live stable catalog has a valid Ed25519 signature trusted through release-key-1.",
|
||||||
"Core v0.1.13 and Campaign v0.1.10 are tagged and package-integrated; this is not target-environment or production approval.",
|
"Core v0.1.13 and Campaign v0.1.10 are tagged and package-integrated; this is not target-environment or production approval.",
|
||||||
"No configuration revision or configuration package is pinned yet."
|
"The absence of a configuration package is pinned explicitly as an environment-profile-only basis; this remains a promotion gap."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"composition": [
|
"composition": [
|
||||||
@@ -188,6 +202,125 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"id": "campaign-pilot",
|
||||||
|
"label": "Controlled Campaign pilot",
|
||||||
|
"status": "partial",
|
||||||
|
"recommendation": "Proceed with a bounded internal pilot after its provider, privacy, workload, and recovery proof checks are assigned and passed.",
|
||||||
|
"composition": [
|
||||||
|
"core",
|
||||||
|
"tenancy",
|
||||||
|
"organizations",
|
||||||
|
"identity",
|
||||||
|
"access",
|
||||||
|
"admin",
|
||||||
|
"dashboard",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"docs",
|
||||||
|
"ops"
|
||||||
|
],
|
||||||
|
"topology": [
|
||||||
|
"One supervised GovOPlaN API process and one immutable built WebUI behind deployment-owned TLS termination",
|
||||||
|
"One PostgreSQL database and a durable single-node or shared managed-file path",
|
||||||
|
"One persistent private Redis broker and one supervised Celery worker when asynchronous delivery is enabled",
|
||||||
|
"One dedicated non-production SMTP/IMAP account with a restricted safe-recipient policy",
|
||||||
|
"External health checks, centralized logs, protected secret injection, and coordinated backup storage"
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Use one internal tenant or office and controlled operators.",
|
||||||
|
"Keep recipient volume non-critical until measured.",
|
||||||
|
"Enable Addresses only when reusable recipient lists or CardDAV are explicitly in scope.",
|
||||||
|
"Do not enable or claim Workflow from this assessment."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "small-production-candidate",
|
||||||
|
"label": "Small-production candidate",
|
||||||
|
"status": "partial",
|
||||||
|
"recommendation": "Do not approve production until every listed operational gate has target evidence and the residual risks have named owners.",
|
||||||
|
"composition": [
|
||||||
|
"core",
|
||||||
|
"tenancy",
|
||||||
|
"organizations",
|
||||||
|
"identity",
|
||||||
|
"access",
|
||||||
|
"admin",
|
||||||
|
"dashboard",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"docs",
|
||||||
|
"ops"
|
||||||
|
],
|
||||||
|
"topology": [
|
||||||
|
"Immutable separately supervised WebUI, API, and worker artifacts behind monitored reverse-proxy TLS",
|
||||||
|
"Dedicated or managed PostgreSQL with measured coordinated backup and isolated restore",
|
||||||
|
"Persistent authenticated Redis with queue-age, queue-depth, and worker-health alerts",
|
||||||
|
"Durable shared or S3-compatible object storage with versioning, lifecycle, and restore evidence",
|
||||||
|
"Target-native secret management, centralized monitoring/logging/audit export, and an exercised incident and disaster-recovery procedure"
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Pin and promote a configuration package instead of relying on an environment-only basis.",
|
||||||
|
"Pass installed-release, target SMTP/IMAP, accessibility, privacy, security, operations, and recovery evidence gates.",
|
||||||
|
"Agree availability, RPO, RTO, retention, support, and procurement requirements.",
|
||||||
|
"Run only one scheduler unless distributed leadership or locking is proved."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"functional_context": {
|
||||||
|
"required_modules": [
|
||||||
|
"core",
|
||||||
|
"tenancy",
|
||||||
|
"organizations",
|
||||||
|
"identity",
|
||||||
|
"access",
|
||||||
|
"admin",
|
||||||
|
"dashboard",
|
||||||
|
"policy",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"files",
|
||||||
|
"mail",
|
||||||
|
"docs",
|
||||||
|
"ops"
|
||||||
|
],
|
||||||
|
"optional_modules": [
|
||||||
|
"addresses"
|
||||||
|
],
|
||||||
|
"external_systems": [
|
||||||
|
"Deployment-owned reverse proxy and TLS certificate lifecycle",
|
||||||
|
"Target SMTP/IMAP service and its DNS, certificate, throttling, bounce, and reply policies",
|
||||||
|
"Target-native secret store, monitoring/logging platform, backup storage, and incident-response process"
|
||||||
|
],
|
||||||
|
"missing_contracts": [
|
||||||
|
"End-to-end federated identity provider and lifecycle contract",
|
||||||
|
"Target monitoring, alert delivery, and central audit/SIEM acceptance contract",
|
||||||
|
"Production configuration-package promotion and approval evidence"
|
||||||
|
],
|
||||||
|
"policy_decisions": [
|
||||||
|
"Recipient allow-list, permitted sender, attachment, retention, and external-disclosure policy",
|
||||||
|
"Identity, MFA, break-glass, service-account, and joiner/mover/leaver policy",
|
||||||
|
"Availability, RPO, RTO, support, procurement, and residual-risk ownership"
|
||||||
|
],
|
||||||
|
"manual_workarounds": [
|
||||||
|
"Use controlled local accounts while federation remains outside the verified slice",
|
||||||
|
"Use one supervised scheduler where periodic work is unavoidable",
|
||||||
|
"Keep provider reconciliation and production promotion under explicit operator review"
|
||||||
|
],
|
||||||
|
"blockers": [
|
||||||
|
"No promoted configuration package is pinned",
|
||||||
|
"No installed-target or target SMTP/IMAP proof is attached",
|
||||||
|
"No coherent target backup/restore or disaster-recovery drill with measured RPO/RTO is attached",
|
||||||
|
"No target privacy, security, accessibility, operations, or production-approval evidence is attached"
|
||||||
|
]
|
||||||
|
},
|
||||||
"questionnaire": {
|
"questionnaire": {
|
||||||
"scope_outcomes": [
|
"scope_outcomes": [
|
||||||
{
|
{
|
||||||
@@ -203,6 +336,20 @@
|
|||||||
"state": "answered",
|
"state": "answered",
|
||||||
"answer": "No; Workflow is planned and explicitly postponed.",
|
"answer": "No; Workflow is planned and explicitly postponed.",
|
||||||
"evidence": []
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "scope.users_tenants_organizations",
|
||||||
|
"question": "Which users, roles, tenants, organization units, and delegated functions participate?",
|
||||||
|
"state": "assumed",
|
||||||
|
"answer": "One internal tenant or office with controlled Campaign operators; detailed organization and delegation shape remains target-specific.",
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "outcome.acceptance",
|
||||||
|
"question": "What constitutes pilot success and production acceptance?",
|
||||||
|
"state": "answered",
|
||||||
|
"answer": "Pilot success requires the bounded Campaign journey and proof checks; production additionally requires installed-artifact, provider, privacy, security, operations, recovery, and approval evidence.",
|
||||||
|
"evidence": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"data_policy": [
|
"data_policy": [
|
||||||
@@ -219,6 +366,13 @@
|
|||||||
"state": "not_assessed",
|
"state": "not_assessed",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
"evidence": []
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "data.privacy_security_disclosure",
|
||||||
|
"question": "Which privacy, security, residency, minimization, access, and external-disclosure constraints apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"identity_integrations": [
|
"identity_integrations": [
|
||||||
@@ -235,22 +389,50 @@
|
|||||||
"state": "not_assessed",
|
"state": "not_assessed",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
"evidence": []
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "identity.protocols_lifecycle",
|
||||||
|
"question": "Which identity protocols, MFA, joiner/mover/leaver, service-account, and break-glass rules are mandatory?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "integration.protocols_network",
|
||||||
|
"question": "Which connector protocols, versions, directions, authentication, certificate, rate-limit, egress, and degraded-mode requirements apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"workload_growth": [
|
"workload_growth": [
|
||||||
{
|
{
|
||||||
"id": "workload.campaign",
|
"id": "workload.campaign_volume_peaks",
|
||||||
"question": "What are Campaign frequency, recipients per Campaign, send window, import size and attachment volume?",
|
"question": "What are Campaign frequency, recipients per Campaign, send window, import size and attachment volume?",
|
||||||
"state": "not_assessed",
|
"state": "not_assessed",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
"evidence": []
|
"evidence": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "workload.platform",
|
"id": "workload.tenants_users_concurrency",
|
||||||
|
"question": "What are tenant, named-user, active-user, concurrent-user, and peak-request assumptions?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workload.files_jobs_audit_growth_retention",
|
||||||
"question": "What are tenant, user, concurrency, file, database, queue and audit growth assumptions?",
|
"question": "What are tenant, user, concurrency, file, database, queue and audit growth assumptions?",
|
||||||
"state": "not_assessed",
|
"state": "not_assessed",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
"evidence": []
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "workload.connector_traffic_batches",
|
||||||
|
"question": "What connector traffic, scheduled-job, batch, queue-depth, queue-age, and external-rate-limit peaks apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"availability_operations": [
|
"availability_operations": [
|
||||||
@@ -267,6 +449,13 @@
|
|||||||
"state": "not_assessed",
|
"state": "not_assessed",
|
||||||
"answer": null,
|
"answer": null,
|
||||||
"evidence": []
|
"evidence": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "hosting.network_constraints",
|
||||||
|
"question": "Which hosting, network-zone, egress, proxy, DNS, NTP, certificate-authority, residency, or disconnected-operation constraints apply?",
|
||||||
|
"state": "not_assessed",
|
||||||
|
"answer": null,
|
||||||
|
"evidence": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"procurement_decisions": [
|
"procurement_decisions": [
|
||||||
@@ -754,6 +943,63 @@
|
|||||||
"recommendation": "Use target-native secret injection and document rotation/recovery.",
|
"recommendation": "Use target-native secret injection and document rotation/recovery.",
|
||||||
"proof_check": "Rotate a non-production credential and recover from a protected backup."
|
"proof_check": "Rotate a non-production credential and recover from a protected backup."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "identity.access",
|
||||||
|
"requirement": "Authenticate users and enforce tenant-scoped authorization through the selected identity mode.",
|
||||||
|
"status": "verified",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-access/tests/test_auth_dependencies.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "committed_source",
|
||||||
|
"locator": "govoplan-core/tests/test_api_smoke.py#cookie-session-csrf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"The bounded pilot accepts local GovOPlaN accounts."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Target MFA, federation, provisioning, and joiner/mover/leaver requirements are not assessed."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"A local-only identity topology may not satisfy institutional production policy."
|
||||||
|
],
|
||||||
|
"recommendation": "Use controlled local pilot accounts and assess the mandatory production identity topology separately.",
|
||||||
|
"proof_check": "Exercise login, role change, account suspension, protected bootstrap, and break-glass recovery in the target."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "connectors.mail",
|
||||||
|
"requirement": "Reach the selected SMTP/IMAP and other external connector endpoints under explicit network and provider policy.",
|
||||||
|
"status": "available_unconfigured",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-mail/tests",
|
||||||
|
"note": "Protocol adapters have direct tests; no target provider was exercised"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "documentation",
|
||||||
|
"scope": "documented_model",
|
||||||
|
"locator": "govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"The deployment supplies DNS, egress, proxy, CA trust, scoped service accounts, and provider limits."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"No target endpoint, TLS chain, throttling, sender policy, bounce/reply path, or disclosure agreement is assessed."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Provider rejection, delay, or ambiguous outcomes can affect delivery and evidence completeness."
|
||||||
|
],
|
||||||
|
"recommendation": "Use a dedicated safe provider account for the pilot and require target interoperability evidence before production.",
|
||||||
|
"proof_check": "Exercise target-like SMTP acceptance, IMAP append, throttling, outage, retry, and reconciliation through the approved network path."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "operations.monitoring",
|
"id": "operations.monitoring",
|
||||||
"requirement": "Detect API, database, worker, queue, storage and delivery degradation.",
|
"requirement": "Detect API, database, worker, queue, storage and delivery degradation.",
|
||||||
@@ -780,6 +1026,30 @@
|
|||||||
"recommendation": "Integrate external monitoring before small production.",
|
"recommendation": "Integrate external monitoring before small production.",
|
||||||
"proof_check": "Trigger each readiness/delivery failure and verify an actionable alert."
|
"proof_check": "Trigger each readiness/delivery failure and verify an actionable alert."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "operations.audit",
|
||||||
|
"requirement": "Retain, monitor, review, and where required export security and business audit evidence.",
|
||||||
|
"status": "partial",
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"kind": "test",
|
||||||
|
"scope": "current_workspace",
|
||||||
|
"locator": "govoplan-audit/tests",
|
||||||
|
"note": "Local audit persistence and retry behavior are exercised"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditions": [
|
||||||
|
"Local database audit evidence is part of coordinated backup and access review."
|
||||||
|
],
|
||||||
|
"gaps": [
|
||||||
|
"Target retention enforcement, tamper-evident export, SIEM integration, alerting, and privileged review are not verified."
|
||||||
|
],
|
||||||
|
"risks": [
|
||||||
|
"Local evidence alone may not meet institutional security, records, or incident-response requirements."
|
||||||
|
],
|
||||||
|
"recommendation": "Define the target audit retention, export, monitoring, and review controls before production approval.",
|
||||||
|
"proof_check": "Exercise privileged-event review, retention, export failure/retry, and target SIEM or archive ingestion."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "operations.backup_restore",
|
"id": "operations.backup_restore",
|
||||||
"requirement": "Back up and restore database, files, configuration and keys as a coherent service.",
|
"requirement": "Back up and restore database, files, configuration and keys as a coherent service.",
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
"assessment_id",
|
"assessment_id",
|
||||||
"assessed_at",
|
"assessed_at",
|
||||||
"scope",
|
"scope",
|
||||||
|
"facts",
|
||||||
|
"decisions",
|
||||||
"release",
|
"release",
|
||||||
"composition",
|
"composition",
|
||||||
"deployment_profile",
|
"deployment_profile",
|
||||||
|
"scenarios",
|
||||||
|
"functional_context",
|
||||||
"questionnaire",
|
"questionnaire",
|
||||||
"capabilities",
|
"capabilities",
|
||||||
"infrastructure",
|
"infrastructure",
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
"format": "uri-reference"
|
"format": "uri-reference"
|
||||||
},
|
},
|
||||||
"schema_version": {
|
"schema_version": {
|
||||||
"const": "0.1.0"
|
"const": "0.2.0"
|
||||||
},
|
},
|
||||||
"assessment_id": {
|
"assessment_id": {
|
||||||
"$ref": "#/$defs/non_empty_string"
|
"$ref": "#/$defs/non_empty_string"
|
||||||
@@ -54,6 +58,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"facts": { "$ref": "#/$defs/string_list" },
|
||||||
|
"decisions": { "$ref": "#/$defs/string_list" },
|
||||||
"release": {
|
"release": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -95,6 +101,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scenarios": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 2,
|
||||||
|
"items": { "$ref": "#/$defs/scenario" }
|
||||||
|
},
|
||||||
|
"functional_context": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"required_modules",
|
||||||
|
"optional_modules",
|
||||||
|
"external_systems",
|
||||||
|
"missing_contracts",
|
||||||
|
"policy_decisions",
|
||||||
|
"manual_workarounds",
|
||||||
|
"blockers"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"required_modules": { "$ref": "#/$defs/string_list" },
|
||||||
|
"optional_modules": { "$ref": "#/$defs/string_list" },
|
||||||
|
"external_systems": { "$ref": "#/$defs/string_list" },
|
||||||
|
"missing_contracts": { "$ref": "#/$defs/string_list" },
|
||||||
|
"policy_decisions": { "$ref": "#/$defs/string_list" },
|
||||||
|
"manual_workarounds": { "$ref": "#/$defs/string_list" },
|
||||||
|
"blockers": { "$ref": "#/$defs/string_list" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"questionnaire": {
|
"questionnaire": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -239,6 +272,37 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"scenario": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"status",
|
||||||
|
"recommendation",
|
||||||
|
"composition",
|
||||||
|
"topology",
|
||||||
|
"conditions"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"label": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"status": { "$ref": "#/$defs/status" },
|
||||||
|
"recommendation": { "$ref": "#/$defs/non_empty_string" },
|
||||||
|
"composition": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
},
|
||||||
|
"topology": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/non_empty_string" }
|
||||||
|
},
|
||||||
|
"conditions": { "$ref": "#/$defs/string_list" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"assessed_item": {
|
"assessed_item": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
@@ -259,6 +323,7 @@
|
|||||||
"status": { "$ref": "#/$defs/status" },
|
"status": { "$ref": "#/$defs/status" },
|
||||||
"evidence": {
|
"evidence": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
"items": { "$ref": "#/$defs/evidence" }
|
"items": { "$ref": "#/$defs/evidence" }
|
||||||
},
|
},
|
||||||
"conditions": { "$ref": "#/$defs/string_list" },
|
"conditions": { "$ref": "#/$defs/string_list" },
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Shared WebUI Primitive Inventory
|
||||||
|
|
||||||
|
This 2026-08-18 inventory records the implementation state after the
|
||||||
|
product-wide structural consolidation and second duplicate-rule audit. It is
|
||||||
|
evidence for enforcement, not a substitute for the normative
|
||||||
|
[interface pattern language](../architecture/INTERFACE_PATTERN_LANGUAGE.md).
|
||||||
|
|
||||||
|
## Implemented And Enforced
|
||||||
|
|
||||||
|
| Contract | Adoption evidence | Ownership now enforced |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| `ActionToolbar` and groups | 50 source files | Raw module-prefixed toolbar elements and local toolbar definitions are rejected. Distribution, wrapping, density, grouping and panel/section surfaces are Core-owned. |
|
||||||
|
| `PageLayout` / `WorkspaceLayout` / `WorkspaceFrame` | 25 / 16 / 17 source files | Headed page anatomy, full-height viewport frames and navigation/list-detail panes no longer repeat inset, heading, notices, loading, shell height, surface, overflow or pane geometry. The raw page-frame and raw workspace exception baselines are both empty. |
|
||||||
|
| `FilterBar` | 14 source files | Catalogue and pane search/filter rows share width, surface, layout and wrapping. |
|
||||||
|
| `SelectionList` family | 19 source files | Resource navigation shares selection, title/description, leading-icon and truncation anatomy. |
|
||||||
|
| `StatePanel` | 25 source files | Whole-surface, compact and fill empty/blocked/error states replace module-local state shells. |
|
||||||
|
| `CountBadge` | 8 source files | Notification, folder, search, postbox and graph counts use one compact badge contract. |
|
||||||
|
| `ContentSection` | 5 source files | Repeated bordered/subtle editor sections and compact provenance panels share surface, density, flow and rhythm. |
|
||||||
|
| `ContentGrid` | 22 source files | Equal-column content geometry and former dashboard/settings/assignment copies are Core-owned. |
|
||||||
|
| `FormGrid` and `FormLayout` | 53 source files | Former generic/admin grids and equal-column dialog/editor copies use named collapse points and native form semantics. |
|
||||||
|
| `MetricGrid` / `MetricCard` | 31 / 33 source files | Module-local metric helpers, grids and card visual definitions were removed. |
|
||||||
|
| `DescriptionList` and `DescriptionItem` | 28 source files | Former generic property grids use semantic `dl`/`dt`/`dd` composition with central density and collapse. |
|
||||||
|
| `DefinitionPalette`, node/canvas visuals and `FloatingStatus` | 2 Dataflow/Workflow consumers each | The copied graph palette, canvas controls, minimap, node icon/port, empty overlay and activity overlay definitions are Core-owned; graph semantics remain local. |
|
||||||
|
| `DialogActions`, `DialogForm`, `DialogSection` | every Core footer / 6 / 6 source files | Footer action flow, native dialog form flow and dialog content grouping are Core-owned. |
|
||||||
|
| Standard dialog sizing | 61 reviewed specialized selectors | Any width matching the Core 460/560/680/1040/1440px scale must use `Dialog size`; the remaining decrease-only exceptions are explicit. |
|
||||||
|
|
||||||
|
`tools/checks/check-shared-webui-primitives.py` verifies Core exports and
|
||||||
|
ownership, representative consumers, the absence of the retired raw anatomy,
|
||||||
|
and composition of every `Dialog` footer through `DialogActions`.
|
||||||
|
`tools/checks/check-shared-webui-layouts.py` additionally requires the reviewed
|
||||||
|
Core and module consumers and rejects any raw page or workspace frame; there
|
||||||
|
are no remaining allow-listed layout exceptions.
|
||||||
|
|
||||||
|
## Dialog Width Classification
|
||||||
|
|
||||||
|
The remaining 61 width selectors do not duplicate the Core 460/560/680/1040/
|
||||||
|
1440px scale. They cover bounded editor widths between scale steps, high-density
|
||||||
|
definition and governance editors, preview/chooser canvases, message and file
|
||||||
|
overlays with coupled height behavior, and responsive full-canvas workflows.
|
||||||
|
Their exact selector set lives in
|
||||||
|
`tools/checks/shared-webui-dialog-width-exceptions.txt`. The focused check fails
|
||||||
|
for a new selector, a stale baseline entry, or any local width that duplicates
|
||||||
|
the Core scale.
|
||||||
|
|
||||||
|
## Audit Result And Deliberate Local Ownership
|
||||||
|
|
||||||
|
The second scan compared exact CSS declaration bodies and JSX anatomy across
|
||||||
|
every WebUI module after migration. All repeated generic structural candidates
|
||||||
|
found in that pass were promoted: viewport frames, catalogue/list shells,
|
||||||
|
filters, selectable lists, state panels, count badges, section frames,
|
||||||
|
equal-column grids, section headers, metrics, and definition-editor chrome.
|
||||||
|
The final legacy-baseline pass also migrated Access administration, Core
|
||||||
|
Settings, Docs, Mail bounce processing, and Organizations to the shared page
|
||||||
|
and workspace layouts and removed their copied responsive geometry.
|
||||||
|
|
||||||
|
The remaining cross-module declaration matches are not independent component
|
||||||
|
anatomy. They are small token-based rules such as ellipsis, muted captions,
|
||||||
|
uppercase terms, or flex-column containment applied to different semantic
|
||||||
|
elements. Moving those rules into a component would erase meaning; their
|
||||||
|
visual values already come from Core tokens. Remaining larger local layouts
|
||||||
|
are deliberately domain-owned:
|
||||||
|
|
||||||
|
- unequal-track editors, import mappings and schema/data tables;
|
||||||
|
- calendar time grids, charts, graph node shapes and graph edge semantics;
|
||||||
|
- file/mail/postbox/records explorer panes whose interaction contracts differ;
|
||||||
|
- timelines, evidence histories, recipient compositions and policy-specific
|
||||||
|
detail sections;
|
||||||
|
- compact list-row internals that cannot preserve their semantics through
|
||||||
|
`SelectionListItemContent`.
|
||||||
|
|
||||||
|
A future candidate is promoted only when a new audit identifies repeated
|
||||||
|
structure plus the same responsive, accessibility and interaction contract.
|
||||||
|
The enforcement script prevents regression for the patterns centralized in
|
||||||
|
this pass and maintains the reviewed dialog-width baseline.
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
|
||||||
|
|
||||||
|
> Generated from [`capability-fit-current.json`](../../capability-fit-current.json).
|
||||||
|
> Edit and validate the machine-readable assessment, then regenerate this file;
|
||||||
|
> do not maintain conclusions independently in Markdown.
|
||||||
|
|
||||||
|
This is an evidence-based fit assessment, not a production approval or
|
||||||
|
security certification. Repository or manifest existence alone never counts
|
||||||
|
as an implemented capability. Unknown target requirements remain explicitly
|
||||||
|
`not_assessed`.
|
||||||
|
|
||||||
|
## Assessment record
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Assessment ID | `campaign-reference-2026-07-22` |
|
||||||
|
| Schema version | `govoplan.fit-assessment/0.2.0` |
|
||||||
|
| Assessed on | 2026-07-22 |
|
||||||
|
| Scope | Campaign-centric internal pilot and small-production candidate |
|
||||||
|
| Release | `stable-catalog-202607220843` (tagged_release) |
|
||||||
|
| Meta commit | `5447299289a1` |
|
||||||
|
| Deployment profile | `production-like-dev` · `partial` |
|
||||||
|
| Configuration packages | `none: environment-profile basis only` |
|
||||||
|
| Canonical input SHA-256 | `5a23f17c5289c5a89d2e92445f2c8b2eef54e3753f1392ebf300aff5508f0bfe` |
|
||||||
|
|
||||||
|
## Controlled status vocabulary
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `verified` | Implemented and directly exercised by evidence appropriate to the stated scope. |
|
||||||
|
| `available_unconfigured` | Implemented with supporting evidence, but not configured and exercised in the target. |
|
||||||
|
| `partial` | A useful subset exists, but a material part of the requirement is missing or unproved. |
|
||||||
|
| `scaffold` | Contracts or structure exist, but the end-to-end capability is not usable. |
|
||||||
|
| `external_system` | The deployment or another system must supply the capability. |
|
||||||
|
| `planned` | Only a concept, backlog item, or design direction exists. |
|
||||||
|
| `not_fit` | Evidence shows that the assessed composition cannot meet the requirement. |
|
||||||
|
| `not_assessed` | The requirement or target environment is not sufficiently known. |
|
||||||
|
|
||||||
|
## Scope and reference journeys
|
||||||
|
|
||||||
|
Reference journeys:
|
||||||
|
|
||||||
|
- Internal operator authors, validates, builds, queues, sends and reconciles an email Campaign with managed attachments
|
||||||
|
- Operator inspects delivery and audit evidence
|
||||||
|
|
||||||
|
Explicitly postponed:
|
||||||
|
|
||||||
|
- Workflow and workflow-driven user stories
|
||||||
|
|
||||||
|
## Facts
|
||||||
|
|
||||||
|
- The assessment is pinned to signed stable catalog sequence 202607220843 and the exact module commits listed below.
|
||||||
|
- The Campaign authoring, validation, build, mock-delivery, managed-file, local-access, and local-audit paths have direct test or contract evidence.
|
||||||
|
- The production-like development profile runs PostgreSQL and Redis in containers while application processes use editable source trees.
|
||||||
|
- No installed-target, external-provider, reference-readiness, recovery, or production-approval evidence bundle is attached to this assessment.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Use Campaign as the first reference journey and flagship pilot scenario.
|
||||||
|
- Keep Workflow and workflow-driven user stories planned and explicitly postponed for this assessment.
|
||||||
|
- Use local GovOPlaN accounts for the bounded pilot; do not claim federated identity support.
|
||||||
|
- Do not approve small production until installed-artifact, target mail, monitoring, backup/restore, and recovery proof checks pass.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The pilot can use local accounts and one internal tenant or office.
|
||||||
|
- A dedicated non-production SMTP/IMAP account and safe recipients are available.
|
||||||
|
- Pilot load fits one API and one worker until measured otherwise.
|
||||||
|
- Durable local storage is acceptable for the pilot.
|
||||||
|
|
||||||
|
## Unresolved decisions
|
||||||
|
|
||||||
|
- What are the target organization's data classes, legal bases, retention and external-disclosure rules?
|
||||||
|
- Which identity, mail, file, address and monitoring systems are mandatory?
|
||||||
|
- What are Campaign volume, concurrency, growth, availability, RPO and RTO?
|
||||||
|
- Who owns each external runtime component and operational control?
|
||||||
|
- Which accessibility, security, support and procurement constraints are mandatory?
|
||||||
|
|
||||||
|
## Pinned release and composition
|
||||||
|
|
||||||
|
Release reproducible: **yes**.
|
||||||
|
|
||||||
|
Release notes:
|
||||||
|
|
||||||
|
- The live stable catalog has a valid Ed25519 signature trusted through release-key-1.
|
||||||
|
- Core v0.1.13 and Campaign v0.1.10 are tagged and package-integrated; this is not target-environment or production approval.
|
||||||
|
- The absence of a configuration package is pinned explicitly as an environment-profile-only basis; this remains a promotion gap.
|
||||||
|
|
||||||
|
| Module | Repository and commit | Manifest version | Enabled | Role |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `core` | `govoplan-core` @ `d487726f4d2c` | `0.1.13` | yes | API, registry, migrations, sessions, kernel contracts and shared WebUI |
|
||||||
|
| `tenancy` | `govoplan-tenancy` @ `efbec827616b` | `0.1.8` | yes | Tenant context and lifecycle |
|
||||||
|
| `organizations` | `govoplan-organizations` @ `39c081c4fb8f` | `0.1.8` | yes | Organization model |
|
||||||
|
| `identity` | `govoplan-identity` @ `7a1710af896f` | `0.1.8` | yes | Normalized internal identity directory |
|
||||||
|
| `access` | `govoplan-access` @ `f1d64d247e12` | `0.1.11` | yes | Local authentication, sessions, API keys and RBAC |
|
||||||
|
| `admin` | `govoplan-admin` @ `11ecf362a36d` | `0.1.8` | yes | Administration surfaces |
|
||||||
|
| `dashboard` | `govoplan-dashboard` @ `4b960ad37f0d` | `0.1.8` | yes | Module-aware home surface |
|
||||||
|
| `policy` | `govoplan-policy` @ `1063622d311a` | `0.1.9` | yes | Policy explanation and configuration boundary |
|
||||||
|
| `audit` | `govoplan-audit` @ `d3d2c60d7dc1` | `0.1.8` | yes | Database audit records and retrying audit outbox |
|
||||||
|
| `campaigns` | `govoplan-campaign` @ `735e874bd03c` | `0.1.10` | yes | Campaign authoring, build, delivery control and reporting |
|
||||||
|
| `files` | `govoplan-files` @ `2b34f6e30578` | `0.1.9` | yes | Managed files and Campaign attachments |
|
||||||
|
| `mail` | `govoplan-mail` @ `3e2302909022` | `0.1.10` | yes | SMTP and IMAP profiles and transports |
|
||||||
|
| `calendar` | `govoplan-calendar` @ `9bcf41bb1fbb` | `0.1.8` | yes | Optional calendar outside the Campaign pilot minimum |
|
||||||
|
| `docs` | `govoplan-docs` @ `be52b716caed` | `0.1.10` | yes | Configured-system documentation |
|
||||||
|
| `ops` | `govoplan-ops` @ `341773a4ff8a` | `0.1.8` | yes | Readiness and deployment-profile visibility |
|
||||||
|
| `addresses` | `govoplan-addresses` @ `93dddbb8c52a` | `0.1.9` | no | Optional reusable recipient sources and CardDAV |
|
||||||
|
|
||||||
|
## Deployment profile
|
||||||
|
|
||||||
|
Status: `partial`
|
||||||
|
|
||||||
|
PostgreSQL and Redis run in containers while API, WebUI, worker and scheduler run from editable source trees.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- configuration/current_workspace: govoplan/dev/production-like/docker-compose.yml
|
||||||
|
- documentation/documented_model: govoplan/dev/production-like/README.md
|
||||||
|
|
||||||
|
## Recommended scenarios
|
||||||
|
|
||||||
|
### Controlled Campaign pilot
|
||||||
|
|
||||||
|
Status: `partial`
|
||||||
|
|
||||||
|
Proceed with a bounded internal pilot after its provider, privacy, workload, and recovery proof checks are assigned and passed.
|
||||||
|
|
||||||
|
Composition: `core`, `tenancy`, `organizations`, `identity`, `access`, `admin`, `dashboard`, `policy`, `audit`, `campaigns`, `files`, `mail`, `docs`, `ops`.
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
|
||||||
|
- One supervised GovOPlaN API process and one immutable built WebUI behind deployment-owned TLS termination
|
||||||
|
- One PostgreSQL database and a durable single-node or shared managed-file path
|
||||||
|
- One persistent private Redis broker and one supervised Celery worker when asynchronous delivery is enabled
|
||||||
|
- One dedicated non-production SMTP/IMAP account with a restricted safe-recipient policy
|
||||||
|
- External health checks, centralized logs, protected secret injection, and coordinated backup storage
|
||||||
|
|
||||||
|
Conditions:
|
||||||
|
|
||||||
|
- Use one internal tenant or office and controlled operators.
|
||||||
|
- Keep recipient volume non-critical until measured.
|
||||||
|
- Enable Addresses only when reusable recipient lists or CardDAV are explicitly in scope.
|
||||||
|
- Do not enable or claim Workflow from this assessment.
|
||||||
|
|
||||||
|
### Small-production candidate
|
||||||
|
|
||||||
|
Status: `partial`
|
||||||
|
|
||||||
|
Do not approve production until every listed operational gate has target evidence and the residual risks have named owners.
|
||||||
|
|
||||||
|
Composition: `core`, `tenancy`, `organizations`, `identity`, `access`, `admin`, `dashboard`, `policy`, `audit`, `campaigns`, `files`, `mail`, `docs`, `ops`.
|
||||||
|
|
||||||
|
Topology:
|
||||||
|
|
||||||
|
- Immutable separately supervised WebUI, API, and worker artifacts behind monitored reverse-proxy TLS
|
||||||
|
- Dedicated or managed PostgreSQL with measured coordinated backup and isolated restore
|
||||||
|
- Persistent authenticated Redis with queue-age, queue-depth, and worker-health alerts
|
||||||
|
- Durable shared or S3-compatible object storage with versioning, lifecycle, and restore evidence
|
||||||
|
- Target-native secret management, centralized monitoring/logging/audit export, and an exercised incident and disaster-recovery procedure
|
||||||
|
|
||||||
|
Conditions:
|
||||||
|
|
||||||
|
- Pin and promote a configuration package instead of relying on an environment-only basis.
|
||||||
|
- Pass installed-release, target SMTP/IMAP, accessibility, privacy, security, operations, and recovery evidence gates.
|
||||||
|
- Agree availability, RPO, RTO, retention, support, and procurement requirements.
|
||||||
|
- Run only one scheduler unless distributed leadership or locking is proved.
|
||||||
|
|
||||||
|
## Functional matrix context
|
||||||
|
|
||||||
|
### Required modules
|
||||||
|
|
||||||
|
- core
|
||||||
|
- tenancy
|
||||||
|
- organizations
|
||||||
|
- identity
|
||||||
|
- access
|
||||||
|
- admin
|
||||||
|
- dashboard
|
||||||
|
- policy
|
||||||
|
- audit
|
||||||
|
- campaigns
|
||||||
|
- files
|
||||||
|
- mail
|
||||||
|
- docs
|
||||||
|
- ops
|
||||||
|
|
||||||
|
### Optional modules
|
||||||
|
|
||||||
|
- addresses
|
||||||
|
|
||||||
|
### External systems and connectors
|
||||||
|
|
||||||
|
- Deployment-owned reverse proxy and TLS certificate lifecycle
|
||||||
|
- Target SMTP/IMAP service and its DNS, certificate, throttling, bounce, and reply policies
|
||||||
|
- Target-native secret store, monitoring/logging platform, backup storage, and incident-response process
|
||||||
|
|
||||||
|
### Missing contracts
|
||||||
|
|
||||||
|
- End-to-end federated identity provider and lifecycle contract
|
||||||
|
- Target monitoring, alert delivery, and central audit/SIEM acceptance contract
|
||||||
|
- Production configuration-package promotion and approval evidence
|
||||||
|
|
||||||
|
### Policy decisions
|
||||||
|
|
||||||
|
- Recipient allow-list, permitted sender, attachment, retention, and external-disclosure policy
|
||||||
|
- Identity, MFA, break-glass, service-account, and joiner/mover/leaver policy
|
||||||
|
- Availability, RPO, RTO, support, procurement, and residual-risk ownership
|
||||||
|
|
||||||
|
### Manual workarounds
|
||||||
|
|
||||||
|
- Use controlled local accounts while federation remains outside the verified slice
|
||||||
|
- Use one supervised scheduler where periodic work is unavoidable
|
||||||
|
- Keep provider reconciliation and production promotion under explicit operator review
|
||||||
|
|
||||||
|
### Blockers
|
||||||
|
|
||||||
|
- No promoted configuration package is pinned
|
||||||
|
- No installed-target or target SMTP/IMAP proof is attached
|
||||||
|
- No coherent target backup/restore or disaster-recovery drill with measured RPO/RTO is attached
|
||||||
|
- No target privacy, security, accessibility, operations, or production-approval evidence is attached
|
||||||
|
|
||||||
|
## Assessment questionnaire
|
||||||
|
|
||||||
|
Every required area remains visible even when its target answer is unknown.
|
||||||
|
|
||||||
|
| Area | Question | State | Answer | Evidence |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Scope Outcomes | Which journey is assessed? | `answered` | An internal operator authors, validates, builds, queues, sends and reconciles a Campaign with managed attachments. | — |
|
||||||
|
| Scope Outcomes | Is Workflow in scope? | `answered` | No; Workflow is planned and explicitly postponed. | — |
|
||||||
|
| Scope Outcomes | Which users, roles, tenants, organization units, and delegated functions participate? | `assumed` | One internal tenant or office with controlled Campaign operators; detailed organization and delegation shape remains target-specific. | — |
|
||||||
|
| Scope Outcomes | What constitutes pilot success and production acceptance? | `answered` | Pilot success requires the bounded Campaign journey and proof checks; production additionally requires installed-artifact, provider, privacy, security, operations, recovery, and approval evidence. | — |
|
||||||
|
| Data Policy | Which data classes and legal bases apply? | `not_assessed` | — | — |
|
||||||
|
| Data Policy | What retention, deletion, archive and legal-hold rules apply? | `not_assessed` | — | — |
|
||||||
|
| Data Policy | Which privacy, security, residency, minimization, access, and external-disclosure constraints apply? | `not_assessed` | — | — |
|
||||||
|
| Identity Integrations | May the pilot use local GovOPlaN accounts? | `assumed` | Yes; federation is outside the verified composition. | — |
|
||||||
|
| Identity Integrations | Which target SMTP/IMAP service and policy apply? | `not_assessed` | — | — |
|
||||||
|
| Identity Integrations | Which identity protocols, MFA, joiner/mover/leaver, service-account, and break-glass rules are mandatory? | `not_assessed` | — | — |
|
||||||
|
| Identity Integrations | Which connector protocols, versions, directions, authentication, certificate, rate-limit, egress, and degraded-mode requirements apply? | `not_assessed` | — | — |
|
||||||
|
| Workload Growth | What are Campaign frequency, recipients per Campaign, send window, import size and attachment volume? | `not_assessed` | — | — |
|
||||||
|
| Workload Growth | What are tenant, named-user, active-user, concurrent-user, and peak-request assumptions? | `not_assessed` | — | — |
|
||||||
|
| Workload Growth | What are tenant, user, concurrency, file, database, queue and audit growth assumptions? | `not_assessed` | — | — |
|
||||||
|
| Workload Growth | What connector traffic, scheduled-job, batch, queue-depth, queue-age, and external-rate-limit peaks apply? | `not_assessed` | — | — |
|
||||||
|
| Availability Operations | What availability, RPO and RTO are required? | `not_assessed` | — | — |
|
||||||
|
| Availability Operations | Who operates database, queue, storage, TLS, secrets, monitoring, backup and incident response? | `not_assessed` | — | — |
|
||||||
|
| Availability Operations | Which hosting, network-zone, egress, proxy, DNS, NTP, certificate-authority, residency, or disconnected-operation constraints apply? | `not_assessed` | — | — |
|
||||||
|
| Procurement Decisions | Which licensing, accessibility, security, certification, support and procurement conditions are mandatory? | `not_assessed` | — | — |
|
||||||
|
|
||||||
|
## Functional capability matrix
|
||||||
|
|
||||||
|
| Requirement | Status | Evidence | Conditions and gaps | Recommendation and proof |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **platform.composition**<br>Compose enabled backend and WebUI modules without hard optional-module dependencies. | `verified` | test/committed_source: govoplan-core/tests/test_module_system.py; contract/current_workspace: govoplan/tools/checks/check-contracts.py (43 modules, 33 providers, 19 requirements, no issues) | Condition: Package integration is verified; repeat checks on the installed target composition.; Gap: No target deployment acceptance is recorded.; Risk: A reproducible module graph can still be installed or configured incorrectly. | Use the signed stable catalog and verify the minimal Campaign composition after installation.<br>**Proof:** Run contract, migration, API and WebUI module-permutation gates on the installed release. |
|
||||||
|
| **access.local**<br>Provide tenant-scoped local accounts, sessions, API keys and RBAC. | `verified` | test/committed_source: govoplan-access/tests/test_auth_dependencies.py; test/committed_source: govoplan-core/tests/test_api_smoke.py#cookie-session-csrf | Condition: Pilot accepts local accounts.; Gap: MFA and federated lifecycle are not part of this conclusion.; Risk: Manual account lifecycle may not satisfy production identity policy. | Use controlled local pilot accounts and define break-glass/bootstrap rules.<br>**Proof:** Exercise joiner, role change, suspension and protected-owner recovery. |
|
||||||
|
| **campaign.journey**<br>Author, validate, build, queue, send, reconcile and report a Campaign with frozen execution evidence. | `verified` | test/committed_source: govoplan-core/tests/test_api_smoke.py#campaign-create-validate-build-mock-send; test/committed_source: govoplan-campaign/tests (Campaign v0.1.10 is exactly the catalog-selected tagged source); configuration/committed_source: https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json#sequence-202607220843 (Core v0.1.13 and Campaign v0.1.10 have matching catalogued Python and WebUI refs) | Condition: This verifies implementation paths, not target-provider delivery.; Gap: Usability and target-provider acceptance remain separate.; Risk: Package integration does not prove provider behavior or production operations. | Use the catalogued Campaign release for usability and target-provider acceptance.<br>**Proof:** Run the complete journey with safe data and the target-like mail service. |
|
||||||
|
| **files.managed_attachments**<br>Store and resolve managed Campaign attachments on durable storage. | `verified` | test/current_workspace: govoplan-files/tests (14 tests passed); test/current_workspace: govoplan-campaign/tests/test_attachment_building.py | Condition: Deployment provides a durable storage root.; Gap: Target backup and restore are not verified.; Risk: Node-local storage prevents safe independent API scaling. | Use durable local storage for the pilot and assess object/shared storage before scaling.<br>**Proof:** Back up and restore files together with database references. |
|
||||||
|
| **mail.smtp_imap**<br>Send Campaign mail through SMTP and optionally append sent messages through IMAP. | `available_unconfigured` | test/current_workspace: govoplan-mail/tests (22 tests passed); documentation/documented_model: govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md | Condition: Use a dedicated non-production service account and safe recipients.; Gap: No target provider, TLS chain, throttling or bounce/reply process was exercised.; Risk: Ambiguous provider outcomes can cause duplicate-send risk if reconciled incorrectly. | Run target-like interoperability and failure drills before production use.<br>**Proof:** Prove SMTP acceptance, IMAP append, throttling and outcome reconciliation. |
|
||||||
|
| **addresses.recipient_sources**<br>Select reusable address lists as Campaign recipient sources. | `available_unconfigured` | test/current_workspace: govoplan-addresses/tests (14 tests passed) | Condition: Enable the Addresses module explicitly.; Gap: Addresses is disabled in the pinned root profile.; Risk: Recipient governance may differ between source data and frozen Campaign evidence. | Enable only when reusable lists are a pilot requirement.<br>**Proof:** Build a Campaign from a source list and verify immutable recipient provenance. |
|
||||||
|
| **audit.local**<br>Retain tenant/system audit evidence and retry governed audit events. | `verified` | test/current_workspace: govoplan-audit/tests (5 tests passed) | Condition: Conclusion covers local database evidence only.; Gap: No central sink, retention enforcement or tamper-evident archive is verified.; Risk: Local audit evidence may not satisfy organizational records or SIEM requirements. | Define retention and export requirements before production approval.<br>**Proof:** Exercise privileged-event review, retention and any required external export. |
|
||||||
|
| **identity.federation**<br>Integrate external LDAP/AD, OIDC/SAML or SCIM identity infrastructure. | `scaffold` | documentation/documented_model: govoplan-idm/README.md | Gap: No end-to-end provider connector or federated login is verified.; Risk: Federation-dependent organizations cannot use the current pilot composition without extra implementation. | Use local pilot accounts or assess and implement the selected provider path.<br>**Proof:** Run provider metadata, login/provisioning, deprovisioning and failure tests. |
|
||||||
|
| **compliance.export_control**<br>Screen persons and organizations against embargo/sanctions lists with review evidence. | `planned` | issue/documented_model: https://git.add-ideas.de/GovOPlaN/govoplan/issues/12 | Gap: No provider, list provenance, match policy, review flow or legal evidence exists.; Risk: The current composition must not be represented as performing export-control screening. | Keep outside pilot claims until the user story is implemented and legally validated.<br>**Proof:** Validate list ingestion, versioning, matching, false-positive review and audit evidence. |
|
||||||
|
| **workflow**<br>Orchestrate the journey through Workflow. | `planned` | observation/documented_model: Assessment scope (Explicitly postponed) | Gap: Workflow is outside this assessment.; Risk: Including it would overstate the assessed composition. | Do not enable or claim Workflow for this reference pilot.<br>**Proof:** Reassess in a later Workflow-focused composition. |
|
||||||
|
|
||||||
|
## Infrastructure matrix
|
||||||
|
|
||||||
|
| Requirement | Status | Evidence | Conditions and gaps | Recommendation and proof |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **runtime.web_api**<br>Serve matching WebUI and API artifacts with health endpoints. | `verified` | test/committed_source: govoplan-core/tests/test_module_system.py; route/committed_source: govoplan-core/src/govoplan_core/server/fastapi.py#/health | Condition: Materialize the matching catalogued artifacts in the target.; Gap: No production image or service bundle is supplied by the profile.; Risk: Editable source processes are unsuitable as a production artifact. | Install matching catalogued WebUI/API refs and supervise them as immutable artifacts.<br>**Proof:** Deploy the built artifacts and run health/module-route checks. |
|
||||||
|
| **runtime.worker**<br>Run durable asynchronous Campaign jobs. | `available_unconfigured` | configuration/current_workspace: govoplan/tools/launch/launch-production-like-dev.sh | Condition: Redis and a supervised worker are required when Celery is enabled.; Gap: Target heartbeat, restart and queue-age alerting are not proved.; Risk: Queued work can stall silently without monitoring. | Start one worker for the pilot and split queues only after measurement.<br>**Proof:** Interrupt and restart a worker while preserving job/reconciliation safety. |
|
||||||
|
| **runtime.scheduler**<br>Run periodic recovery and cleanup safely. | `partial` | test/committed_source: govoplan-calendar/tests/test_outbox.py (Committed and pushed after the catalogued Calendar v0.1.8 tag) | Condition: Calendar outbox and recovery work is remote-integrated source but not stable-package-integrated.; Gap: No distributed leader election or target supervision is established.; Risk: Multiple schedulers can duplicate periodic dispatch without locking. | Omit from the Campaign-only pilot or run one supervised instance.<br>**Proof:** Prove missed-schedule recovery and single-leader behavior. |
|
||||||
|
| **data.postgresql**<br>Persist application state in PostgreSQL with explicit migrations. | `verified` | configuration/committed_source: govoplan/dev/postgres; test/committed_source: govoplan/tools/checks/postgres-integration-check.py | Condition: Target database remains deployment-owned.; Gap: HA, patching, WAL policy and capacity are not assessed.; Risk: A single unprotected database is a system-wide failure point. | Use managed or dedicated PostgreSQL with explicit migration and backup controls.<br>**Proof:** Run migrations and restore a target-like database. |
|
||||||
|
| **queue.redis**<br>Provide the Celery broker and queue persistence. | `available_unconfigured` | configuration/current_workspace: govoplan/dev/production-like/docker-compose.yml#redis | Gap: Authentication, TLS, eviction, HA and queue-loss policy are not assessed.; Risk: Broker loss or eviction can delay work even when database business state survives. | Configure private persistent Redis and monitor queue age/depth.<br>**Proof:** Exercise broker interruption and worker recovery. |
|
||||||
|
| **storage.local**<br>Persist managed files on a durable single-node/shared path. | `verified` | contract/committed_source: govoplan-files/src/govoplan_files/backend/storage/backends.py | Condition: Path is durable, private, writable and backed up.; Gap: Node-local storage cannot support independent API replicas.; Risk: Files can be lost or become inconsistent with database state. | Use for a bounded pilot only with coordinated backup.<br>**Proof:** Restore files and verify all database references. |
|
||||||
|
| **storage.object**<br>Use S3-compatible storage for independently scalable file persistence. | `partial` | test/current_workspace: govoplan-files/tests/test_connector_providers.py | Gap: No chosen target service or storage-backend interoperability drill.; Risk: Provider semantics, CA or lifecycle mismatch can break file access/retention. | Select and exercise the target object store before horizontal scaling.<br>**Proof:** Upload, retrieve, version, back up and restore representative objects. |
|
||||||
|
| **edge.proxy_tls**<br>Terminate HTTPS and enforce proxy/security policy. | `external_system` | route/committed_source: govoplan-ops/src/govoplan_ops/backend/api/v1/routes.py#deployment-security | Gap: No proxy, certificates, renewal, header or request-limit configuration is shipped here.; Risk: Incorrect proxy/cookie/CORS configuration can expose sessions or block legitimate use. | Supply and monitor the edge through the target platform.<br>**Proof:** Run external TLS/header/cookie/CORS and upload-limit tests. |
|
||||||
|
| **security.secret_store**<br>Inject and rotate master, database, mail and connector secrets. | `external_system` | configuration/committed_source: govoplan/.env.example | Gap: No target secret manager or rotation drill is selected.; Risk: Loss of the master key makes encrypted credentials unavailable; leakage compromises connectors. | Use target-native secret injection and document rotation/recovery.<br>**Proof:** Rotate a non-production credential and recover from a protected backup. |
|
||||||
|
| **identity.access**<br>Authenticate users and enforce tenant-scoped authorization through the selected identity mode. | `verified` | test/committed_source: govoplan-access/tests/test_auth_dependencies.py; test/committed_source: govoplan-core/tests/test_api_smoke.py#cookie-session-csrf | Condition: The bounded pilot accepts local GovOPlaN accounts.; Gap: Target MFA, federation, provisioning, and joiner/mover/leaver requirements are not assessed.; Risk: A local-only identity topology may not satisfy institutional production policy. | Use controlled local pilot accounts and assess the mandatory production identity topology separately.<br>**Proof:** Exercise login, role change, account suspension, protected bootstrap, and break-glass recovery in the target. |
|
||||||
|
| **connectors.mail**<br>Reach the selected SMTP/IMAP and other external connector endpoints under explicit network and provider policy. | `available_unconfigured` | test/current_workspace: govoplan-mail/tests (Protocol adapters have direct tests; no target provider was exercised); documentation/documented_model: govoplan-campaign/docs/CAMPAIGN_DELIVERY_RUNBOOK.md | Condition: The deployment supplies DNS, egress, proxy, CA trust, scoped service accounts, and provider limits.; Gap: No target endpoint, TLS chain, throttling, sender policy, bounce/reply path, or disclosure agreement is assessed.; Risk: Provider rejection, delay, or ambiguous outcomes can affect delivery and evidence completeness. | Use a dedicated safe provider account for the pilot and require target interoperability evidence before production.<br>**Proof:** Exercise target-like SMTP acceptance, IMAP append, throttling, outage, retry, and reconciliation through the approved network path. |
|
||||||
|
| **operations.monitoring**<br>Detect API, database, worker, queue, storage and delivery degradation. | `partial` | route/committed_source: govoplan-ops/src/govoplan_ops/backend/api/v1/routes.py#/ops/readiness; contract/committed_source: govoplan-core/src/govoplan_core/server/fastapi.py#slow-request-logging | Gap: No metrics exporter, log collector, dashboards, alert routes or SLO is verified.; Risk: Failures and queue backlog can remain unnoticed. | Integrate external monitoring before small production.<br>**Proof:** Trigger each readiness/delivery failure and verify an actionable alert. |
|
||||||
|
| **operations.audit**<br>Retain, monitor, review, and where required export security and business audit evidence. | `partial` | test/current_workspace: govoplan-audit/tests (Local audit persistence and retry behavior are exercised) | Condition: Local database audit evidence is part of coordinated backup and access review.; Gap: Target retention enforcement, tamper-evident export, SIEM integration, alerting, and privileged review are not verified.; Risk: Local evidence alone may not meet institutional security, records, or incident-response requirements. | Define the target audit retention, export, monitoring, and review controls before production approval.<br>**Proof:** Exercise privileged-event review, retention, export failure/retry, and target SIEM or archive ingestion. |
|
||||||
|
| **operations.backup_restore**<br>Back up and restore database, files, configuration and keys as a coherent service. | `partial` | documentation/documented_model: govoplan-core/docs/DEPLOYMENT_OPERATOR_GUIDE.md; issue/documented_model: https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/29 | Gap: No target full-service restore drill or measured RPO/RTO exists.; Risk: Partial restore can produce missing files, unusable secrets or inconsistent evidence. | Treat Core #29 and a target restore drill as a production gate.<br>**Proof:** Restore the whole service into an isolated environment and measure it. |
|
||||||
|
| **operations.disaster_recovery**<br>Recover the service after site or dependency loss within agreed RPO/RTO. | `not_assessed` | absence/current_workspace: No target DR plan or exercise evidence supplied | Gap: RPO/RTO, off-site copies, recovery order, failover, communications and exercise schedule are unknown.; Risk: Service and evidence may be unrecoverable after a major incident. | Define and exercise DR before any availability commitment.<br>**Proof:** Run a documented end-to-end recovery exercise. |
|
||||||
|
## Data flows and trust boundaries
|
||||||
|
|
||||||
|
| Flow | From → to | Data | Trust boundary | Controls |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `browser.api` | User browser → Reverse proxy and GovOPlaN WebUI/API | Session and CSRF cookies; Campaign content; Recipient personal data; Managed files | Client/public to application | HTTPS; Exact CORS origins; Secure cookies; Tenant and RBAC enforcement; Request limits |
|
||||||
|
| `api.database` | GovOPlaN API and workers → PostgreSQL | Tenant and identity records; Campaign drafts, snapshots and jobs; Connector metadata; Audit evidence | Application to primary state store | Dedicated database identity; Private or encrypted transport; Migrations; Backup and retention |
|
||||||
|
| `api.queue.worker` | GovOPlaN API → Redis and Celery worker | Job identifiers; Queue routing and retry metadata | Request plane to asynchronous processing plane | Private authenticated broker; Bounded payloads; Idempotent claims; Queue monitoring |
|
||||||
|
| `worker.mail` | GovOPlaN Campaign worker → External SMTP and IMAP services | Recipient addresses; Message bodies; Attachments; Sent-message copy | GovOPlaN to external communication provider | Scoped service account; TLS and CA policy; Sender and recipient policy; Rate limits; Outcome reconciliation |
|
||||||
|
| `worker.connectors` | GovOPlaN connector worker → External address, file, object or calendar service | Addresses; Files and provenance; Calendar resources | GovOPlaN to organizational/external content systems | Explicit sync direction; Scoped credentials; Endpoint allow-list; Provenance; Conflict and reconciliation policy |
|
||||||
|
|
||||||
|
## Risks and residual risks
|
||||||
|
|
||||||
|
| Risk | Impact | Treatment | Owner | Residual risk |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| **risk.reproducibility**<br>The signed package selection is reproducible but has not been accepted as an installed target composition. | Installation or configuration drift can still produce uncertain deployed behavior. | Materialize the signed catalog in an isolated target and run installed-artifact acceptance gates. | unassigned | Module and environment differences still require release-environment verification. |
|
||||||
|
| **risk.delivery_provider**<br>Target SMTP/IMAP behavior and failure modes are unproved. | Failed, delayed or duplicate communication and incomplete evidence. | Run target-like interoperability, throttling and uncertainty drills. | unassigned | External provider outages and ambiguous outcomes remain operational risks. |
|
||||||
|
| **risk.recovery**<br>Backup/restore and disaster recovery are not demonstrated across all state and keys. | Irrecoverable or inconsistent service after loss. | Complete Core #29 and an isolated full-service restore/DR exercise. | unassigned | Recovery time and data loss remain bounded by the selected external infrastructure. |
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
- Proceed only with a controlled internal Campaign pilot after the bounded proof checks pass.
|
||||||
|
- Use the minimal composition and enable Addresses only for an explicit reusable-recipient journey.
|
||||||
|
- Do not claim Workflow, export-control screening, identity federation or production DR as implemented.
|
||||||
|
- Treat installed-release acceptance, target mail proof, monitoring and a coherent restore drill as production gates.
|
||||||
|
|
||||||
|
## Proof-of-concept and promotion checks
|
||||||
|
|
||||||
|
1. Materialize the signed catalog into an isolated installation and rerun contract, migration and module-permutation gates against the installed artifacts.
|
||||||
|
2. Collect the isolated installation with the bounded installed-composition evidence contract; require exact enabled package/module versions, complete RECORD verification and immutable provenance anchored to this assessment.
|
||||||
|
3. Run a safe target-like Campaign through SMTP acceptance, IMAP append, reporting and audit.
|
||||||
|
4. Drill worker, Redis and ambiguous-delivery failures without duplicate sends.
|
||||||
|
5. Restore PostgreSQL, managed files, configuration and encrypted credentials and measure RPO/RTO.
|
||||||
|
6. Validate proxy/TLS, cookies/CORS, account bootstrap, secret redaction, monitoring and alert delivery.
|
||||||
|
7. Measure representative Campaign/file/queue/database load and external throttling.
|
||||||
|
8. Require separately issued, expiring and independently scope-authorized evidence before marking target environment, external provider or production approval proof as checked.
|
||||||
|
|
||||||
|
## Generation contract
|
||||||
|
|
||||||
|
This report is deterministic output from the schema-validated JSON companion.
|
||||||
|
The generator rejects duplicate JSON keys, schema drift, secret-bearing field
|
||||||
|
names, stale checked-in output, and oversized inputs. A new assessment or
|
||||||
|
release changes the canonical input hash and requires review of the affected
|
||||||
|
evidence and conclusions through the release-aware reassessment tool.
|
||||||
+21
-15
@@ -1,8 +1,14 @@
|
|||||||
# GovOPlaN Capability and IT-Infrastructure Fit Assessment
|
# Supporting Narrative: 2026-07-22 Capability and Infrastructure Assessment
|
||||||
|
|
||||||
|
> **Canonical report:** The schema-validated human report is generated from the
|
||||||
|
> machine-readable input at
|
||||||
|
> [`CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md`](CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md).
|
||||||
|
> This file retains the original hand-authored evidence narrative and operator
|
||||||
|
> guidance; it is not maintained as a second set of conclusions.
|
||||||
|
|
||||||
> **Pinned historical evidence:** This document assesses the exact 2026-07-22
|
> **Pinned historical evidence:** This document assesses the exact 2026-07-22
|
||||||
> Campaign composition below. It is intentionally not updated to describe later
|
> Campaign composition below. It is intentionally not updated to describe later
|
||||||
> main-branch work. Use [Strategy Status](STRATEGY_STATUS.md) for the current
|
> main-branch work. Use [Strategy Status](../../strategy/STRATEGY_STATUS.md) for the current
|
||||||
> cross-product reconciliation and create a new dated fit assessment for a new
|
> cross-product reconciliation and create a new dated fit assessment for a new
|
||||||
> target composition.
|
> target composition.
|
||||||
|
|
||||||
@@ -17,16 +23,16 @@
|
|||||||
| Configuration basis | Root `.env.example` and the production-like development profile |
|
| Configuration basis | Root `.env.example` and the production-like development profile |
|
||||||
| Scope | Campaign-centric internal pilot and small-production candidate |
|
| Scope | Campaign-centric internal pilot and small-production candidate |
|
||||||
| Explicitly postponed | Workflow and workflow-driven user stories |
|
| Explicitly postponed | Workflow and workflow-driven user stories |
|
||||||
| Machine-readable companion | [`capability-fit-current.json`](capability-fit-current.json) |
|
| Machine-readable companion | [`capability-fit-current.json`](../../capability-fit-current.json) |
|
||||||
| Input schema | [`capability-fit.schema.json`](capability-fit.schema.json) |
|
| Input schema | [`capability-fit.schema.json`](../../capability-fit.schema.json) |
|
||||||
|
|
||||||
**Snapshot notice:** this assessment remains valid only for the pinned
|
**Snapshot notice:** this assessment remains valid only for the pinned
|
||||||
2026-07-22 composition above. Workflow Engine, the optional Workflow editor,
|
2026-07-22 composition above. Workflow Engine, the optional Workflow editor,
|
||||||
Datasources, Dataflow, Search, encryption contracts, and other later main-branch
|
Datasources, Dataflow, Search, encryption contracts, and other later main-branch
|
||||||
work must not be inferred into this evidence record. The current product
|
work must not be inferred into this evidence record. The current product
|
||||||
direction and implemented-state reconciliation are documented separately in
|
direction and implemented-state reconciliation are documented separately in
|
||||||
the [Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
the [Institutional Governance Target Architecture](../../architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
||||||
and [Strategy Status](STRATEGY_STATUS.md).
|
and [Strategy Status](../../strategy/STRATEGY_STATUS.md).
|
||||||
|
|
||||||
This is a fit assessment, not a production approval or security certification.
|
This is a fit assessment, not a production approval or security certification.
|
||||||
It deliberately does not infer implementation from a repository, issue, or
|
It deliberately does not infer implementation from a repository, issue, or
|
||||||
@@ -404,7 +410,7 @@ that observation to the assessment and signed catalog:
|
|||||||
```
|
```
|
||||||
|
|
||||||
The collector follows the strict version `0.4.0`
|
The collector follows the strict version `0.4.0`
|
||||||
[`installed-composition-evidence.schema.json`](installed-composition-evidence.schema.json)
|
[`installed-composition-evidence.schema.json`](../../installed-composition-evidence.schema.json)
|
||||||
contract. It enumerates all installed distributions whose normalized name starts
|
contract. It enumerates all installed distributions whose normalized name starts
|
||||||
with `govoplan-`, compares the enabled assessed package and module-manifest
|
with `govoplan-`, compares the enabled assessed package and module-manifest
|
||||||
versions, and identifies missing, duplicate and extra GovOPlaN distributions.
|
versions, and identifies missing, duplicate and extra GovOPlaN distributions.
|
||||||
@@ -487,14 +493,14 @@ unchecked boundary.
|
|||||||
Installed evidence cannot establish target acceptance, accessibility, privacy,
|
Installed evidence cannot establish target acceptance, accessibility, privacy,
|
||||||
security, operations, recovery, an external provider, or production use. These
|
security, operations, recovery, an external provider, or production use. These
|
||||||
scopes use a separate, expiring
|
scopes use a separate, expiring
|
||||||
[`capability-fit-boundary-evidence.schema.json`](capability-fit-boundary-evidence.schema.json)
|
[`capability-fit-boundary-evidence.schema.json`](../../capability-fit-boundary-evidence.schema.json)
|
||||||
bundle. The bundle is bound to the assessment ID, assessment release and exact
|
bundle. The bundle is bound to the assessment ID, assessment release and exact
|
||||||
installed-evidence SHA-256 digest. It contains only opaque subject/control/result
|
installed-evidence SHA-256 digest. It contains only opaque subject/control/result
|
||||||
IDs and content hashes, not endpoints, credentials, people or raw result files.
|
IDs and content hashes, not endpoints, credentials, people or raw result files.
|
||||||
|
|
||||||
Boundary evidence is accepted only when at least one Ed25519 signature validates
|
Boundary evidence is accepted only when at least one Ed25519 signature validates
|
||||||
against a separately provisioned
|
against a separately provisioned
|
||||||
[`capability-fit-proof-authority-keyring.schema.json`](capability-fit-proof-authority-keyring.schema.json).
|
[`capability-fit-proof-authority-keyring.schema.json`](../../capability-fit-proof-authority-keyring.schema.json).
|
||||||
Each authority key explicitly lists the scopes it may attest. Target,
|
Each authority key explicitly lists the scopes it may attest. Target,
|
||||||
accessibility, privacy, security, operations, recovery, and provider claims use
|
accessibility, privacy, security, operations, recovery, and provider claims use
|
||||||
`passed` or `failed`; production claims use `approved` or `rejected`.
|
`passed` or `failed`; production claims use `approved` or `rejected`.
|
||||||
@@ -511,7 +517,7 @@ the tool's deterministic canonicalization.
|
|||||||
|
|
||||||
`tools/assessments/boundary-evidence.py` is the bounded issuance path. It
|
`tools/assessments/boundary-evidence.py` is the bounded issuance path. It
|
||||||
accepts a private target-run manifest conforming to
|
accepts a private target-run manifest conforming to
|
||||||
[`capability-fit-boundary-run.schema.json`](capability-fit-boundary-run.schema.json),
|
[`capability-fit-boundary-run.schema.json`](../../capability-fit-boundary-run.schema.json),
|
||||||
hashes each retained result file without following a final-component symlink,
|
hashes each retained result file without following a final-component symlink,
|
||||||
and excludes all paths and raw results from the signed receipt. Issuance is
|
and excludes all paths and raw results from the signed receipt. Issuance is
|
||||||
refused unless an independently trusted catalog, exact installed payload,
|
refused unless an independently trusted catalog, exact installed payload,
|
||||||
@@ -521,7 +527,7 @@ is authorized for the full proof interval; catalog and installer key reuse is
|
|||||||
rejected. The command immediately verifies its own result and atomically writes
|
rejected. The command immediately verifies its own result and atomically writes
|
||||||
both the proof and a sanitized review. The complete operator procedure and
|
both the proof and a sanitized review. The complete operator procedure and
|
||||||
recovery measurement definition are in
|
recovery measurement definition are in
|
||||||
[`TARGET_MATURITY_EVIDENCE_RUNBOOK.md`](TARGET_MATURITY_EVIDENCE_RUNBOOK.md).
|
[`TARGET_MATURITY_EVIDENCE_RUNBOOK.md`](../../operations/TARGET_MATURITY_EVIDENCE_RUNBOOK.md).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./.venv/bin/python tools/assessments/capability-fit.py \
|
./.venv/bin/python tools/assessments/capability-fit.py \
|
||||||
@@ -599,7 +605,7 @@ separate from production approval and from provider-specific acceptance.
|
|||||||
|
|
||||||
Both authority keyrings are governance trust roots. Installer receipt keys use
|
Both authority keyrings are governance trust roots. Installer receipt keys use
|
||||||
the strict
|
the strict
|
||||||
[`installer-receipt-authority-keyring.schema.json`](installer-receipt-authority-keyring.schema.json)
|
[`installer-receipt-authority-keyring.schema.json`](../../installer-receipt-authority-keyring.schema.json)
|
||||||
contract and may attest only `installed_release_origin`; their public material
|
contract and may attest only `installed_release_origin`; their public material
|
||||||
must not be reused by catalog or boundary-proof authorities. Do not download or
|
must not be reused by catalog or boundary-proof authorities. Do not download or
|
||||||
generate them from the proof bundle being checked. The checker rejects
|
generate them from the proof bundle being checked. The checker rejects
|
||||||
@@ -623,9 +629,9 @@ journey, source tests, or signed release metadata.
|
|||||||
|
|
||||||
## Evidence used in this slice
|
## Evidence used in this slice
|
||||||
|
|
||||||
- [Production-like profile](../dev/production-like/README.md) and
|
- [Production-like profile](../../../dev/production-like/README.md) and
|
||||||
[Compose dependencies](../dev/production-like/docker-compose.yml)
|
[Compose dependencies](../../../dev/production-like/docker-compose.yml)
|
||||||
- [Module contracts and install boundaries](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- [Module contracts and install boundaries](../../operations/MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
- [Core deployment operator guide](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
- [Core deployment operator guide](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/DEPLOYMENT_OPERATOR_GUIDE.md)
|
||||||
- [Ops scalability profiles](https://git.add-ideas.de/GovOPlaN/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
- [Ops scalability profiles](https://git.add-ideas.de/GovOPlaN/govoplan-ops/src/branch/main/docs/SCALABILITY_PROFILES.md)
|
||||||
- Actual module manifests in the pinned repositories and the static contract
|
- Actual module manifests in the pinned repositories and the static contract
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# DSAR Provider Coverage
|
||||||
|
|
||||||
|
This generated matrix is enforced by `tools/checks/check-dsar-coverage.py`.
|
||||||
|
A migration-owning module must register and document its canonical DSAR provider.
|
||||||
|
Every other active module requires a reviewed explanation of why it owns no
|
||||||
|
persistent subject-data store. Adding a migration invalidates that explanation.
|
||||||
|
|
||||||
|
- Active modules: 72
|
||||||
|
- Registered and documented DSAR providers: 49
|
||||||
|
- Reviewed no-store rationales: 23
|
||||||
|
- Unexplained coverage gaps: 0
|
||||||
|
|
||||||
|
| Module | Repository | Persistence | Coverage | Rationale |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `access` | `govoplan-access` | Migration-owned | Provider | Provider `privacy.dsar.access` is registered and documented. |
|
||||||
|
| `addresses` | `govoplan-addresses` | Migration-owned | Provider | Provider `privacy.dsar.addresses` is registered and documented. |
|
||||||
|
| `admin` | `govoplan-admin` | Migration-owned | Provider | Provider `privacy.dsar.admin` is registered and documented. |
|
||||||
|
| `approvals` | `govoplan-approvals` | Migration-owned | Provider | Provider `privacy.dsar.approvals` is registered and documented. |
|
||||||
|
| `assets` | `govoplan-assets` | No module migration | Reviewed no-store rationale | Contract-only module: asset persistence and lifecycle APIs are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `audit` | `govoplan-audit` | Migration-owned | Provider | Provider `privacy.dsar.audit` is registered and documented. |
|
||||||
|
| `booking` | `govoplan-booking` | No module migration | Reviewed no-store rationale | Contract-only module: booking persistence and reservation workflows are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `calendar` | `govoplan-calendar` | Migration-owned | Provider | Provider `privacy.dsar.calendar` is registered and documented. |
|
||||||
|
| `campaigns` | `govoplan-campaign` | Migration-owned | Provider | Provider `privacy.dsar.campaigns` is registered and documented. |
|
||||||
|
| `cases` | `govoplan-cases` | Migration-owned | Provider | Provider `privacy.dsar.cases` is registered and documented. |
|
||||||
|
| `certificates` | `govoplan-certificates` | No module migration | Reviewed no-store rationale | Contract-only module: certificate issuance and revocation persistence are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `committee` | `govoplan-committee` | Migration-owned | Provider | Provider `privacy.dsar.committee` is registered and documented. |
|
||||||
|
| `connectors` | `govoplan-connectors` | Migration-owned | Provider | Provider `privacy.dsar.connectors` is registered and documented. |
|
||||||
|
| `consultation` | `govoplan-consultation` | No module migration | Reviewed no-store rationale | Contract-only module: consultation submissions and evaluation persistence are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `contracts` | `govoplan-contracts` | No module migration | Reviewed no-store rationale | Contract-only module: contract, amendment, and obligation persistence are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `dashboard` | `govoplan-dashboard` | Migration-owned | Provider | Provider `privacy.dsar.dashboard` is registered and documented. |
|
||||||
|
| `dataflow` | `govoplan-dataflow` | Migration-owned | Provider | Provider `privacy.dsar.dataflow` is registered and documented. |
|
||||||
|
| `datasources` | `govoplan-datasources` | Migration-owned | Provider | Provider `privacy.dsar.datasources` is registered and documented. |
|
||||||
|
| `decisions` | `govoplan-decisions` | Migration-owned | Provider | Provider `privacy.dsar.decisions` is registered and documented. |
|
||||||
|
| `dist_lists` | `govoplan-dist-lists` | Migration-owned | Provider | Provider `privacy.dsar.dist_lists` is registered and documented. |
|
||||||
|
| `dms` | `govoplan-dms` | No module migration | Reviewed no-store rationale | Stateless integration-preview module: DMS retains no document, person, credential, or provider-response store; Files and Records remain the subject-data owners. Reassess before persisting a target binding, plan, receipt, or diagnostic. |
|
||||||
|
| `docs` | `govoplan-docs` | Migration-owned | Provider | Provider `privacy.dsar.docs` is registered and documented. |
|
||||||
|
| `encryption` | `govoplan-encryption` | Migration-owned | Provider | Provider `privacy.dsar.encryption` is registered and documented. |
|
||||||
|
| `erp` | `govoplan-erp` | No module migration | Reviewed no-store rationale | Stateless integration-contract module: ERP retains no invoice, payable, plan, booking observation, provider response, or credential store; Procurement, Payments, Ledger, Files, and Audit remain the subject-data owners. Reassess before persisting a target binding, plan, receipt, reconciliation decision, or diagnostic. |
|
||||||
|
| `evaluation` | `govoplan-evaluation` | No module migration | Reviewed no-store rationale | Contract-only module: evaluation runs, responses, and scores are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `facilities` | `govoplan-facilities` | No module migration | Reviewed no-store rationale | Contract-only module: facility and maintenance persistence are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `files` | `govoplan-files` | Migration-owned | Provider | Provider `privacy.dsar.files` is registered and documented. |
|
||||||
|
| `fit_connect` | `govoplan-fit-connect` | No module migration | Reviewed no-store rationale | Stateless transport-contract module: FIT-Connect retains no submission, attachment, receipt, acknowledgement plan, key, provider response, or diagnostic store; the owning Service, Forms, Cases, Files, and Audit workflows remain responsible for subject data. Reassess before persisting any ingress or event-log evidence. |
|
||||||
|
| `forms` | `govoplan-forms` | Migration-owned | Provider | Provider `privacy.dsar.forms` is registered and documented. |
|
||||||
|
| `forms_runtime` | `govoplan-forms-runtime` | Migration-owned | Provider | Provider `privacy.dsar.forms_runtime` is registered and documented. |
|
||||||
|
| `grants` | `govoplan-grants` | No module migration | Reviewed no-store rationale | Contract-only module: grant applications, awards, and monitoring are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `helpdesk` | `govoplan-helpdesk` | Migration-owned | Provider | Provider `privacy.dsar.helpdesk` is registered and documented. |
|
||||||
|
| `identity` | `govoplan-identity` | Migration-owned | Provider | Provider `privacy.dsar.identity` is registered and documented. |
|
||||||
|
| `identity_trust` | `govoplan-identity-trust` | Migration-owned | Provider | Provider `privacy.dsar.identity_trust` is registered and documented. |
|
||||||
|
| `idm` | `govoplan-idm` | Migration-owned | Provider | Provider `privacy.dsar.idm` is registered and documented. |
|
||||||
|
| `inspections` | `govoplan-inspections` | No module migration | Reviewed no-store rationale | Contract-only module: inspections, findings, and measures are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `learning` | `govoplan-learning` | No module migration | Reviewed no-store rationale | Contract-only module: learning offers, enrollment, and completion are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `mail` | `govoplan-mail` | Migration-owned | Provider | Provider `privacy.dsar.mail` is registered and documented. |
|
||||||
|
| `mandates` | `govoplan-mandates` | Migration-owned | Provider | Provider `privacy.dsar.mandates` is registered and documented. |
|
||||||
|
| `notifications` | `govoplan-notifications` | Migration-owned | Provider | Provider `privacy.dsar.notifications` is registered and documented. |
|
||||||
|
| `ops` | `govoplan-ops` | No module migration | Reviewed no-store rationale | Projection-only module: Ops reads bounded platform and provider status; durable recovery evidence remains owned by Core and domain modules. |
|
||||||
|
| `organizations` | `govoplan-organizations` | Migration-owned | Provider | Provider `privacy.dsar.organizations` is registered and documented. |
|
||||||
|
| `parties` | `govoplan-parties` | Migration-owned | Provider | Provider `privacy.dsar.parties` is registered and documented. |
|
||||||
|
| `payments` | `govoplan-payments` | Migration-owned | Provider | Provider `privacy.dsar.payments` is registered and documented. |
|
||||||
|
| `permits` | `govoplan-permits` | No module migration | Reviewed no-store rationale | Contract-only module: permit applications, assessments, and decisions are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `policy` | `govoplan-policy` | Migration-owned | Provider | Provider `privacy.dsar.policy` is registered and documented. |
|
||||||
|
| `poll` | `govoplan-poll` | Migration-owned | Provider | Provider `privacy.dsar.poll` is registered and documented. |
|
||||||
|
| `portal` | `govoplan-portal` | No module migration | Reviewed no-store rationale | Projection-only module: Portal stores no applicant records; Services, Forms Runtime, Cases, and Postbox own and export authoritative subject data. |
|
||||||
|
| `postbox` | `govoplan-postbox` | Migration-owned | Provider | Provider `privacy.dsar.postbox` is registered and documented. |
|
||||||
|
| `procurement` | `govoplan-procurement` | No module migration | Reviewed no-store rationale | Contract-only module: procurement procedures, tenders, and awards are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `projects` | `govoplan-projects` | Migration-owned | Provider | Provider `privacy.dsar.projects` is registered and documented. |
|
||||||
|
| `quick_access` | `govoplan-quick-access` | Migration-owned | Provider | Provider `privacy.dsar.quick_access` is registered and documented. |
|
||||||
|
| `records` | `govoplan-records` | Migration-owned | Provider | Provider `privacy.dsar.records` is registered and documented. |
|
||||||
|
| `reporting` | `govoplan-reporting` | Migration-owned | Provider | Provider `privacy.dsar.reporting` is registered and documented. |
|
||||||
|
| `resources` | `govoplan-resources` | No module migration | Reviewed no-store rationale | Contract-only module: resource catalog and allocation persistence are not implemented; reassess before adding a migration-owned store. |
|
||||||
|
| `rest` | `govoplan-rest` | No module migration | Reviewed no-store rationale | Transport-only module: REST binds explicitly published functions and owns no domain or subject-data store. |
|
||||||
|
| `risk_compliance` | `govoplan-risk-compliance` | Migration-owned | Provider | Provider `privacy.dsar.risk_compliance` is registered and documented. |
|
||||||
|
| `scheduling` | `govoplan-scheduling` | Migration-owned | Provider | Provider `privacy.dsar.scheduling` is registered and documented. |
|
||||||
|
| `search` | `govoplan-search` | Migration-owned | Provider | Provider `privacy.dsar.search` is registered and documented. |
|
||||||
|
| `services` | `govoplan-services` | Migration-owned | Provider | Provider `privacy.dsar.services` is registered and documented. |
|
||||||
|
| `soap` | `govoplan-soap` | No module migration | Reviewed no-store rationale | Transport-only module: SOAP binds explicitly published operations and owns no domain or subject-data store. |
|
||||||
|
| `tasks` | `govoplan-tasks` | Migration-owned | Provider | Provider `privacy.dsar.tasks` is registered and documented. |
|
||||||
|
| `templates` | `govoplan-templates` | Migration-owned | Provider | Provider `privacy.dsar.templates` is registered and documented. |
|
||||||
|
| `tenancy` | `govoplan-tenancy` | Migration-owned | Provider | Provider `privacy.dsar.tenancy` is registered and documented. |
|
||||||
|
| `tickets` | `govoplan-tickets` | Migration-owned | Provider | Provider `privacy.dsar.tickets` is registered and documented. |
|
||||||
|
| `transparency` | `govoplan-transparency` | No module migration | Reviewed no-store rationale | Contract-only module: requests, disclosure reviews, and publications are not persisted; reassess before adding a migration-owned store. |
|
||||||
|
| `views` | `govoplan-views` | Migration-owned | Provider | Provider `privacy.dsar.views` is registered and documented. |
|
||||||
|
| `voting` | `govoplan-voting` | Migration-owned | Provider | Provider `privacy.dsar.voting` is registered and documented. |
|
||||||
|
| `wiki` | `govoplan-wiki` | Migration-owned | Provider | Provider `privacy.dsar.wiki` is registered and documented. |
|
||||||
|
| `workflow` | `govoplan-workflow` | No module migration | Reviewed no-store rationale | Presentation-only module: Workflow edits and projects Workflow Engine state; Workflow Engine owns persistence and DSAR coverage. |
|
||||||
|
| `workflow_engine` | `govoplan-workflow-engine` | Migration-owned | Provider | Provider `privacy.dsar.workflow_engine` is registered and documented. |
|
||||||
|
| `xrechnung` | `govoplan-xrechnung` | No module migration | Reviewed no-store rationale | Stateless validation-contract module: XRechnung persists no invoice, report, diagnostic, or handoff; the invoking Files, Procurement, or Payments workflow remains the subject-data owner. Reassess before adding a validation store. |
|
||||||
|
|
||||||
|
Provider search, export minimization, retention, and erasure behavior remains
|
||||||
|
documented and tested by each owning module. This matrix verifies adoption and
|
||||||
|
ownership coverage; Core continues to test disabled providers, partial failure,
|
||||||
|
retry, authorization evidence, and horizontally coordinated execution.
|
||||||
+13
-6
@@ -1,19 +1,24 @@
|
|||||||
# GovOPlaN Interface Surface Inventory And Rollout
|
# GovOPlaN Interface Surface Inventory And Rollout
|
||||||
|
|
||||||
|
> **Pinned snapshot:** This inventory records the source-derived state reviewed
|
||||||
|
> on 2026-08-03. It is retained as evidence, not maintained as the current
|
||||||
|
> rollout ledger. Generate a new inventory and use Gitea issues for current
|
||||||
|
> implementation state.
|
||||||
|
|
||||||
This is the initial evidence inventory for the product-wide interface pattern
|
This is the initial evidence inventory for the product-wide interface pattern
|
||||||
language. It records code contributions, not an assertion that every listed
|
language. It records code contributions, not an assertion that every listed
|
||||||
surface is complete, enabled in a deployment, usable, or compliant.
|
surface is complete, enabled in a deployment, usable, or compliant.
|
||||||
|
|
||||||
The applicable design contract is
|
The applicable design contract is
|
||||||
[`INTERFACE_PATTERN_LANGUAGE.md`](INTERFACE_PATTERN_LANGUAGE.md).
|
[`INTERFACE_PATTERN_LANGUAGE.md`](../../architecture/INTERFACE_PATTERN_LANGUAGE.md).
|
||||||
|
|
||||||
## Snapshot And Method
|
## Snapshot And Method
|
||||||
|
|
||||||
The source-derived inventory command is documented in
|
The source-derived inventory command is documented in
|
||||||
[`PLATFORM_CONTROL_PLANE.md`](PLATFORM_CONTROL_PLANE.md). It produces
|
[`PLATFORM_CONTROL_PLANE.md`](../../architecture/PLATFORM_CONTROL_PLANE.md). It produces
|
||||||
machine-readable field, label, translation, route, API-reference, and module
|
machine-readable field, label, translation, route, API-reference, and module
|
||||||
manifest evidence. This hand-maintained document remains the reviewed product
|
manifest evidence. This hand-maintained document is the reviewed interpretation
|
||||||
interpretation and rollout ledger; generated evidence does not replace it.
|
of that snapshot; generated evidence does not retroactively change it.
|
||||||
|
|
||||||
Snapshot refreshed: 2026-08-03.
|
Snapshot refreshed: 2026-08-03.
|
||||||
|
|
||||||
@@ -104,7 +109,9 @@ semantics as authenticated navigation routes.
|
|||||||
| `/scheduling/public/:requestId/:token` | Scheduling | Public signed token | Public participation | Scheduling #8 complete in `c17cbda` |
|
| `/scheduling/public/:requestId/:token` | Scheduling | Public signed token | Public participation | Scheduling #8 complete in `c17cbda` |
|
||||||
| `/search` | Search | `search:result:read` | Keyboard-first global/context overlay and full results fallback | Search pattern migration complete in [Search #4](https://git.add-ideas.de/GovOPlaN/govoplan-search/issues/4); durable evidence in `govoplan-search/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
| `/search` | Search | `search:result:read` | Keyboard-first global/context overlay and full results fallback | Search pattern migration complete in [Search #4](https://git.add-ideas.de/GovOPlaN/govoplan-search/issues/4); durable evidence in `govoplan-search/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
| `/templates` | Templates | Template read/write/publish/render/admin | Governed library, immutable-revision editor, compatibility preview, and render evidence | Templates pattern migration complete in [Templates #5](https://git.add-ideas.de/GovOPlaN/govoplan-templates/issues/5), commit `72fafa2` |
|
| `/templates` | Templates | Template read/write/publish/render/admin | Governed library, immutable-revision editor, compatibility preview, and render evidence | Templates pattern migration complete in [Templates #5](https://git.add-ideas.de/GovOPlaN/govoplan-templates/issues/5), commit `72fafa2` |
|
||||||
|
| `/tickets` | Tickets | `tickets:ticket:read` | Governed operational queue/detail workspace with distinct report, triage, assignment, resolution, comment, reference and removal boundaries | Tickets vertical slice and pattern migration complete in [Tickets #1](https://git.add-ideas.de/GovOPlaN/govoplan-tickets/issues/1), release `v0.1.20` |
|
||||||
| `/voting` | Voting | `voting:ballot:read` | Governed ballot workspace | Voting pattern migration complete in [Voting #1](https://git.add-ideas.de/GovOPlaN/govoplan-voting/issues/1), commit `2625990` |
|
| `/voting` | Voting | `voting:ballot:read` | Governed ballot workspace | Voting pattern migration complete in [Voting #1](https://git.add-ideas.de/GovOPlaN/govoplan-voting/issues/1), commit `2625990` |
|
||||||
|
| `/wiki` | Wiki | `wiki:page:read` | Governed space-tree/page workspace with draft editing, immutable revision comparison, publication, comments, typed references and archival | Native Wiki vertical slice and pattern migration implemented in [Wiki #1](https://git.add-ideas.de/GovOPlaN/govoplan-wiki/issues/1), release `v0.1.20` |
|
||||||
| `/workflow` | Workflow | Definition read or instance admin | Native BPMN editor, governed revision actions and execution evidence | Workflow pattern migration complete in [Workflow #15](https://git.add-ideas.de/GovOPlaN/govoplan-workflow/issues/15); durable evidence in `govoplan-workflow/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
| `/workflow` | Workflow | Definition read or instance admin | Native BPMN editor, governed revision actions and execution evidence | Workflow pattern migration complete in [Workflow #15](https://git.add-ideas.de/GovOPlaN/govoplan-workflow/issues/15); durable evidence in `govoplan-workflow/docs/INTERFACE_PATTERN_MIGRATION.md` |
|
||||||
|
|
||||||
## Final Module Closure Evidence
|
## Final Module Closure Evidence
|
||||||
@@ -318,8 +325,8 @@ The generated manifest snapshot reports no WebUI package for:
|
|||||||
`govoplan-learning`, `govoplan-mandates`, `govoplan-parties`,
|
`govoplan-learning`, `govoplan-mandates`, `govoplan-parties`,
|
||||||
`govoplan-permits`, `govoplan-poll`, `govoplan-procurement`,
|
`govoplan-permits`, `govoplan-poll`, `govoplan-procurement`,
|
||||||
`govoplan-records`, `govoplan-resources`, `govoplan-rest`,
|
`govoplan-records`, `govoplan-resources`, `govoplan-rest`,
|
||||||
`govoplan-services`, `govoplan-soap`, `govoplan-tickets`,
|
`govoplan-services`, `govoplan-soap`, `govoplan-transparency`, and
|
||||||
`govoplan-transparency`, `govoplan-wiki`, and `govoplan-workflow-engine`.
|
`govoplan-workflow-engine`.
|
||||||
|
|
||||||
Tenancy does provide composed administration surfaces despite having no direct
|
Tenancy does provide composed administration surfaces despite having no direct
|
||||||
route. This section is only negative package evidence; connector-only,
|
route. This section is only negative package evidence; connector-only,
|
||||||
+2
-2
@@ -16,8 +16,8 @@ deployment/evidence boundary.
|
|||||||
|
|
||||||
The machine-readable contracts are:
|
The machine-readable contracts are:
|
||||||
|
|
||||||
- [`backup-evidence.schema.json`](backup-evidence.schema.json);
|
- [`backup-evidence.schema.json`](../backup-evidence.schema.json);
|
||||||
- [`backup-evidence-keyring.schema.json`](backup-evidence-keyring.schema.json).
|
- [`backup-evidence-keyring.schema.json`](../backup-evidence-keyring.schema.json).
|
||||||
|
|
||||||
One evidence document is bound to the installation id, deployment profile,
|
One evidence document is bound to the installation id, deployment profile,
|
||||||
topology subject, exact signed release manifest, image digests, and composition
|
topology subject, exact signed release manifest, image digests, and composition
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Full registry candidates / Vollständige Registry-Kandidaten
|
||||||
|
|
||||||
|
## Operator workflow (EN)
|
||||||
|
|
||||||
|
The canonical `release-catalog.py full-registry` command takes `--package-set`,
|
||||||
|
`--package-lock`, `--wheelhouse`, `--webui-packages`, `--output-dir`, and a
|
||||||
|
configured `--catalog-signing-key`. Generate the package set with
|
||||||
|
`generate-release-package-set.py --profile full` and download its exact artifacts
|
||||||
|
with `resolve-package-artifacts.py`; do not substitute locally rebuilt wheels.
|
||||||
|
The candidate compares the package set with the exact developer meta-package
|
||||||
|
pins, checks archive bytes and package metadata against the lock, and synthesizes
|
||||||
|
every entry from its immutable tagged manifest. Native package publication and
|
||||||
|
its CI authority remain trusted: this verifies the published artifact identity,
|
||||||
|
not independent reproducible-build equivalence to source.
|
||||||
|
|
||||||
|
Pass `--selected-repository` once for each newly released repository, including
|
||||||
|
Core when it changes. These selected units must have clean, version-aligned
|
||||||
|
named branches whose HEAD equals the annotated local and remote release tag.
|
||||||
|
Other full-profile packages retain their exact older annotated tags; a later
|
||||||
|
workflow-only commit on `main` does not relabel those package contents or force
|
||||||
|
a version bump. Every source fetch/push endpoint must match the registered
|
||||||
|
origin, and all entries bind their source commit and annotated tag object.
|
||||||
|
Git replacement objects, caller Git configuration, and executable-path
|
||||||
|
redirection cannot substitute another tagged manifest tree.
|
||||||
|
|
||||||
|
The fixed existing website catalog and keyring are authenticated before signing.
|
||||||
|
An older catalog without a signed keyring hash can be migrated only through this
|
||||||
|
complete rebuild, only when its signature verifies and its entire keyring
|
||||||
|
exactly matches the configured known signers. No old entries or artifact hashes
|
||||||
|
are reused. The new catalog signs the exact unchanged website keyring hash;
|
||||||
|
key rotation remains a separate reviewed operation. Selective candidates still
|
||||||
|
reject unpinned base keyrings. New candidate directories are private and
|
||||||
|
exclusive: a retry must choose a new directory, not overwrite a reviewed one.
|
||||||
|
|
||||||
|
Run these commands on the trusted host, with an operator-private source workspace
|
||||||
|
and artifact directory. `RELEASE_PYTHON` must select its private environment and
|
||||||
|
`RELEASE_NPM` an absolute npm executable with a trusted sibling Node 22 binary.
|
||||||
|
In Flatpak, execute host commands through `flatpak-spawn --host`; sandbox and
|
||||||
|
host UID mappings are not interchangeable. Do not weaken trust gates or change
|
||||||
|
system-wide permissions.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# These paths identify previously prepared private operator resources.
|
||||||
|
RELEASE_WORKSPACE=/path/to/private/workspace
|
||||||
|
RELEASE_PYTHON="$RELEASE_WORKSPACE/govoplan/.host-venv/bin/python"
|
||||||
|
RELEASE_NPM=/path/to/private/node22/bin/npm
|
||||||
|
ARTIFACT_ROOT=/path/to/private/artifacts
|
||||||
|
RELEASE_CANDIDATE=/path/to/private/new-candidate
|
||||||
|
RELEASE_VERSION=0.1.45
|
||||||
|
RELEASE_TOOLS="$RELEASE_WORKSPACE/govoplan/tools/release"
|
||||||
|
|
||||||
|
umask 077
|
||||||
|
"$RELEASE_PYTHON" "$RELEASE_TOOLS/generate-release-package-set.py" \
|
||||||
|
--version "$RELEASE_VERSION" --profile full \
|
||||||
|
--workspace "$RELEASE_WORKSPACE" --output "$ARTIFACT_ROOT/packages.json"
|
||||||
|
PATH="$(dirname "$RELEASE_NPM"):/usr/bin:/bin" \
|
||||||
|
"$RELEASE_PYTHON" "$RELEASE_TOOLS/resolve-package-artifacts.py" \
|
||||||
|
--package-set "$ARTIFACT_ROOT/packages.json" \
|
||||||
|
--wheelhouse "$ARTIFACT_ROOT/wheels" \
|
||||||
|
--webui-packages "$ARTIFACT_ROOT/webui" \
|
||||||
|
--lock-output "$ARTIFACT_ROOT/artifacts.lock.json" \
|
||||||
|
--python "$RELEASE_PYTHON" --npm "$RELEASE_NPM"
|
||||||
|
|
||||||
|
# Repeat --selected-repository for EVERY newly released unit, not just Core.
|
||||||
|
"$RELEASE_PYTHON" "$RELEASE_TOOLS/release-catalog.py" full-registry \
|
||||||
|
--workspace-root "$RELEASE_WORKSPACE" \
|
||||||
|
--package-set "$ARTIFACT_ROOT/packages.json" \
|
||||||
|
--package-lock "$ARTIFACT_ROOT/artifacts.lock.json" \
|
||||||
|
--wheelhouse "$ARTIFACT_ROOT/wheels" --webui-packages "$ARTIFACT_ROOT/webui" \
|
||||||
|
--output-dir "$RELEASE_CANDIDATE" --selected-repository govoplan-core \
|
||||||
|
--catalog-signing-key known-key=/path/to/private/known-key.pem --json
|
||||||
|
|
||||||
|
"$RELEASE_PYTHON" "$RELEASE_TOOLS/release-catalog.py" publish-candidate \
|
||||||
|
--workspace-root "$RELEASE_WORKSPACE" --candidate-dir "$RELEASE_CANDIDATE" \
|
||||||
|
--channel stable --npm "$RELEASE_NPM" --build-web \
|
||||||
|
--commit --tag --push --tag-name "catalog-v$RELEASE_VERSION" --json
|
||||||
|
```
|
||||||
|
|
||||||
|
The last command is a strict non-mutating preview because `--apply` is absent.
|
||||||
|
Review its output, then repeat it with `--apply` to publish. The website's locked
|
||||||
|
build dependencies must already be installed before `--build-web`. The publisher
|
||||||
|
sanitizes the build and Git environments and pushes the verified immutable
|
||||||
|
website commit/tag. Source tag publication and registry package availability
|
||||||
|
must be complete before candidate generation.
|
||||||
|
|
||||||
|
Source tags, registry packages, and a signed module catalog do not imply that a
|
||||||
|
new runtime distribution exists. While runtime images are held, leave the Meta
|
||||||
|
Gitea runtime Release held too: a normal source-only Release can replace Gitea's
|
||||||
|
`releases/latest` discovery result despite having no deployment assets. The
|
||||||
|
deployer still requires an explicit signed manifest, digest, and trusted
|
||||||
|
keyring; it does not deploy a tag or module catalog directly.
|
||||||
|
|
||||||
|
## Betriebsablauf (DE)
|
||||||
|
|
||||||
|
`release-catalog.py full-registry` übernimmt den vollständigen Paketbestand,
|
||||||
|
die Registry-Sperrdatei, das Wheel-Verzeichnis, die WebUI-Archive und den
|
||||||
|
konfigurierten Signaturschlüssel. Zuerst mit
|
||||||
|
`generate-release-package-set.py --profile full` die exakten Meta-Paketversionen
|
||||||
|
ermitteln und mit `resolve-package-artifacts.py` die veröffentlichten Artefakte
|
||||||
|
herunterladen. Lokal neu gebaute Wheels sind kein Ersatz. Der Kandidat prüft
|
||||||
|
Paketidentitäten, Dateigrößen und Hashes und erzeugt alle Einträge aus den
|
||||||
|
unveränderlichen getaggten Manifesten. Die Registry und ihre veröffentlichende
|
||||||
|
CI bleiben eine Vertrauensgrundlage; dies ist kein unabhängiger Nachweis eines
|
||||||
|
reproduzierbaren Builds aus dem Quellcode.
|
||||||
|
|
||||||
|
Jedes neu veröffentlichte Repository wird mit `--selected-repository`
|
||||||
|
angegeben. Nur diese Auswahl muss mit dem sauberen, versionsgleichen HEAD eines
|
||||||
|
benannten Branches und dem annotierten lokalen und entfernten Tag übereinstimmen.
|
||||||
|
Unveränderte Pakete behalten ihren ursprünglichen Tag, auch wenn auf `main`
|
||||||
|
bereits eine spätere Workflow-Korrektur liegt. Alle Quelladressen müssen dem
|
||||||
|
registrierten Ursprung entsprechen; Commit und annotiertes Tag-Objekt werden
|
||||||
|
für jeden Eintrag gebunden. Git-Ersetzungsobjekte oder fremde Git-Konfiguration
|
||||||
|
können dabei keinen anderen Manifestbaum unterschieben.
|
||||||
|
|
||||||
|
Vor dem Signieren werden der bestehende Website-Katalog und sein Schlüsselbund
|
||||||
|
geprüft. Ein alter Katalog ohne signierten Schlüsselbund-Hash darf ausschließlich
|
||||||
|
durch diesen vollständigen Neuaufbau migriert werden: Seine Signatur muss gültig
|
||||||
|
sein und der gesamte Schlüsselbund exakt den konfigurierten bekannten Signierern
|
||||||
|
entsprechen. Alte Einträge oder Artefakt-Hashes werden nicht übernommen. Der neue
|
||||||
|
Katalog bindet den unveränderten Schlüsselbund-Hash; ein Schlüsselwechsel bleibt
|
||||||
|
ein eigener geprüfter Vorgang. Selektive Kandidaten verlangen weiterhin einen
|
||||||
|
bereits gebundenen Schlüsselbund. Kandidaten werden nur in neuen privaten
|
||||||
|
Verzeichnissen erzeugt und niemals überschrieben.
|
||||||
|
|
||||||
|
Das obige Befehlsbeispiel wird auf dem vertrauenswürdigen Host ausgeführt. Dafür
|
||||||
|
die private Python-Umgebung und einen absoluten `--npm`-Pfad zu Node 22 verwenden;
|
||||||
|
unter Flatpak die Host-Werkzeuge über `flatpak-spawn --host` aufrufen. Die
|
||||||
|
gesperrten Website-Build-Abhängigkeiten vorher installieren. Keine
|
||||||
|
Vertrauensprüfung umgehen und keine globalen Rechte ändern. Die Artefaktordner
|
||||||
|
müssen privat und bei der Auflösung leer sein. Vor der Kandidatenerzeugung
|
||||||
|
müssen Quell-Tags und Registry-Pakete vollständig veröffentlicht sein.
|
||||||
|
|
||||||
|
Die Veröffentlichung zunächst mit `publish-candidate --commit --tag --push
|
||||||
|
--build-web` ohne `--apply` prüfen und erst nach Prüfung mit `--apply` ausführen.
|
||||||
|
Solange Laufzeit-Images zurückgestellt sind, bleibt auch das Meta-Gitea-Runtime-
|
||||||
|
Release zurückgestellt: Ein reines Quellcode-Release könnte sonst als neuestes
|
||||||
|
Release erscheinen. Eine Installation benötigt weiterhin ein signiertes
|
||||||
|
Laufzeitmanifest, dessen Digest und einen explizit vertrauenswürdigen Schlüsselbund.
|
||||||
+79
-5
@@ -8,7 +8,7 @@ receives a working base system. Re-running the same tool repairs or
|
|||||||
reconfigures that installation instead of creating unrelated state.
|
reconfigures that installation instead of creating unrelated state.
|
||||||
|
|
||||||
The canonical product journey remains
|
The canonical product journey remains
|
||||||
[System Administrator Lifecycle User Story](SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
[System Administrator Lifecycle User Story](../strategy/SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md).
|
||||||
This document defines the deployer boundary and the first executable slice.
|
This document defines the deployer boundary and the first executable slice.
|
||||||
The execution, topology, component-ownership and assurance modes are defined
|
The execution, topology, component-ownership and assurance modes are defined
|
||||||
canonically in [Deployment Profiles](DEPLOYMENT_PROFILES.md). In particular,
|
canonically in [Deployment Profiles](DEPLOYMENT_PROFILES.md). In particular,
|
||||||
@@ -99,6 +99,8 @@ The private installation directory contains:
|
|||||||
| `existing-proxy.json` | Exact upstream, trusted-source, header, and health contract for an operator-owned proxy |
|
| `existing-proxy.json` | Exact upstream, trusted-source, header, and health contract for an operator-owned proxy |
|
||||||
| `plan.json` | Latest desired-state diff and readiness findings |
|
| `plan.json` | Latest desired-state diff and readiness findings |
|
||||||
| `receipt.json` | Last successfully applied immutable identities |
|
| `receipt.json` | Last successfully applied immutable identities |
|
||||||
|
| `infrastructure-capabilities.json` | Deterministic non-secret capability states, endpoint metadata, secret references, consumers, and resumable post-install tasks |
|
||||||
|
| `infrastructure-dependency-inventory.json` | Owner-only, short-lived Ops evidence of actual module-owned configuration and data that depend on infrastructure capabilities |
|
||||||
| `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer |
|
| `distribution-manifest.json` | Canonical signed runtime/image selection adopted by the installer |
|
||||||
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
|
| `distribution-keyring.json` | Explicitly installed public trust anchor for runtime releases |
|
||||||
| `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt |
|
| `backup-evidence.json` | Signed provider-neutral coordinated backup and isolated-restore receipt |
|
||||||
@@ -110,7 +112,27 @@ The private installation directory contains:
|
|||||||
| `.deployment.lock` | Same-host operation exclusion |
|
| `.deployment.lock` | Same-host operation exclusion |
|
||||||
|
|
||||||
The specification contract is
|
The specification contract is
|
||||||
[`installation-spec.schema.json`](installation-spec.schema.json).
|
[`installation-spec.schema.json`](../installation-spec.schema.json).
|
||||||
|
|
||||||
|
The API, workers, scheduler, and Ops read the capability receipt through the
|
||||||
|
same bounded Core validator. Configuration-package providers receive that typed
|
||||||
|
receipt in preflight context. Mail uses `mail.smtp` to offer an idempotent SMTP
|
||||||
|
profile plan and accepts only an existing credential-envelope reference; Files
|
||||||
|
uses `files.storage` to prove that the deployment-owned local/S3 runtime binding
|
||||||
|
already matches. Files deliberately blocks drift instead of rewriting process
|
||||||
|
environment or initiating an implicit object migration. Invalid receipts fail
|
||||||
|
closed, while a deployment without a mounted receipt continues to run but
|
||||||
|
cannot apply receipt-bound configuration fragments.
|
||||||
|
|
||||||
|
Enabled modules may also register a Core infrastructure-dependency provider.
|
||||||
|
The authorized Ops endpoint aggregates those providers without importing their
|
||||||
|
tables. Mail reports persisted SMTP endpoints, credential-binding counts and
|
||||||
|
legacy profiles; Files reports its runtime storage binding plus persisted blob
|
||||||
|
counts and byte totals grouped by backend. Ops reports the active PostgreSQL,
|
||||||
|
Redis coordination, ingress, and load-balancing runtime bindings. Provider output contains stable
|
||||||
|
references, bounded numeric metrics and required migration actions, never
|
||||||
|
credentials, endpoint secrets, tenant identifiers or file keys. A provider
|
||||||
|
failure makes the entire inventory incomplete.
|
||||||
|
|
||||||
Build the same dependency-free tool as one downloadable artifact:
|
Build the same dependency-free tool as one downloadable artifact:
|
||||||
|
|
||||||
@@ -199,9 +221,9 @@ it can initialize a new volume; the actual HAProxy process retains the image's
|
|||||||
non-root identity and runs read-only with all capabilities dropped.
|
non-root identity and runs read-only with all capabilities dropped.
|
||||||
|
|
||||||
The manifest contract is
|
The manifest contract is
|
||||||
[`runtime-distribution-manifest.schema.json`](runtime-distribution-manifest.schema.json),
|
[`runtime-distribution-manifest.schema.json`](../runtime-distribution-manifest.schema.json),
|
||||||
and its separately distributed trust-anchor contract is
|
and its separately distributed trust-anchor contract is
|
||||||
[`runtime-distribution-keyring.schema.json`](runtime-distribution-keyring.schema.json).
|
[`runtime-distribution-keyring.schema.json`](../runtime-distribution-keyring.schema.json).
|
||||||
Publication is immutable: an existing Gitea release asset must have the same
|
Publication is immutable: an existing Gitea release asset must have the same
|
||||||
size and SHA-256 digest or publication fails.
|
size and SHA-256 digest or publication fails.
|
||||||
|
|
||||||
@@ -339,6 +361,15 @@ and WebUI API proxy traffic across API replicas. The WebUI and API services do
|
|||||||
not publish host ports. HAProxy has no Docker socket and discovers only the
|
not publish host ports. HAProxy has no Docker socket and discovers only the
|
||||||
bounded replica slots rendered into `load-balancer.cfg`.
|
bounded replica slots rendered into `load-balancer.cfg`.
|
||||||
|
|
||||||
|
New installation specifications default to HAProxy `3.2.23-alpine`, pinned to
|
||||||
|
registry index `sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e`.
|
||||||
|
This patch remains on HAProxy 3.2 LTS and Alpine 3.24.1. Loading an existing
|
||||||
|
specification preserves its explicit image; it does not perform an upgrade.
|
||||||
|
The [runtime remediation evidence](../security/RUNTIME_IMAGE_REMEDIATION_2026-09-08.md)
|
||||||
|
records both architecture scans and the pending binary/configuration, runtime,
|
||||||
|
inventory and final-image checks. The source default is not a release approval:
|
||||||
|
runtime publication remains held in Meta #52.
|
||||||
|
|
||||||
Replica counts are desired state:
|
Replica counts are desired state:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -408,16 +439,30 @@ the supported topology and promotion path.
|
|||||||
## Reconfiguration Semantics
|
## Reconfiguration Semantics
|
||||||
|
|
||||||
`installation.json` is desired state. `receipt.json` is the last successfully
|
`installation.json` is desired state. `receipt.json` is the last successfully
|
||||||
applied state. `plan` compares their canonical hashes and service sets.
|
applied state. `plan` compares their canonical hashes, service sets, and
|
||||||
|
infrastructure capability projections.
|
||||||
|
|
||||||
- Adding a managed component creates its service and persistent volume.
|
- Adding a managed component creates its service and persistent volume.
|
||||||
- Removing a component removes its service container on apply.
|
- Removing a component removes its service container on apply.
|
||||||
|
- Reconfiguring, replacing or removing a capability adds a review action that
|
||||||
|
names the prior and desired state/source, declared consumers, actual
|
||||||
|
provider-reported dependency records and each required migration action.
|
||||||
|
- The deployer blocks that change when provider inventory is missing,
|
||||||
|
incomplete, more than five minutes old, from another installation, timestamped
|
||||||
|
in the future, or does not cover every impacted capability. It never treats
|
||||||
|
installer-declared consumers as proof that persisted module state is absent.
|
||||||
|
- The inventory reports impact; it does not migrate or delete module-owned
|
||||||
|
configuration or data. Complete the reported preparation and collect again
|
||||||
|
immediately before apply.
|
||||||
- Volumes are retained by default; deleting data requires a separate,
|
- Volumes are retained by default; deleting data requires a separate,
|
||||||
deliberately destructive workflow.
|
deliberately destructive workflow.
|
||||||
- Existing generated credentials are retained unless an explicit future rotate
|
- Existing generated credentials are retained unless an explicit future rotate
|
||||||
operation is requested.
|
operation is requested.
|
||||||
- Private configuration changes are represented by a keyed fingerprint in the
|
- Private configuration changes are represented by a keyed fingerprint in the
|
||||||
plan and receipt; plaintext values are never copied there.
|
plan and receipt; plaintext values are never copied there.
|
||||||
|
- Capability documents contain sanitized scheme/host/port metadata and stable
|
||||||
|
`env:` references only. Credential values and secret-bearing URLs remain in
|
||||||
|
`secrets.env` or module-owned credential envelopes.
|
||||||
- Managed-to-external transitions require the new endpoint in the same
|
- Managed-to-external transitions require the new endpoint in the same
|
||||||
operation.
|
operation.
|
||||||
- Migrations run as a one-shot service before API/worker replacement.
|
- Migrations run as a one-shot service before API/worker replacement.
|
||||||
@@ -430,6 +475,35 @@ applied state. `plan` compares their canonical hashes and service sets.
|
|||||||
- Health must recover before a new receipt and applied-state snapshot are
|
- Health must recover before a new receipt and applied-state snapshot are
|
||||||
committed.
|
committed.
|
||||||
|
|
||||||
|
Compose mounts the capability document read-only into API and worker runtime
|
||||||
|
containers. The Kubernetes export projects the same document through a
|
||||||
|
dedicated ConfigMap and read-only file mount. Ops validates the bounded schema
|
||||||
|
before displaying configured, externally supplied, available-unconfigured, or
|
||||||
|
unavailable states and any pending post-install tasks.
|
||||||
|
|
||||||
|
Collect current dependency evidence with an API key whose principal has one of
|
||||||
|
the Ops read scopes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export GOVOPLAN_OPS_API_KEY='<short-lived operator API key>'
|
||||||
|
python3 govoplan-deploy.pyz collect-infrastructure-inventory \
|
||||||
|
--directory /srv/govoplan/example
|
||||||
|
python3 govoplan-deploy.pyz doctor \
|
||||||
|
--directory /srv/govoplan/example
|
||||||
|
python3 govoplan-deploy.pyz apply \
|
||||||
|
--directory /srv/govoplan/example
|
||||||
|
unset GOVOPLAN_OPS_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
The command defaults to
|
||||||
|
`<public-url>/api/v1/ops/infrastructure/dependencies`; `--ops-url` may select an
|
||||||
|
explicit HTTPS endpoint (plain HTTP is accepted only on loopback). `apply`
|
||||||
|
refreshes the inventory automatically when `GOVOPLAN_OPS_API_KEY` is present.
|
||||||
|
Otherwise an already collected, current inventory may be used. The API key is
|
||||||
|
sent only as `X-API-Key`, is never written to the bundle, and the inventory file
|
||||||
|
is owner-readable only. Because it contains operational references and counts,
|
||||||
|
handle it as private evidence even though it contains no secret material.
|
||||||
|
|
||||||
Every apply operation is journalled before image pulls or runtime mutation. A
|
Every apply operation is journalled before image pulls or runtime mutation. A
|
||||||
failure before migration may restore a verified previous bundle. Once migration
|
failure before migration may restore a verified previous bundle. Once migration
|
||||||
starts, recovery is forward-only unless an independently verified database
|
starts, recovery is forward-only unless an independently verified database
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Integrity and performance source release — September 2026
|
||||||
|
|
||||||
|
Coordinated tracking: [Core #298](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/298).
|
||||||
|
This source publication advances only the affected packages; it is not a signed
|
||||||
|
catalog publication, runtime-image release, or remote production rollout.
|
||||||
|
|
||||||
|
## Package set
|
||||||
|
|
||||||
|
| Package | Version |
|
||||||
|
| --- | --- |
|
||||||
|
| Core / developer meta-package | 0.1.46 |
|
||||||
|
| Addresses | 0.1.23 |
|
||||||
|
| Calendar | 0.1.24 |
|
||||||
|
| Campaign | 0.1.29 |
|
||||||
|
| Cases | 0.1.25 |
|
||||||
|
| Committee | 0.1.22 |
|
||||||
|
| Connectors | 0.1.27 |
|
||||||
|
| Dataflow | 0.1.25 |
|
||||||
|
| Datasources | 0.1.26 |
|
||||||
|
| Files | 0.1.27 |
|
||||||
|
| Forms Runtime | 0.1.22 |
|
||||||
|
| IDM | 0.1.26 |
|
||||||
|
| Mail | 0.1.28 |
|
||||||
|
| Reporting | 0.1.22 |
|
||||||
|
| Tickets | 0.1.23 |
|
||||||
|
|
||||||
|
Consumers of new Core helpers require Core 0.1.46 or later. Dataflow and
|
||||||
|
Datasources also require the matching Core WebUI contract. The release manifests,
|
||||||
|
immutable Git lock, and developer package describe this coordinated composition.
|
||||||
|
Unchanged packages retain their independent versions.
|
||||||
|
|
||||||
|
## Database and data integrity
|
||||||
|
|
||||||
|
The reviewed additive heads are Connectors `d2a4c6e8f0b1`, Datasources
|
||||||
|
`e2b8d4a0f6c3`, Files `a2b3c4d5e701`, and Mail `b5d6e7f8091a`.
|
||||||
|
Back up the target deployment and rehearse its normal upgrade before rollout.
|
||||||
|
Never downgrade away retained CSV originals without a separate recovery plan.
|
||||||
|
Files preserves historical duplicate copies; Mail leaves legacy maildrop identity
|
||||||
|
unset rather than guessing an account. A schema upgrade does not authorize POP3
|
||||||
|
retrieval/reconciliation, provider deletion, or message resending.
|
||||||
|
|
||||||
|
Development startup may automatically apply pending migrations after a watched
|
||||||
|
source change. Therefore, inspect actual Alembic heads and schema before assuming
|
||||||
|
a live development database is still at its pre-change state. A backup taken
|
||||||
|
after such an upgrade is a current-state recovery copy, not a pre-upgrade backup.
|
||||||
|
|
||||||
|
On 8 September, the local PostgreSQL development database already contained all
|
||||||
|
four heads. Schema and constraint inspection passed; recomputing the Files
|
||||||
|
identity backfill checked 7,385 rows with zero mismatches. A private current-state
|
||||||
|
database backup was created. Per-instance backup paths and row contents are not
|
||||||
|
published in this repository. The implementation's initial receipt claiming no
|
||||||
|
live migration occurred was incorrect: watched development-server restarts had
|
||||||
|
applied the migration files automatically. Restoring the backup into an isolated
|
||||||
|
PostgreSQL 16 cluster and running the normal upgrade preserved all 281 public
|
||||||
|
tables, 142,287 rows, and migration heads exactly. Both Datasources PostgreSQL
|
||||||
|
concurrency regressions passed. The test-only cluster was then stopped.
|
||||||
|
|
||||||
|
Release preparation additionally corrected Alembic's ConfigParser handling of
|
||||||
|
percent-escaped connection URLs, preserving the exact database URL. The static
|
||||||
|
migration auditor now recognizes the existing reviewed development-wrapper
|
||||||
|
aliases and peer filenames without executing migration code; historical
|
||||||
|
migrations are unchanged.
|
||||||
|
|
||||||
|
## Verification and boundaries
|
||||||
|
|
||||||
|
The implementation passed the required focused workspace gate, including 63
|
||||||
|
frontend build configurations and 230 browser conformance tests. Owning-module
|
||||||
|
regressions cover exact import/rollback evidence, authorization-before-pagination,
|
||||||
|
bounded recurrence and response processing, collision-safe attachment naming,
|
||||||
|
and stale asynchronous UI completion. English/German feature documentation stays
|
||||||
|
in each owning module; Core documents shared integrity contracts.
|
||||||
|
|
||||||
|
Mock-provider tests and local work-count measurements do not establish production
|
||||||
|
throughput or provider race behavior. Real S3/SMB/Seafile and POP3 acceptance,
|
||||||
|
representative load testing, and deployment authentication checks remain separate
|
||||||
|
operational validation. Source tags trigger package workflows; a pushed source
|
||||||
|
tag alone does not prove a registry artifact or signed catalog is published.
|
||||||
|
|
||||||
|
## Deutsch
|
||||||
|
|
||||||
|
Dieses koordinierte Quellrelease veröffentlicht nur die betroffenen Pakete.
|
||||||
|
Produktions-Images, signierter Modulkatalog und entfernte Produktivinstanzen
|
||||||
|
werden dadurch nicht ausgerollt. Die vier Migrationen bewahren bestehende Daten;
|
||||||
|
historische POP3-Zuordnungen werden nicht geraten. POP3 ist ein eigener
|
||||||
|
Import-Arbeitsablauf innerhalb von **Mail**, kein separat installierbares Modul.
|
||||||
|
|
||||||
|
Der Entwicklungsserver kann Migrationen beim automatischen Neustart nach einer
|
||||||
|
Quelländerung bereits anwenden. Tatsächliche Schema-Stände prüfen; eine danach
|
||||||
|
erstellte Sicherung enthält den aktuellen Stand und ist keine Sicherung vor dem
|
||||||
|
Upgrade. Externe Transportaktionen, erneutes Versenden und Löschungen werden
|
||||||
|
durch die Migration oder Quellveröffentlichung nicht ausgelöst.
|
||||||
@@ -0,0 +1,465 @@
|
|||||||
|
# Package Registry Releases
|
||||||
|
|
||||||
|
GovOPlaN publishes reusable module artifacts through Gitea's native PyPI and
|
||||||
|
npm registries. These packages improve developer installation, release
|
||||||
|
resolution, cacheability, and artifact inspection. They do not replace the
|
||||||
|
signed runtime distribution: the signed manifest and digest-pinned OCI images
|
||||||
|
remain the production deployment authority.
|
||||||
|
|
||||||
|
## Publication boundary
|
||||||
|
|
||||||
|
Every repository with a `pyproject.toml` contains
|
||||||
|
`.gitea/workflows/module-package-release.yml`. The meta repository owns the
|
||||||
|
canonical template and installs it with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/repo/sync-module-package-workflows.py --write
|
||||||
|
python tools/repo/sync-module-package-workflows.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow runs for `v*` tags and may be dispatched manually for an existing
|
||||||
|
tag. The organization preflight verifies that every package repository protects
|
||||||
|
the `v*` namespace. Before building, the workflow itself verifies that:
|
||||||
|
|
||||||
|
- the tagged commit is contained in `main`;
|
||||||
|
- the tag, Python project version, and optional WebUI package version agree;
|
||||||
|
- package names remain in the `govoplan-*` and `@govoplan/*-webui` namespaces.
|
||||||
|
|
||||||
|
The workflow binds the repository explicitly from the Gitea Actions context.
|
||||||
|
Do not rely on GitHub-compatible environment variables being injected by the
|
||||||
|
runner image; Gitea runners may expose only the context values. Gitea 1.24 job
|
||||||
|
tokens cannot read repository tag-protection settings, so package jobs must not
|
||||||
|
receive a broad administrator token merely to repeat the organization preflight.
|
||||||
|
Run the following before the first publication and after repository or tag-rule
|
||||||
|
changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/gitea/gitea-configure-package-releases.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Preview and dispatch the exact wheel/WebUI versions selected by the developer
|
||||||
|
meta-package with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/gitea/gitea-dispatch-package-set.py \
|
||||||
|
--env-file ~/.config/gitea/gitea.env
|
||||||
|
python tools/gitea/gitea-dispatch-package-set.py \
|
||||||
|
--env-file ~/.config/gitea/gitea.env \
|
||||||
|
--apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The dispatcher reads exact versions from `packages/govoplan-meta/pyproject.toml`,
|
||||||
|
inspects the selected tag to determine whether a WebUI package is expected,
|
||||||
|
skips complete registry pairs and does not duplicate an active workflow. Use
|
||||||
|
`--repository govoplan-core` for a bounded dispatch or `--verify-existing` to
|
||||||
|
rebuild and hash-verify versions already present in both registries.
|
||||||
|
|
||||||
|
For coordinated lockstep tags, `push-release-tag.sh` pushes module tags first,
|
||||||
|
Core next, and the meta tag last. This is a dependency guarantee for a
|
||||||
|
single-capacity Actions runner: the developer package cannot run before its
|
||||||
|
exact Core and module versions have entered the queue.
|
||||||
|
|
||||||
|
The same release entry point first validates the migration graph, then records
|
||||||
|
the reviewed current Alembic heads under the target release version and reruns
|
||||||
|
the strict migration audit before it changes package versions, commits, or
|
||||||
|
tags. The default preflight intentionally does not require those heads to exist
|
||||||
|
in the previous release baseline. A failed candidate-baseline check therefore
|
||||||
|
cannot produce a protected package release.
|
||||||
|
|
||||||
|
The source gate validates `pyproject.toml`, the module version declaration
|
||||||
|
(`MODULE_VERSION` or the top-level `ModuleManifest.version`), public package
|
||||||
|
`__version__`, and WebUI metadata before creating tags. Release-tag artifact
|
||||||
|
checks run only after the candidate tags and immutable WebUI lock have been
|
||||||
|
created locally.
|
||||||
|
|
||||||
|
Release-lock regeneration resolves a fresh immutable lock from the reviewed
|
||||||
|
candidate manifests; it does not seed resolution from the previous release
|
||||||
|
lock. This prevents removed transitive packages and stale peer metadata from
|
||||||
|
blocking or contaminating the new release. Candidate resolution also uses an
|
||||||
|
isolated temporary npm cache, so a locally replaced tag cannot reuse metadata
|
||||||
|
from a failed, unpushed release attempt.
|
||||||
|
|
||||||
|
Modules that retain the same WebUI package identity in both a root publish
|
||||||
|
manifest and `webui/package.json` use the WebUI manifest as the canonical peer
|
||||||
|
contract. The coordinated release synchronizes `peerDependencies` and
|
||||||
|
`peerDependenciesMeta` into the publish manifest before creating the module
|
||||||
|
tag, then synchronizes each lockfile root from the final package metadata. A
|
||||||
|
distinct root package remains independent.
|
||||||
|
|
||||||
|
Every module referenced by Core's Git-based `package.release.json` must expose
|
||||||
|
its WebUI identity at the repository root, including matching peer requirements
|
||||||
|
and `webui/`-prefixed entry exports (also CSS subpaths). npm resolves Git
|
||||||
|
dependencies from the repository root, while the native-package workflow packs
|
||||||
|
`webui/`; success in one path does not verify the other. Run
|
||||||
|
`python tools/checks/check-webui-package-facades.py` after changing either
|
||||||
|
manifest or the release composition. The focused gate also runs this check.
|
||||||
|
Adding or correcting a facade in an already published repository requires a
|
||||||
|
new patch tag; never repair an existing immutable tag in place.
|
||||||
|
|
||||||
|
It builds one wheel and, where applicable, one npm tarball. The workflow records
|
||||||
|
the source tag, source commit, filename, size, and SHA-256 in
|
||||||
|
`package-artifacts.json` before publishing. Gitea rejects a second upload of the
|
||||||
|
same package version, so correction requires a new version rather than artifact
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
A retry after partial publication is safe. Before upload, the workflow reads the
|
||||||
|
native package registry file record and compares its SHA-256 with the artifact
|
||||||
|
rebuilt from the protected tag. An exact existing artifact is skipped; a
|
||||||
|
same-version artifact with another digest or an unexpected file set fails
|
||||||
|
closed. This permits a failed npm publication to resume without weakening
|
||||||
|
package immutability or accepting `--skip-existing` blindly.
|
||||||
|
|
||||||
|
The npm tarball is always published through an explicit local `./dist/...`
|
||||||
|
path. Without that prefix, npm may interpret a relative tarball name as a Git
|
||||||
|
package shorthand before it ever contacts the configured registry.
|
||||||
|
|
||||||
|
Published WebUI packages contain registry-compatible dependencies only. The
|
||||||
|
workflow converts an internal dependency pinned to a protected `vX.Y.Z` Git tag
|
||||||
|
into the exact `X.Y.Z` registry version and rejects unresolved `file:` or Git
|
||||||
|
dependencies. Repository development metadata may therefore keep local or Git
|
||||||
|
references without leaking them into the published package contract.
|
||||||
|
Historical `add-ideas` and current `GovOPlaN` organization URLs are accepted
|
||||||
|
for immutable tagged releases; both normalize to the same exact registry
|
||||||
|
dependency and no branch or unversioned Git reference is accepted.
|
||||||
|
|
||||||
|
## One-time Gitea setup
|
||||||
|
|
||||||
|
Protect `v*` tags in every package repository and the meta repository. Allow
|
||||||
|
only the `Owners` team to create or delete those tags.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. ~/.config/gitea/gitea.env
|
||||||
|
set +a
|
||||||
|
python tools/gitea/gitea-configure-package-releases.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a dedicated personal access token with only `write:package` scope and
|
||||||
|
store these organization-level Actions secrets on `GovOPlaN`:
|
||||||
|
|
||||||
|
- `GOVOPLAN_PACKAGE_USERNAME`: account owning the package token;
|
||||||
|
- `GOVOPLAN_PACKAGE_TOKEN`: dedicated package-write token.
|
||||||
|
|
||||||
|
Do not use an administrator or general release token. Gitea 1.24 does not grant
|
||||||
|
package publication to the automatic Actions job token. Organization secrets
|
||||||
|
allow the same least-privilege credential to serve every module workflow.
|
||||||
|
|
||||||
|
## Exact release consumption
|
||||||
|
|
||||||
|
`tools/release/generate-release-package-set.py` supports two explicit package
|
||||||
|
profiles. `base` translates the reviewed roots in `requirements-release.txt`;
|
||||||
|
`full` reads the exact `govoplan[full]` dependency set from the developer
|
||||||
|
meta-package. Both profiles resolve every version tag to its commit and verify
|
||||||
|
the package metadata from that exact Git tree. The official module directory
|
||||||
|
and immutable runtime distribution use `full`, so every publicly released
|
||||||
|
module can be discovered without rebuilding the application image.
|
||||||
|
|
||||||
|
`tools/release/resolve-package-artifacts.py` then downloads exactly those wheel
|
||||||
|
and WebUI versions from Gitea. It reads the identity embedded in every wheel and
|
||||||
|
npm tarball, rejects missing, duplicate, unexpected, or oversized artifacts,
|
||||||
|
and writes `package-artifacts.lock.json` with credential-free HTTPS download
|
||||||
|
URLs, SHA-256 values, and npm registry integrity values. The resolver verifies
|
||||||
|
that the bytes downloaded by `npm pack` match the registry's own integrity
|
||||||
|
record. Credentials are accepted only through environment variables and are
|
||||||
|
never written to the lock. Python resolution ignores ambient pip configuration
|
||||||
|
and extra indexes for GovOPlaN roots, preventing an internal package name from
|
||||||
|
being selected from an undeclared registry.
|
||||||
|
|
||||||
|
The runtime distribution workflow uses the verified full-profile wheelhouse
|
||||||
|
directly and installs every selected module WebUI tarball only after matching
|
||||||
|
it to the lock. It publishes the package set, package lock, and hash-locked
|
||||||
|
requirements as release assets.
|
||||||
|
The WebUI installer receives the absolute runtime-build interpreter path so its
|
||||||
|
directory changes cannot escape the isolated release environment.
|
||||||
|
Gitea 1.24 dispatches this workflow from a branch, but that branch is only the
|
||||||
|
workflow implementation. The job fetches and peels the protected `v<version>`
|
||||||
|
tag explicitly and materializes both `requirements-release.txt` and the
|
||||||
|
developer meta-package from that Git tree. It then binds the signed distribution
|
||||||
|
source and Gitea release assets to the same exact commit. A post-tag workflow
|
||||||
|
repair can therefore retry publication without changing the released package
|
||||||
|
composition or relabelling the later branch commit as released source.
|
||||||
|
The package-lock SHA-256 is part of the signed distribution manifest. Runtime
|
||||||
|
finalization also requires the lock's package versions and hashes to match the
|
||||||
|
wheel composition embedded in the images. OCI assembly remains network-free
|
||||||
|
after package and third-party dependency resolution.
|
||||||
|
|
||||||
|
The source refs remain in the module catalog for source provenance and release
|
||||||
|
planning. Production installation consumes the signed runtime images rather
|
||||||
|
than invoking `pip`, `npm`, or Git on the target host.
|
||||||
|
|
||||||
|
## Public module directory
|
||||||
|
|
||||||
|
For an operator-reviewed full publication, use
|
||||||
|
`tools/release/release-catalog.py full-registry` followed by the same tool's
|
||||||
|
`publish-candidate` command. Resolve the package set and registry lock first;
|
||||||
|
the older direct-write shell wrapper is not the strict candidate publication
|
||||||
|
path. See [Full registry candidates / Vollständige Registry-Kandidaten](FULL_REGISTRY_CANDIDATES.md)
|
||||||
|
for the private host runtime, exact artifact checks, and legacy keyring transition.
|
||||||
|
Catalog entries are synthesized from
|
||||||
|
the exact tagged module manifests, never from a hand-maintained module list or
|
||||||
|
the current workspace. Each entry binds its Python wheel and optional WebUI
|
||||||
|
tarball to the registry URL, filename, size, SHA-256, package identity, source
|
||||||
|
tag, and source commit before the complete catalog is signed.
|
||||||
|
|
||||||
|
The same publication transaction regenerates and prunes the browsable static
|
||||||
|
directory under `public/catalogs/v1/modules/`. It writes a global
|
||||||
|
`modules/index.json`, one `<module>/index.json`, and one
|
||||||
|
`<module>/<version>/manifest.json` for every entry in the signed channel.
|
||||||
|
These files are derived from that exact signed payload and keyring; stale JSON
|
||||||
|
from an older partial catalog is removed while unrelated static assets are left
|
||||||
|
untouched. The signed channel remains the trust anchor, while the module
|
||||||
|
directory provides stable discovery URLs for browsers and external tooling.
|
||||||
|
|
||||||
|
Official GovOPlaN modules are open-source directory entries and do not require
|
||||||
|
license entitlements. The generic `license_features` contract remains available
|
||||||
|
for third-party package directories, support/configuration packages, or future
|
||||||
|
deployment-specific presets. A catalog entry is gated only when that entry
|
||||||
|
explicitly declares such features.
|
||||||
|
|
||||||
|
Core carries the public stable catalog URL and its independently pinned trust
|
||||||
|
anchor. In the absence of an operator-configured catalog, Admin discovers the
|
||||||
|
official directory automatically. Selecting an entry creates a reviewed
|
||||||
|
install/update plan; the trusted installer downloads the exact signed artifacts
|
||||||
|
into a private digest cache, verifies size and hash, and installs only from that
|
||||||
|
cache. A saved plan is rejected if any package ref, artifact identity, catalog
|
||||||
|
channel, sequence, or signing-key identity differs from the currently validated
|
||||||
|
catalog.
|
||||||
|
|
||||||
|
The Admin directory can be searched by module, package, repository, or tag and
|
||||||
|
filtered by available, installed, update, and blocked/withdrawn states. It
|
||||||
|
shows the source revision, artifact digest, release notes, and configuration
|
||||||
|
requirements. Missing dependency/interface providers and unsupported update
|
||||||
|
windows are surfaced before an operator adds the entry to a plan; installer
|
||||||
|
preflight remains authoritative.
|
||||||
|
|
||||||
|
Catalog entries also carry the permission definitions declared by the tagged
|
||||||
|
module manifest. Admin groups and exposes their scopes before an install or
|
||||||
|
update is planned. This is disclosure only: installing a module does not grant
|
||||||
|
its permissions to an account, role, group, tenant, or service account.
|
||||||
|
|
||||||
|
Package lifecycle and availability are intentionally separate:
|
||||||
|
|
||||||
|
- install, update, and uninstall change the instance-wide package composition;
|
||||||
|
- enable and disable change the active instance runtime graph;
|
||||||
|
- tenant module entitlements define unavailable, available, and forced modules;
|
||||||
|
- group/user presentation is governed through Views and Policy; and
|
||||||
|
- enabling a capability module does not opt data into that capability.
|
||||||
|
|
||||||
|
Single-process or single-host installations may execute a supervised package
|
||||||
|
plan locally. Shared-state and Kubernetes profiles reject node-local package
|
||||||
|
mutation: operators compose and roll out a new signed full-profile runtime image
|
||||||
|
instead. This prevents replicas from drifting while retaining the same Admin
|
||||||
|
catalog and preflight experience.
|
||||||
|
|
||||||
|
## Developer meta-package
|
||||||
|
|
||||||
|
`packages/govoplan-meta` builds the optional `govoplan` package. Its default
|
||||||
|
dependencies mirror the reviewed runtime roots; `govoplan[full]` adds all
|
||||||
|
currently packageable workspace modules. Regenerate it after changing release
|
||||||
|
requirements or package versions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/release/generate-developer-meta-package.py
|
||||||
|
python tools/release/generate-developer-meta-package.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
The direct generator is a development synchronization tool, not a receipt-gated
|
||||||
|
release executor. For release preparation, use the guarded out-of-run stage below.
|
||||||
|
|
||||||
|
`push-release-tag.sh` performs this synchronization before release commits and
|
||||||
|
tags. The meta-package is for editable/developer setup and composition tests. It
|
||||||
|
does not enable modules, apply migrations, provision services, or establish
|
||||||
|
backup and recovery evidence.
|
||||||
|
|
||||||
|
### Shared source-tag contract and Meta composition
|
||||||
|
|
||||||
|
The shared version collector names Meta's real
|
||||||
|
`packages/govoplan-meta/pyproject.toml` separately from root `pyproject.toml`.
|
||||||
|
Only the registered `govoplan` system/meta repository with nested project name
|
||||||
|
`govoplan` receives this contract. Missing, unknown, or misidentified metadata
|
||||||
|
does not become a versionless exception. Version alignment compares the complete
|
||||||
|
nested file with the canonical operator-tool generator output: its version must
|
||||||
|
match Core, and dependencies and `full` composition must match the reviewed
|
||||||
|
requirements and workspace package versions. Validation never executes a
|
||||||
|
generator from a selected checkout. The shared trusted manifest checker can
|
||||||
|
load reviewed application manifests; these checks are not a code sandbox.
|
||||||
|
|
||||||
|
Meta's complete generated file is recognized by shared version-mutation discovery,
|
||||||
|
but the generic durable version executor deliberately cannot write it. A durable
|
||||||
|
run freezes the release console's own Meta checkout as trusted runtime code;
|
||||||
|
changing it in place would invalidate that run. The planner therefore places Meta
|
||||||
|
after Core and exposes only non-executable support preparation/publication steps,
|
||||||
|
not misleading automatic Meta version, commit, tag, or push actions. A missing or
|
||||||
|
different Core target produces an actionable preparation prerequisite.
|
||||||
|
|
||||||
|
Prepare Core and the intended module inputs first, commit their reviewed state,
|
||||||
|
then stop active durable runs for the target workspace. Use trusted operator tools
|
||||||
|
against a separate registered, private source checkout, never the running operator
|
||||||
|
Meta directory. Preview outside any selected source checkout, for example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python tools/release/prepare-developer-meta-package.py \
|
||||||
|
--workspace /private/release-workspace --target-version X.Y.Z \
|
||||||
|
> /private/operator/meta-preview.json
|
||||||
|
python tools/release/prepare-developer-meta-package.py \
|
||||||
|
--workspace /private/release-workspace --target-version X.Y.Z \
|
||||||
|
--receipt /private/operator/meta-preview.json --apply --confirm-out-of-run
|
||||||
|
```
|
||||||
|
|
||||||
|
The explicit confirmation attests that no durable run is active for that target
|
||||||
|
workspace; the helper does not discover or stop other processes. Preview/apply
|
||||||
|
requires registered clean main sources, matching origins and live-main ancestry,
|
||||||
|
Core already aligned at the target, the exact nested identity, and no existing or
|
||||||
|
unverifiable target Meta tag. Frozen receipts cover source HEADs/filesystem
|
||||||
|
identities, release requirements, every discovered registered full-composition
|
||||||
|
pyproject, the trusted generator snapshot, and the resulting full-file hash.
|
||||||
|
Inputs are limited to 128 selected files, 2 MiB per file and 16 MiB aggregate.
|
||||||
|
The canonical generator renders copied bounded data in a temporary directory;
|
||||||
|
no generator from the selected checkout executes. Changed receipts block before
|
||||||
|
the file effect. Apply writes only `packages/govoplan-meta/pyproject.toml`, then
|
||||||
|
rechecks the other sources and exact output. A write or post-check failure that
|
||||||
|
may have changed the file reports `needs-reconciliation` and leaves that bounded
|
||||||
|
delta for explicit review; it never retries, rolls back, commits or publishes.
|
||||||
|
|
||||||
|
Review the complete generated composition and manually commit the resulting file.
|
||||||
|
Complete matching Core publication before guarded Meta source tagging/publication,
|
||||||
|
then start a fresh durable run from reviewed, clean, published operator tooling.
|
||||||
|
Hot self-updating durable Meta release execution remains explicitly unsupported;
|
||||||
|
this out-of-run preparation is the existing developer-meta support contract.
|
||||||
|
|
||||||
|
For every `tag_repositories` batch, strict checks apply to every selected
|
||||||
|
repository before any tag creation, fetch, or push: registered checkout and
|
||||||
|
origin/push URL, a clean `main` tracking `origin/main`, live remote-main ancestry,
|
||||||
|
exact frozen HEADs, and immutable annotated local/remote tag objects. Git metadata
|
||||||
|
must remain inside the operator's trusted workspace. Missing local knowledge of
|
||||||
|
live remote main is a blocker; fetch and review it separately. Unknown repositories
|
||||||
|
and non-registered remote aliases fail closed. If selected, Meta runs last.
|
||||||
|
For Meta only, the matching annotated Core release must exist before its effect; Core may be
|
||||||
|
an earlier selected repository, or an already tagged dependency. Publication
|
||||||
|
requires that Core's exact tag and main commit are already remote.
|
||||||
|
|
||||||
|
Non-Meta selections do not acquire Meta's composition or Core-tag prerequisite.
|
||||||
|
Local module-candidate tags still work before Core's final release lock or tag.
|
||||||
|
The existing Core WebUI bundle gate still applies to module publication and
|
||||||
|
batches selecting Core: relevant Core release-package and release-lock inputs
|
||||||
|
must be operator-owned regular files, at most 16 MiB each, and their identities
|
||||||
|
and content hashes are frozen before preflight and rechecked before every effect.
|
||||||
|
When Core is unselected, this does not require its checkout to be clean or tagged;
|
||||||
|
reviewed pending composition inputs retain their previous meaning. Backend-only
|
||||||
|
selections never read irrelevant Core WebUI files.
|
||||||
|
|
||||||
|
Before even read-only Git commands, source ancestry must be owned by root or the
|
||||||
|
current operator and must not be group/world writable. A sticky shared ancestor
|
||||||
|
such as `/tmp` is permitted only above an owned, protected child; the workspace
|
||||||
|
and checkouts receive no writable-directory exception. The current operator must
|
||||||
|
own source inputs and actual Git/worktree/common metadata, which must be regular
|
||||||
|
files/directories, non-symlinked, and non-writable by other users. Metadata walks
|
||||||
|
are bounded to 500,000 entries and 128 levels, and tracked inputs to 100,000 paths
|
||||||
|
and 16 MiB of listing text. Read-only Git targets, object alternates/grafts and
|
||||||
|
hidden/sparse/unmerged index entries are blocked. Frozen receipts include actual
|
||||||
|
checkout/Git directory paths, devices, inodes, owners and modes, so replacing Git
|
||||||
|
metadata with the same HEAD is still detected. All selected version/composition
|
||||||
|
inputs must be tracked, including root and WebUI package/lock metadata, discovered
|
||||||
|
module manifests and package initializers, and Meta's nested package and release
|
||||||
|
requirements; ignored working files cannot supply declarations absent from a tag.
|
||||||
|
No chmod, ownership repair or
|
||||||
|
global Git trust change is performed. A shared writable workspace must first be
|
||||||
|
recreated or reviewed in the operator's protected release area by an explicitly
|
||||||
|
authorized preparation workflow.
|
||||||
|
|
||||||
|
Preview is read-only. Local-tag mode creates only pinned annotated tags (or
|
||||||
|
retrieves an identical published annotation); it does not publish main or tags.
|
||||||
|
Publish mode atomically pushes the frozen main commit and annotation object,
|
||||||
|
without force, retagging, fallback, or automatic retry. The complete source
|
||||||
|
receipt is rechecked before every effect and afterward; remote main and the
|
||||||
|
exact annotated tag must both match, not merely the Git exit status. Changes
|
||||||
|
after preflight stop the remaining batch. Atomicity is per repository, not
|
||||||
|
across repositories: earlier successful publications and a newly created local
|
||||||
|
tag can remain after a later failure. Inspect reported receipts and obtain a new
|
||||||
|
review before retrying; do not move immutable tags.
|
||||||
|
|
||||||
|
Whole-batch revalidation deliberately repeats source and live-remote checks around
|
||||||
|
each repository effect; the number of checks can grow quadratically with batch
|
||||||
|
size. Plan release time accordingly rather than bypassing trust checks. The
|
||||||
|
shared internal preflight is read-only and exposes no legacy mutation path.
|
||||||
|
The fixture suite covers Meta and non-Meta preview/local-tag/
|
||||||
|
publication using temporary local bare remotes, including stale compositions,
|
||||||
|
unsafe origins, divergent branches, damaged tag identity, changed receipts and
|
||||||
|
false publication success. This is local tooling evidence, not a real release
|
||||||
|
publication or production permission check.
|
||||||
|
|
||||||
|
Deutsch: Die gemeinsamen Helfer erkennen ausschließlich das registrierte
|
||||||
|
Meta-Repository mit dem echten Paket `packages/govoplan-meta/pyproject.toml`
|
||||||
|
(Projektname `govoplan`). Version und vollständige Zusammensetzung müssen dem
|
||||||
|
kanonischen Generator, Core und den geprüften Anforderungen entsprechen; der
|
||||||
|
Generator stammt niemals aus dem ausgewählten Checkout. Der gemeinsame
|
||||||
|
Manifestprüfer kann geprüften Anwendungscode laden und ist keine Sandbox.
|
||||||
|
Die gemeinsame Änderungsplanung erkennt die vollständig generierte Paketdatei,
|
||||||
|
aber der dauerhafte Versionsausführer darf Meta nicht selbst verändern: sein
|
||||||
|
eingefrorener Lauf bindet den Meta-Checkout als vertrauenswürdigen Programmstand.
|
||||||
|
Meta erscheint deshalb nach Core ausschließlich mit nicht automatisch ausführbaren
|
||||||
|
Vorbereitungs-/Veröffentlichungsschritten. Zuerst Core und Modulquellen vorbereiten
|
||||||
|
und geprüft committen; bei abweichender Core-Zielversion nennt der Plan diese
|
||||||
|
Voraussetzung ausdrücklich. Aktive dauerhafte Läufe des Ziel-Workspaces beenden.
|
||||||
|
Mit `prepare-developer-meta-package.py` zunächst eine Vorschau außerhalb der
|
||||||
|
Quell-Checkouts speichern, dann deren JSON über `--receipt` zusammen mit `--apply`
|
||||||
|
und `--confirm-out-of-run` bestätigen. Das Ziel muss ein separater registrierter
|
||||||
|
privater Checkout sein, niemals das laufende Operator-Meta. Die Bestätigung ist
|
||||||
|
eine Betreibererklärung; der Helfer sucht oder beendet keine fremden Prozesse.
|
||||||
|
Quell-HEADs, Pfadidentitäten, Anforderungen, alle registrierten vollständigen
|
||||||
|
Paket-Eingaben, der vertrauenswürdige Generator und der vollständige Ausgabehash
|
||||||
|
werden eingefroren. Es gelten höchstens 128 Quelldateien, 2 MiB je Datei und
|
||||||
|
16 MiB insgesamt. Core muss bereits vollständig zur Zielversion passen; vorhandene
|
||||||
|
oder nicht verifizierbare Meta-Zieltags sperren die Vorbereitung. Geänderte
|
||||||
|
Nachweise stoppen vor dem Schreiben. Ausschließlich die verschachtelte Paketdatei
|
||||||
|
wird vollständig generiert und danach geprüft; ein Fehler nach dem Schreiben
|
||||||
|
meldet `needs-reconciliation` und erfordert die manuelle Prüfung dieser begrenzten
|
||||||
|
Änderung, ohne automatisches Zurücksetzen. Kein automatischer
|
||||||
|
Commit, Push oder Wiederholungsversuch findet statt. Zusammensetzung prüfen,
|
||||||
|
manuell committen, Core zuerst veröffentlichen, dann die geschützte Meta-Tag-Route
|
||||||
|
verwenden und einen neuen dauerhaften Lauf starten. Eine Selbstaktualisierung
|
||||||
|
des aktiven dauerhaften Meta-Laufs bleibt ausdrücklich nicht unterstützt.
|
||||||
|
Für jeden Tag-Stapel, auch ohne Meta, gelten
|
||||||
|
Vertrauens-, Origin-, saubere Main- und Live-Abstammungsprüfungen für die gesamte
|
||||||
|
Auswahl vor jeder Änderung. Unbekannte Repositories und nicht registrierte
|
||||||
|
Remote-Aliase sind gesperrt. Nur bei ausgewähltem Meta gelten zusätzlich dessen
|
||||||
|
Zusammensetzungsprüfung und der passende annotierte Core-Tag als Voraussetzung;
|
||||||
|
Meta folgt zuletzt. Für Metas Veröffentlichung müssen Core-Tag und Main-Commit
|
||||||
|
bereits auf dem Remote vorliegen. Lokale Modul-Kandidatentags bleiben vor Cores
|
||||||
|
abschließendem Release-Lock und Tag möglich. Die vorhandene Core-WebUI-Bundleprüfung
|
||||||
|
bleibt bei Modulveröffentlichung und Core-Auswahl erhalten. Relevante Core-Paket-
|
||||||
|
und Lockdateien müssen eigene reguläre Dateien mit höchstens je 16 MiB sein;
|
||||||
|
Identität und Inhaltshash werden eingefroren und vor jeder Aktion erneut geprüft.
|
||||||
|
Nicht ausgewähltes Core benötigt dafür weder einen sauberen Checkout noch einen
|
||||||
|
Tag. Reine Backend-Auswahlen lesen keine irrelevanten Core-WebUI-Dateien.
|
||||||
|
Vor Git-Aufrufen werden Eigentümer, Schreibrechte, sichere
|
||||||
|
Pfadabstammung und echte Git-/Worktree-Metadaten geprüft; veränderbare gemeinsame
|
||||||
|
Verzeichnisse, fremde Eigentümer, Alternates, Grafts und versteckte Indexeinträge
|
||||||
|
sind gesperrt. Ein Sticky-Bit-Vorfahr wie `/tmp` ist nur oberhalb eines eigenen
|
||||||
|
geschützten Unterverzeichnisses zulässig. Es erfolgen weder Rechtereparaturen
|
||||||
|
noch globale Git-Vertrauensänderungen. Ausgewählte Versions- und Zusammensetzungs-
|
||||||
|
dateien müssen versioniert sein: Paket-/Lockdateien, Modulmanifeste und
|
||||||
|
Paketinitialisierer sowie Metas verschachteltes Paket und Release-Anforderungen.
|
||||||
|
Ignorierte Arbeitsdateien dürfen keine vom Tag abweichenden Angaben liefern.
|
||||||
|
Die Vorschau schreibt nichts, lokale Tags veröffentlichen nichts,
|
||||||
|
und die Veröffentlichung überträgt Main und den exakten annotierten Tag atomar
|
||||||
|
je Repository. Unmittelbar vor und nach den Aktionen werden die eingefrorenen
|
||||||
|
Quellnachweise erneut geprüft, einschließlich entferntem Main und Tag-Objekt.
|
||||||
|
Bei Änderungen oder Fehlern stoppt der Rest des Stapels ohne automatischen
|
||||||
|
Wiederholungsversuch. Frühere Veröffentlichungen und neu erzeugte lokale Tags
|
||||||
|
können bestehen bleiben: vor einem neuen Versuch Nachweise prüfen und erneut
|
||||||
|
freigeben, niemals unveränderliche Tags verschieben. Die vollständigen Quell- und
|
||||||
|
Live-Remote-Prüfungen werden um jede Aktion wiederholt; bei großen Stapeln kann
|
||||||
|
deren Anzahl quadratisch wachsen. Diese konservativen Prüfkosten gehören zur
|
||||||
|
Release-Planung. Der interne Vorprüfer ist ausschließlich lesend und besitzt
|
||||||
|
keinen alten Änderungspfad. Tests für Auswahlen mit und ohne Meta verwenden
|
||||||
|
nur temporäre lokale Remotes und ersetzen keine echte Veröffentlichungsprüfung.
|
||||||
|
|
||||||
|
If the tag-triggered developer meta-package job fails before publication, rerun
|
||||||
|
`publish-developer-meta-package.yml` with the existing protected version. The
|
||||||
|
manual path validates that tag against `main`, checks out its exact commit, and
|
||||||
|
publishes only when the registry does not already contain the same wheel hash.
|
||||||
|
|
||||||
|
Generic Packages are intentionally not used. Add that transport only when a
|
||||||
|
consumer needs an artifact format unsupported by PyPI, npm, Gitea Releases, or
|
||||||
|
the OCI registry.
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The Core recovery ledger is a platform primitive, not automatic protection for
|
The Core recovery ledger is a platform primitive, not automatic protection for
|
||||||
module-owned effects. The canonical, machine-checked inventory is
|
module-owned effects. The canonical, machine-checked inventory is
|
||||||
[`recovery-operation-inventory.json`](recovery-operation-inventory.json).
|
[`recovery-operation-inventory.json`](../recovery-operation-inventory.json).
|
||||||
|
|
||||||
## Classification Rules
|
## Classification Rules
|
||||||
|
|
||||||
@@ -58,6 +58,45 @@ checkouts remain usable for read-only planning, but every durable executor
|
|||||||
fails closed there; clone the registered origins into a private workspace
|
fails closed there; clone the registered origins into a private workspace
|
||||||
before releasing.
|
before releasing.
|
||||||
|
|
||||||
|
For a host with a confirmed IPv6 connection timeout, set
|
||||||
|
`GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` only for the release-tool invocation.
|
||||||
|
When unset, the original SSH command is preserved, including the trusted
|
||||||
|
operator's per-host `AddressFamily` configuration (normally `any`). Explicit
|
||||||
|
values accepted by the shared source/tag Git helper are exactly `any`, `inet`
|
||||||
|
(IPv4 only), and `inet6` (IPv6 only). Empty, misspelled, whitespace-padded, or
|
||||||
|
injected values fail before Git starts. The selector only adds the corresponding
|
||||||
|
fixed SSH `AddressFamily` option: it does not change DNS, host-key verification,
|
||||||
|
the registered remote, authentication, `BatchMode=yes`, or `ConnectTimeout=8`.
|
||||||
|
Arbitrary `GIT_SSH_COMMAND` overrides remain ignored. For example, start a
|
||||||
|
single local console invocation with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet \
|
||||||
|
./.venv/bin/python tools/release/release-console.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The same process-scoped setting applies to canonical source/tag readbacks and
|
||||||
|
registry-candidate source verification. Under Flatpak, pass it explicitly to
|
||||||
|
the host invocation with `flatpak-spawn --host /usr/bin/env
|
||||||
|
GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet ...`. It is not a global SSH setting
|
||||||
|
and does not affect the website publisher's separate transport sanitizer, npm,
|
||||||
|
or HTTP downloads. An IPv4-only setting cannot reach IPv6-only hosts; omit it
|
||||||
|
or use `any` when the diagnosed restriction no longer applies.
|
||||||
|
|
||||||
|
Deutsch: Bei einem bestätigten IPv6-Verbindungs-Timeout kann für genau einen
|
||||||
|
Release-Werkzeugaufruf `GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY=inet` gesetzt werden.
|
||||||
|
Ohne diese Variable bleibt der bisherige SSH-Befehl einschließlich der
|
||||||
|
vertrauenswürdigen Host-Konfiguration unverändert (normalerweise `any`).
|
||||||
|
Explizit zulässig sind ausschließlich `any`, `inet` (nur IPv4) und `inet6`
|
||||||
|
(nur IPv6). Andere oder leere Werte werden vor dem Git-Aufruf abgewiesen.
|
||||||
|
DNS, Hostschlüsselprüfung, registrierte Quelladresse, Authentifizierung und
|
||||||
|
Zeitlimit bleiben unverändert; frei vorgegebene SSH-Befehle bleiben gesperrt.
|
||||||
|
Unter Flatpak die Variable ausdrücklich an den Host-Aufruf übergeben. Die
|
||||||
|
Auswahl gilt für den gemeinsamen Git-Helfer der Quell-/Tag-Prüfungen, nicht
|
||||||
|
für den separaten Website-Publisher, npm oder HTTP-Downloads. Sie ändert keine
|
||||||
|
globale Konfiguration. Nach Behebung des Netzwerkproblems die Variable
|
||||||
|
weglassen oder auf `any` setzen; IPv4-only erreicht keine IPv6-only-Ziele.
|
||||||
|
|
||||||
The runtime itself is part of the authority boundary. Durable run creation
|
The runtime itself is part of the authority boundary. Durable run creation
|
||||||
verifies the meta checkout, release/check tooling, repository registry, Python
|
verifies the meta checkout, release/check tooling, repository registry, Python
|
||||||
environment, loaded `govoplan_core` and cryptography packages, and Git/SSH
|
environment, loaded `govoplan_core` and cryptography packages, and Git/SSH
|
||||||
@@ -221,6 +260,9 @@ Repository capabilities are frozen into each plan unit (`python-package`,
|
|||||||
`core-release-bundle`, and the universal `git-source`) and determine which
|
`core-release-bundle`, and the universal `git-source`) and determine which
|
||||||
steps appear. Internally aligned version changes are rendered deterministically
|
steps appear. Internally aligned version changes are rendered deterministically
|
||||||
from recognized TOML, JSON, lockfile, manifest, and package declarations.
|
from recognized TOML, JSON, lockfile, manifest, and package declarations.
|
||||||
|
The manifest may use a literal version or a top-level literal `MODULE_VERSION`;
|
||||||
|
the latter is updated without rewriting independently versioned interfaces.
|
||||||
|
Computed or missing version declarations fail before any metadata is written.
|
||||||
Pre-existing dirty worktrees remain visible but have no commit executor; the
|
Pre-existing dirty worktrees remain visible but have no commit executor; the
|
||||||
console never absorbs unrelated operator changes.
|
console never absorbs unrelated operator changes.
|
||||||
|
|
||||||
@@ -232,6 +274,17 @@ runs a receipt-bound alignment gate before exposing any atomic branch/tag push.
|
|||||||
A failed step stops later steps while preserving prior receipts for explicit
|
A failed step stops later steps while preserving prior receipts for explicit
|
||||||
retry or reconciliation.
|
retry or reconciliation.
|
||||||
|
|
||||||
|
Local module candidate creation deliberately does not require those candidates
|
||||||
|
to be resolved already in Core's release lock: their annotated tags are inputs
|
||||||
|
to the next lock-generation step. The internal tag helper applies this ordering
|
||||||
|
only when no Core repository is selected and remote publication is disabled.
|
||||||
|
Module version/lock consistency, manifest validity, clean/non-behind worktrees,
|
||||||
|
and local/remote tag immutability checks still apply. Core candidate tagging
|
||||||
|
continues to validate its own complete bundle, and every remote-publication
|
||||||
|
preview and execution requires the selected modules to match Core's release
|
||||||
|
input and resolved lock. A local candidate is therefore not publication
|
||||||
|
approval; a stale Core lock blocks publication without changing remote refs.
|
||||||
|
|
||||||
The browser likewise retains the request identifier for an uncertain
|
The browser likewise retains the request identifier for an uncertain
|
||||||
resume/retry/reconciliation response and replays it after reload. A successful
|
resume/retry/reconciliation response and replays it after reload. A successful
|
||||||
replay selects the returned run state. Transport and server failures retain the
|
replay selects the returned run state. Transport and server failures retain the
|
||||||
@@ -503,6 +556,15 @@ tree and requires byte-for-byte equality with those validated objects. Tags and
|
|||||||
remote branch updates then reference that exact commit SHA rather than the
|
remote branch updates then reference that exact commit SHA rather than the
|
||||||
mutable worktree `HEAD`.
|
mutable worktree `HEAD`.
|
||||||
|
|
||||||
|
For a full registry-backed release, first build a fresh private candidate using
|
||||||
|
`release-catalog.py full-registry`. Pass `--selected-repository` for newly
|
||||||
|
released HEAD-bound units, not every unchanged package in the full profile.
|
||||||
|
The command independently checks all full-profile registry bytes and annotated
|
||||||
|
tag provenance, then feeds this same strict `publish-candidate` transaction.
|
||||||
|
It does not create Gitea runtime Releases or dispatch image builds. See
|
||||||
|
[Full registry candidates / Vollständige Registry-Kandidaten](FULL_REGISTRY_CANDIDATES.md)
|
||||||
|
for the complete EN/DE workflow and the narrowly scoped legacy keyring transition.
|
||||||
|
|
||||||
Published channels are expected below the public catalog base URL:
|
Published channels are expected below the public catalog base URL:
|
||||||
|
|
||||||
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
- `https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`
|
||||||
@@ -515,8 +577,9 @@ updated catalog, and keep the published keyring healthy.
|
|||||||
|
|
||||||
When a selected module exposes a WebUI package, its requested version must also
|
When a selected module exposes a WebUI package, its requested version must also
|
||||||
match Core's `webui/package.release.json` input and the resolved
|
match Core's `webui/package.release.json` input and the resolved
|
||||||
`package-lock.release.json` entry. The source-tag preflight, selective plan, and
|
`package-lock.release.json` entry. The source-publication preflight, selective
|
||||||
catalog-candidate writer all enforce this composition boundary. Pins for modules
|
plan, and catalog-candidate writer all enforce this composition boundary;
|
||||||
|
module-only local candidate tags use the staged order described above. Pins for modules
|
||||||
that are not part of the selective release remain unchanged.
|
that are not part of the selective release remain unchanged.
|
||||||
|
|
||||||
Release integration also enforces repository and composition version alignment
|
Release integration also enforces repository and composition version alignment
|
||||||
+1
-1
@@ -277,4 +277,4 @@ test against the target ingress controller and network implementation.
|
|||||||
This proves the bounded stateless-node-loss slice only. Session continuity,
|
This proves the bounded stateless-node-loss slice only. Session continuity,
|
||||||
accepted-job redelivery, state-service failover, and coordinated restore remain
|
accepted-job redelivery, state-service failover, and coordinated restore remain
|
||||||
separate target exercises whose signed evidence is governed by
|
separate target exercises whose signed evidence is governed by
|
||||||
`docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md` and GovOPlaN #37.
|
`docs/operations/TARGET_MATURITY_EVIDENCE_RUNBOOK.md` and GovOPlaN #37.
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# WebUI release dependency installer retries
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This operational note covers
|
||||||
|
[`install-webui-release-dependencies.sh`](../../tools/release/install-webui-release-dependencies.sh)
|
||||||
|
and the exit-status repair tracked in
|
||||||
|
[Meta #54](https://git.add-ideas.de/GovOPlaN/govoplan/issues/54).
|
||||||
|
It applies to release administrators using the legacy runtime WebUI installer;
|
||||||
|
there are no new application settings, permissions, or end-user workflows.
|
||||||
|
|
||||||
|
Each retried npm install or Git clone has at most three attempts. The installer
|
||||||
|
waits 10 seconds after the first failure and 20 seconds after the second, and
|
||||||
|
continues immediately after success. If all attempts fail, it exits with the
|
||||||
|
last command's nonzero status. Its `set -e` execution stops before subsequent
|
||||||
|
installation stages; callers using `set -e` also stop before subsequent work.
|
||||||
|
Previously, the retry helper could report success after three failures because
|
||||||
|
it captured the status of a completed `if` statement instead of the command.
|
||||||
|
|
||||||
|
On exhaustion, inspect the npm or Git error and correct the reported cause
|
||||||
|
before rerunning the installation. The temporary dependency workspace is
|
||||||
|
removed on exit. Earlier changes to `package.json`, removal of `package-lock.json`,
|
||||||
|
cache cleaning, and completed dependency installations are not rolled back;
|
||||||
|
prepare a fresh disposable release workspace when a clean retry is required.
|
||||||
|
|
||||||
|
The repair preserves the existing retry count, backoff, cache behavior, and
|
||||||
|
peer-resolution flags. It does not lift the runtime publication hold tracked in
|
||||||
|
[Meta #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52).
|
||||||
|
Review the historical `--legacy-peer-deps` workaround separately before lifting
|
||||||
|
that hold. Strict disposable Git-release and signed catalog verification do not
|
||||||
|
use this installer; strict release verification must not bypass peer checks.
|
||||||
|
See [Package Registry Releases](PACKAGE_REGISTRY_RELEASES.md) for release context.
|
||||||
|
|
||||||
|
Run the isolated regression suite from the meta repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m unittest -v tests.test_webui_release_dependency_retries
|
||||||
|
```
|
||||||
|
|
||||||
|
The suite executes the actual Bash installer and a caller using `set -e`, with
|
||||||
|
local npm, Git, Node, and sleep stubs. It covers success on attempts one, two, and
|
||||||
|
three, final failure status, backoff, and termination at each retry call site.
|
||||||
|
It performs no network access, real waiting, or changes to the real npm cache.
|
||||||
|
It checks shell control flow, not package resolution or runtime publication.
|
||||||
|
|
||||||
|
## Deutsch
|
||||||
|
|
||||||
|
Dieser Betriebshinweis beschreibt
|
||||||
|
[`install-webui-release-dependencies.sh`](../../tools/release/install-webui-release-dependencies.sh)
|
||||||
|
und die unter [Meta #54](https://git.add-ideas.de/GovOPlaN/govoplan/issues/54)
|
||||||
|
erfasste Korrektur des Rückgabestatus. Er richtet sich an Release-Administratoren,
|
||||||
|
die den bisherigen WebUI-Installer für Laufzeit-Releases verwenden. Neue
|
||||||
|
Anwendungseinstellungen, Berechtigungen oder Endanwenderabläufe entstehen nicht.
|
||||||
|
|
||||||
|
Jede wiederholte npm-Installation und jeder Git-Klon erhält höchstens drei
|
||||||
|
Versuche. Nach dem ersten Fehlschlag wartet der Installer 10 Sekunden, nach dem
|
||||||
|
zweiten 20 Sekunden; nach einem Erfolg fährt er sofort fort. Scheitern alle
|
||||||
|
Versuche, endet er mit dem letzten von null verschiedenen Rückgabestatus.
|
||||||
|
Durch `set -e` werden nachfolgende Installationsschritte nicht ausgeführt;
|
||||||
|
auch aufrufende Skripte mit `set -e` brechen vor ihren nächsten Schritten ab.
|
||||||
|
Bisher konnte die Hilfsfunktion nach drei Fehlschlägen Erfolg melden, weil sie
|
||||||
|
den Status der abgeschlossenen `if`-Anweisung statt des Befehls übernahm.
|
||||||
|
|
||||||
|
Prüfen Sie nach dem Abbruch die npm- oder Git-Fehlermeldung und beheben Sie deren
|
||||||
|
Ursache vor einem erneuten Installationslauf. Das temporäre Verzeichnis für
|
||||||
|
Abhängigkeiten wird beim Beenden entfernt. Vorherige Änderungen an `package.json`,
|
||||||
|
das Entfernen von `package-lock.json`, die Cache-Bereinigung und abgeschlossene
|
||||||
|
Installationen werden nicht zurückgerollt. Bereiten Sie bei Bedarf einen neuen
|
||||||
|
temporären Release-Arbeitsbereich für einen sauberen Wiederholungslauf vor.
|
||||||
|
|
||||||
|
Die Korrektur erhält Anzahl und Wartezeiten der Versuche, Cache-Verhalten und
|
||||||
|
Optionen zur Peer-Auflösung. Die Sperre für Laufzeitveröffentlichungen aus
|
||||||
|
[Meta #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52) bleibt bestehen.
|
||||||
|
Der bisherige Einsatz von `--legacy-peer-deps` muss vor ihrer Aufhebung gesondert
|
||||||
|
geprüft werden. Die strenge Git-Release-Prüfung in einem temporären Arbeitsbereich
|
||||||
|
und die Prüfung signierter Kataloge verwenden diesen Installer nicht; die strenge
|
||||||
|
Release-Prüfung darf Peer-Prüfungen nicht umgehen. Weitere Zusammenhänge erläutert
|
||||||
|
[Package Registry Releases](PACKAGE_REGISTRY_RELEASES.md).
|
||||||
|
|
||||||
|
Führen Sie die isolierten Regressionstests im Meta-Repository aus:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m unittest -v tests.test_webui_release_dependency_retries
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Tests führen den tatsächlichen Bash-Installer und ein aufrufendes Skript mit
|
||||||
|
`set -e` aus. Lokale Testprogramme ersetzen npm, Git, Node und sleep. Geprüft werden
|
||||||
|
Erfolge im ersten, zweiten und dritten Versuch, der letzte Fehlerstatus,
|
||||||
|
Warteintervalle und der Abbruch an jeder Aufrufstelle. Es gibt keine
|
||||||
|
Netzwerkzugriffe, echten Wartezeiten oder Änderungen am tatsächlichen npm-Cache.
|
||||||
|
Die Tests prüfen den Shell-Ablauf, nicht die Paketauflösung oder Veröffentlichung.
|
||||||
@@ -79,7 +79,7 @@ development depend on submodule updates.
|
|||||||
Module release tags also publish wheels and WebUI tarballs to the organization
|
Module release tags also publish wheels and WebUI tarballs to the organization
|
||||||
PyPI/npm registries. The meta release resolves exact versions into a hash-bound
|
PyPI/npm registries. The meta release resolves exact versions into a hash-bound
|
||||||
package lock before producing the signed OCI runtime. See
|
package lock before producing the signed OCI runtime. See
|
||||||
`docs/PACKAGE_REGISTRY_RELEASES.md`. Git tags remain source provenance; package
|
`docs/operations/PACKAGE_REGISTRY_RELEASES.md`. Git tags remain source provenance; package
|
||||||
registries are reusable artifact transport; the signed runtime manifest and
|
registries are reusable artifact transport; the signed runtime manifest and
|
||||||
digest-pinned images remain production authority.
|
digest-pinned images remain production authority.
|
||||||
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# GovOPlaN 0.1.45 — usability, reliability and security hardening
|
||||||
|
|
||||||
|
Release coordination: [GovOPlaN #51](https://git.add-ideas.de/GovOPlaN/govoplan/issues/51).
|
||||||
|
The exact independently versioned composition is recorded in
|
||||||
|
`packages/govoplan-meta/pyproject.toml`; unchanged modules retain their versions.
|
||||||
|
This source release does not by itself establish a deployed or independently
|
||||||
|
approved production environment. Package, signed catalog and runtime publication
|
||||||
|
results are recorded separately in the coordination issue.
|
||||||
|
|
||||||
|
## Runtime publication hold
|
||||||
|
|
||||||
|
The [runtime image audit](../security/RUNTIME_IMAGE_AUDIT_2026-09-08.md) completed
|
||||||
|
eleven registry-only amd64 scans, but found unresolved vulnerabilities and
|
||||||
|
inventory gaps. Runtime publication remains held separately from this source
|
||||||
|
release. Patch-only image updates are insufficient; maintained minor-line
|
||||||
|
changes, narrowly evidenced finding decisions, arm64/final-layer scans and
|
||||||
|
deployment checks remain necessary. No audited candidate was automatically
|
||||||
|
adopted and no image was executed during those scans.
|
||||||
|
The remaining gates are tracked in
|
||||||
|
[GovOPlaN #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52).
|
||||||
|
|
||||||
|
## Included changes
|
||||||
|
|
||||||
|
- Shared page/action placement, reusable navigation grouping/editing, table and
|
||||||
|
dialog sizing, field alignment, multi-select filters and predictable tree
|
||||||
|
selection. Files, Mail, Search, Notifications and domain pages use the same
|
||||||
|
contracts, with browser regression coverage.
|
||||||
|
- Campaign draft saving and independent Mail/ZIP-policy repair, persistent and
|
||||||
|
bulk message review, clearer delivery eligibility, bounded configurable
|
||||||
|
synchronous delivery, guarded workerless recovery, lightweight SMTP/IMAP
|
||||||
|
progress, reused IMAP connections and recipient-complete reporting.
|
||||||
|
- Files archive staging/reuse, unpacking previously uploaded archives, numeric
|
||||||
|
progress and bounded traversal. Optional native archive acceleration retains
|
||||||
|
the same validation rules; portable fallbacks remain available.
|
||||||
|
- Mail credential references and IMAP folder-name decoding; help topics can be
|
||||||
|
found by area and tags without expanding every occurrence of the same topic.
|
||||||
|
- Authentication provenance/scope and browser-cache hardening, patched rich-text
|
||||||
|
dependencies, spreadsheet/archive/template/Dataflow resource limits, batched
|
||||||
|
Docs/Notifications queries and safe Reporting bind names. See the
|
||||||
|
[security/performance review](../security/SECURITY_PERFORMANCE_REVIEW_2026-09-08.md)
|
||||||
|
for measurements, test evidence and remaining limitations.
|
||||||
|
- A deterministic governance-journey clock fixture, fresh-process Campaign
|
||||||
|
import coverage, and a new Cases patch aligning its root npm facade with its
|
||||||
|
Python/WebUI package. Historical published tags are not rewritten.
|
||||||
|
- Git-root WebUI package facades are aligned with their owning packages, with
|
||||||
|
a cross-composition parity check. Tasks is included in default module
|
||||||
|
discovery; it remains subject to enabled modules and normal permissions.
|
||||||
|
|
||||||
|
## Upgrade and verification
|
||||||
|
|
||||||
|
Back up the database and file storage before upgrading. Apply the complete
|
||||||
|
selected migration graph before starting the new API/workers. This release
|
||||||
|
includes additive repair migrations `c58a2d7e9f10` (Core ownership history) and
|
||||||
|
`d8f1b4e7a0c3` (Access external-function mappings), plus Campaign delivery-state
|
||||||
|
migrations. Existing business evidence is retained; a schema downgrade is not
|
||||||
|
a substitute for a reviewed backup/restore plan. Restart API and worker
|
||||||
|
processes together after upgrading their matching packages.
|
||||||
|
|
||||||
|
Updated UI consumers require Core 0.1.45 where they use its new shared contracts.
|
||||||
|
Tenant keys that previously relied on unintended system permissions/wildcards
|
||||||
|
must be corrected; the release does not preserve that unsafe behavior. Extremely
|
||||||
|
sparse spreadsheets, oversized generated output and excessive archive paths
|
||||||
|
can now fail early with a diagnostic.
|
||||||
|
|
||||||
|
For archive staging across multiple hosts, provide shared POSIX storage with
|
||||||
|
working locks or sticky routing. Background delivery still needs configured
|
||||||
|
workers; increasing the synchronous limit does not create a worker or guarantee
|
||||||
|
delivery after a process failure. An unknown SMTP outcome must be reconciled,
|
||||||
|
not automatically resent.
|
||||||
|
|
||||||
|
After deployment, manually verify login/logout and least-privilege API keys,
|
||||||
|
Campaign Settings and independent Mail/ZIP saves, archive upload/unpack,
|
||||||
|
recipient-complete reports, and SMTP/IMAP progress with an explicitly approved
|
||||||
|
test mailbox. No release verification sends real campaign mail automatically.
|
||||||
|
|
||||||
|
Hard process isolation, forced-password-change/recovery enforcement, bounded
|
||||||
|
Xrechnung subprocess output and large-history pagination remain separate open
|
||||||
|
issues. This release is not a claim that all security or performance debt is
|
||||||
|
resolved. Production-image scans and multi-host evidence must refer to the
|
||||||
|
actual signed runtime being deployed.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Runtime image candidate audit — 8 September 2026
|
||||||
|
|
||||||
|
Release coordination: [GovOPlaN #51](https://git.add-ideas.de/GovOPlaN/govoplan/issues/51).
|
||||||
|
Canonical remediation: [GovOPlaN #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52).
|
||||||
|
This follow-up to the [source security/performance review](SECURITY_PERFORMANCE_REVIEW_2026-09-08.md)
|
||||||
|
records registry-only scans of nine proposed runtime dependencies and two
|
||||||
|
same-minor patch candidates. **Runtime publication is held:** patch-only updates
|
||||||
|
do not resolve the baseline. Source/package publication is a separate outcome.
|
||||||
|
No images were executed, rebuilt, selected for CI, or published by this audit.
|
||||||
|
|
||||||
|
## Method and reproducible evidence
|
||||||
|
|
||||||
|
Official Trivy **0.74.0** was installed only in a private local task directory,
|
||||||
|
without sudo or Docker access. Its Linux-64bit release archive matched both the
|
||||||
|
official checksums file and GitHub release asset metadata:
|
||||||
|
|
||||||
|
- Archive SHA256: `2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a`.
|
||||||
|
- Checksums-file SHA256: `bc701c3c3ee8b9acbea2c23257e41381e3854888f51281616a6ba5dc96963821`.
|
||||||
|
- Vulnerability database schema 2, updated `2026-09-07T19:06:01.154199452Z`,
|
||||||
|
downloaded from `mirror.gcr.io/aquasec/trivy-db:2`.
|
||||||
|
- Scan flags: `--image-src remote --platform linux/amd64 --scanners vuln
|
||||||
|
--format json --no-progress --timeout 8m --max-image-size 2GB --exit-code 0`.
|
||||||
|
Findings were counted from validated JSON; exit zero did not mean clean.
|
||||||
|
- Existing Docker credentials were not read; no private keys or secrets were
|
||||||
|
used. Checksums over official HTTPS metadata were verified, not independent
|
||||||
|
Sigstore signatures. See the [official release](https://github.com/aquasecurity/trivy/releases/tag/v0.74.0)
|
||||||
|
and [registry-only scan documentation](https://trivy.dev/docs/latest/target/container_image/).
|
||||||
|
|
||||||
|
Raw evidence is retained locally, not committed:
|
||||||
|
`/home/zemion/.cache/govoplan-trivy-remote.yKZgjDOg/scan/`.
|
||||||
|
It contains eleven `reports/*-amd64.json` reports/logs, scanner scripts,
|
||||||
|
`patch-candidate-inspection.json`, exact successor registry indices, and
|
||||||
|
`evidence-checksums.json`. Summary SHA256 values:
|
||||||
|
|
||||||
|
- `summary.json`: `f2785a731d637452ab9c0b1f5399772c0f8828a63ca83d5fa7496abdad1c757a`.
|
||||||
|
- `patch-summary.json`: `b3fc6273fcdad98864040ccdf3477ecf379afd46e9f94444b1f2910f48c1d85b`.
|
||||||
|
|
||||||
|
All eleven executions succeeded without timeout/rate-limit failure. Initial
|
||||||
|
summary fields distinguish `scan_execution_complete: true` from
|
||||||
|
`coverage_complete: false`: Garage has no detectable package inventory.
|
||||||
|
Checksums preserve evidence identity, not indefinite storage availability.
|
||||||
|
|
||||||
|
## Exact requested pins and results
|
||||||
|
|
||||||
|
All references below use `docker.io/`. Counts are package-vulnerability records,
|
||||||
|
not distinct CVEs or confirmed exploitable application defects. A vulnerability
|
||||||
|
can appear against several installed packages. Unfixed/unknown records remain.
|
||||||
|
|
||||||
|
| Image tag | Exact index SHA256 | Critical / High / Medium / Low / Unknown | Fixable C/H |
|
||||||
|
| --- | --- | --- | ---: |
|
||||||
|
| `library/python:3.12-slim-bookworm` | `782412e85d0f0984994c290652577d4018aff08145c85b262bb63dc0c7522254` | 5 / 55 / 102 / 103 / 5 | 0 |
|
||||||
|
| `library/postgres:16-alpine` | `cf78e76683b9ca8c5733cbbdce6c9262b45b6767934dd0a95e671f9a0fc20685` | 1 / 30 / 28 / 14 / 1 | 31 |
|
||||||
|
| `library/redis:7-alpine` | `ff02b58f971e7d7d156a1267e283fcbbeee91773b6aa36c49dac28ecfe28eadf` | 0 / 0 / 0 / 0 / 0 | 0 |
|
||||||
|
| `nginxinc/nginx-unprivileged:1.29-alpine` | `0c79d56aee561a1d81c63f00eee5fb5fe29279560cdc55e91425133104c7fbe6` | 0 / 33 / 74 / 37 / 20 | 33 |
|
||||||
|
| `library/haproxy:3.2.21-alpine` | `66e25cc9a8332635f4e897f7f4b1e5622c25f09f0ee23cddc6ce9bdb3a24772a` | 0 / 2 / 6 / 12 / 0 | 2 |
|
||||||
|
| `library/caddy:2.10.2-alpine` | `4c6e91c6ed0e2fa03efd5b44747b625fec79bc9cd06ac5235a779726618e530d` | 7 / 75 / 67 / 37 / 4 | 82 |
|
||||||
|
| `dxflrs/garage:v2.3.0` | `866bd13ed2038ba7e7190e840482bc27234c4afaf77be8cfa439ae088c1e4690` | **Unknown: no inventory** | — |
|
||||||
|
| `greenmail/standalone:2.1.9` | `3ac5a83dd6727cf95e4d50e18907fb8ee7bbf5f67e8534714dee2fb1b5b2e1d4` | 0 / 0 / 116 / 35 / 0 | 0 |
|
||||||
|
| `tonistiigi/binfmt:qemu-v10.2.3-68` | `400a4873b838d1b89194d982c45e5fb3cda4593fbfd7e08a02e76b03b21166f0` | 0 / 9 / 2 / 1 / 1 | 9 |
|
||||||
|
|
||||||
|
## Patch-only options and limits
|
||||||
|
|
||||||
|
Complete publisher tag listings were inspected for nginx 1.29, Caddy 2.10,
|
||||||
|
HAProxy 3.2, GreenMail 2.1 and binfmt qemu10.2. Two newer candidates were
|
||||||
|
scanned; their registry index bytes matched both registry and publisher digests,
|
||||||
|
and contained amd64 and arm64 manifests:
|
||||||
|
|
||||||
|
- `library/haproxy:3.2.23-alpine@sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e`:
|
||||||
|
same Alpine 3.24.1, 24 detected OS packages, zero reported findings. This is
|
||||||
|
a useful candidate, not a completed compatibility test or application audit.
|
||||||
|
- `greenmail/standalone:2.1.13@sha256:3df66b7edd01c8a301343ca5e3601d8674760d4708655573560c24745e624fb2`:
|
||||||
|
upstream changes Ubuntu 22.04 to Debian 13.6; **3 C / 80 H / 98 M / 85 L /
|
||||||
|
5 unknown**, 30 fixable C/H records. Not selected as a no-base-change update.
|
||||||
|
- nginx's newest matching Alpine patch is already 1.29.8 at the scanned pin;
|
||||||
|
Caddy 2.10 remains 2.10.2; binfmt qemu10.2 remains 10.2.3-68. No newer matching
|
||||||
|
publisher images were found. The current [official Caddy image catalogue](https://raw.githubusercontent.com/docker-library/official-images/master/library/caddy)
|
||||||
|
uses 2.11.4; switching minor lines requires new scans and compatibility checks.
|
||||||
|
|
||||||
|
Priority remediation: Caddy's own seven HIGH records require fixes through
|
||||||
|
2.11.4, with additional bundled Go/library fixes that must be re-scanned;
|
||||||
|
nginx's packages include curl/libcurl fixes through 8.22.0-r0, OpenSSL 3.5.8-r0,
|
||||||
|
c-ares 1.34.8-r0, expat 2.8.1-r0 and libuuid 2.41.6-r1. PostgreSQL's OS records
|
||||||
|
require OpenSSL 3.5.8-r0 and libuuid 2.42.3-r1; its CRITICAL plus 21 HIGH Go
|
||||||
|
records concern the **gosu helper**, not PostgreSQL server code. binfmt's nine
|
||||||
|
HIGH records concern its Go 1.26.4 build, with fixes through 1.26.6. Package
|
||||||
|
presence does not establish vulnerable-symbol reachability. No unscanned tag
|
||||||
|
is claimed to meet every fix requirement.
|
||||||
|
|
||||||
|
## Python triage and coverage caveats
|
||||||
|
|
||||||
|
Python image metadata identifies CPython 3.12.14, but Trivy inventories only
|
||||||
|
Debian packages and pip, **not CPython/stdlib**. All 60 C/H records concern
|
||||||
|
Debian packages: 21 CVEs, 50 `affected` records, 9 `fix_deferred`, 1
|
||||||
|
`will_not_fix`, without a recorded fixed Bookworm version. Five util-linux CVEs
|
||||||
|
repeat across eight binary packages. These remain installed; they are not all
|
||||||
|
removed build dependencies. Pip 25.0.1 separately has five MEDIUM/one LOW
|
||||||
|
records, with fixes through 26.2.0; it is install tooling, and the API image uses
|
||||||
|
an offline `--no-index` wheelhouse rather than an arbitrary package index.
|
||||||
|
|
||||||
|
Narrow triage examples, **not blanket exemptions**:
|
||||||
|
|
||||||
|
- Debian states [CVE-2023-45853](https://security-tracker.debian.org/tracker/CVE-2023-45853)
|
||||||
|
does not affect the built Bookworm zlib binaries because vulnerable minizip
|
||||||
|
code is not included. Other bundled minizip implementations are separate.
|
||||||
|
- [CVE-2026-8376](https://security-tracker.debian.org/tracker/CVE-2026-8376)
|
||||||
|
explicitly requires 32-bit Perl; this scan targets amd64.
|
||||||
|
- [CVE-2025-7458](https://security-tracker.debian.org/tracker/CVE-2025-7458)
|
||||||
|
requires crafted arbitrary SQLite SQL; the managed runtime uses PostgreSQL,
|
||||||
|
but alternate SQLite use must be reviewed.
|
||||||
|
- Perl's regex and Archive::Tar records need exact binary/module applicability
|
||||||
|
checks; vendor-deferred status alone is not a finding dismissal.
|
||||||
|
|
||||||
|
Only amd64 was scanned. arm64, newly built GovOPlaN API/Web layers and optional
|
||||||
|
dependency combinations remain unverified. Garage has no inventory; Redis,
|
||||||
|
HAProxy and PostgreSQL source-built executables, CPython and QEMU static
|
||||||
|
binaries need supplemental SBOM/source coverage. Zero detected OS findings is
|
||||||
|
not zero application vulnerabilities. Trivy also lacks Alpine 3.24 EOL metadata
|
||||||
|
and nginx CVE-2026-80256 detail; unknowns are retained. There were no runtime,
|
||||||
|
exploitability, secret, misconfiguration, malware or signature-policy checks.
|
||||||
|
|
||||||
|
Before lifting the runtime hold: approve and test maintained image-line changes
|
||||||
|
where necessary, fix or narrowly disposition findings with evidence, close
|
||||||
|
inventory gaps, scan both architectures and final runtime layers, then run
|
||||||
|
deployment/ingress smoke checks. Do not silently change base OS, use unpinned
|
||||||
|
`latest`, rebuild third-party images, or accept all HIGH/CRITICAL findings.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Runtime image remediation follow-up — 8 September 2026
|
||||||
|
|
||||||
|
Canonical tracking: [Meta #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52)
|
||||||
|
and [website #9](https://git.add-ideas.de/add-ideas/addideas-govoplan-website/issues/9).
|
||||||
|
This addendum supplements the [original audit](RUNTIME_IMAGE_AUDIT_2026-09-08.md);
|
||||||
|
it does not replace that historical baseline or lift either publication or
|
||||||
|
deployment gate. The immutable 0.1.45 release and `catalog-v0.1.45` are unchanged.
|
||||||
|
|
||||||
|
## Source change and candidate decisions
|
||||||
|
|
||||||
|
New installer specifications now use
|
||||||
|
`haproxy:3.2.23-alpine@sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e`.
|
||||||
|
This is a patch update from 3.2.21 within the supported
|
||||||
|
[3.2 LTS branch](https://www.haproxy.org/), keeping Alpine 3.24.1.
|
||||||
|
The [publisher's exact build source](https://github.com/docker-library/haproxy/blob/7a5c202cde713867a737033dca56e7a211a8b8df/3.2/alpine/Dockerfile)
|
||||||
|
and scanned image configuration retain the non-root `haproxy` user,
|
||||||
|
`/usr/local/etc/haproxy/haproxy.cfg`, entrypoint and graceful-stop signal.
|
||||||
|
The [upstream changelog](https://www.haproxy.org/download/3.2/src/CHANGELOG)
|
||||||
|
includes HTTP parsing, TLS and memory-safety fixes in 3.2.22/3.2.23.
|
||||||
|
Existing specifications retain their explicit image, including an older pin;
|
||||||
|
this source change does not update a running installation.
|
||||||
|
|
||||||
|
| Candidate | OS | C / H / M / L / Unknown, per architecture | Disposition |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| HAProxy `3.2.23-alpine` | Alpine 3.24.1 | 0 / 0 / 0 / 0 / 0 | Installer source default updated; binary/runtime checks pending |
|
||||||
|
| nginx-unprivileged `1.30.4-alpine` | Alpine 3.24.1 | 0 / 0 / 0 / 0 / 0 | Candidate only; compatibility and website OS upgrade review pending |
|
||||||
|
| Caddy `2.11.4-alpine` | Alpine 3.23.5 | 1 / 38 / 41 / 12 / 23 | Not selected; all 39 C/H records have recorded fixes |
|
||||||
|
| Node `24-alpine` (24.20.0) | Alpine 3.24.1 | 0 / 6 / 11 / 12 / 0 | Not selected; major change and six fixable HIGH records |
|
||||||
|
|
||||||
|
Each row was scanned separately for **linux/amd64 and linux/arm64**, with the
|
||||||
|
same counts on both. Counts are package-vulnerability records, not distinct
|
||||||
|
CVEs or proven exploits. The [machine-readable evidence](runtime-image-candidates-2026-09-08.json)
|
||||||
|
contains exact index, platform-manifest, config and report digests, inventory
|
||||||
|
counts, scanner bounds and decisions. It is audit data, not an accepted release
|
||||||
|
manifest or an installer input.
|
||||||
|
|
||||||
|
nginx's candidate reference is
|
||||||
|
`docker.io/nginxinc/nginx-unprivileged:1.30.4-alpine@sha256:442753882674b49ae2c1de83ed67896131c0777f56df5005e356e62bc3f7e7ce`.
|
||||||
|
It inventories 70 Alpine packages including nginx/NJS, uses UID 101 and exposes
|
||||||
|
8080. The [upstream stable release](https://nginx.org/en/download.html) and
|
||||||
|
[security advisories](https://nginx.org/en/security_advisories.html) include the
|
||||||
|
1.30.4 fixes. The publisher retains its
|
||||||
|
[unprivileged port and temporary-path contract](https://github.com/nginx/docker-nginx-unprivileged).
|
||||||
|
For the website, this changes nginx 1.27.5 to 1.30.4, NJS 0.8.10 to 1.0.1 and
|
||||||
|
Alpine 3.21.3 to 3.24.1. These changes are explicit review items; the website
|
||||||
|
Dockerfile has not been changed. The GovOPlaN Web image still requires an
|
||||||
|
explicit verified `NGINX_IMAGE` build argument.
|
||||||
|
|
||||||
|
Caddy's candidate reference is
|
||||||
|
`docker.io/library/caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648`.
|
||||||
|
Although this is the current
|
||||||
|
[official image line](https://raw.githubusercontent.com/docker-library/official-images/master/library/caddy),
|
||||||
|
its inventory still includes Go 1.26.3, `x/crypto` 0.52.0, `x/net` 0.55.0,
|
||||||
|
`x/text` 0.37.0 and gRPC 1.81.0. Recorded fixes include Go 1.26.6,
|
||||||
|
`x/crypto` 0.55.0, `x/net` 0.56.0, `x/text` 0.39.0 and gRPC 1.83.1; Alpine
|
||||||
|
findings also remain in c-ares, curl/libcurl and OpenSSL. The CRITICAL
|
||||||
|
`CVE-2026-56854` concerns `x/crypto/ssh` source-address enforcement. A module
|
||||||
|
record alone does not establish that this binary exposes that SSH path; exact
|
||||||
|
binary symbol/reachability analysis is still required for a disposition.
|
||||||
|
|
||||||
|
The [official Node image catalogue](https://raw.githubusercontent.com/docker-library/official-images/master/library/node)
|
||||||
|
still maps Node 22 Alpine to 22.23.2 and the previously scanned digest. Node 24's
|
||||||
|
candidate is
|
||||||
|
`docker.io/library/node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf`.
|
||||||
|
Its HIGH records remain in two OpenSSL packages and npm dependencies
|
||||||
|
`brace-expansion`, `ip-address` and `tar`; fixing the earlier critical tar
|
||||||
|
record alone is insufficient. The website builder stays on Node 22 pending
|
||||||
|
a reviewed build-tool remedy and a final builder scan.
|
||||||
|
|
||||||
|
## Method, verification and retained evidence
|
||||||
|
|
||||||
|
The existing Trivy 0.74.0 executable was rehashed against the previously verified
|
||||||
|
archive member: `d89bcc6510a267f11b773398cbf1be5520ce39f9e8b6633178c4487f05b7d791`.
|
||||||
|
The same schema-2 vulnerability database was used, updated
|
||||||
|
`2026-09-07T19:06:01.154199452Z`. No tool installation or database refresh occurred.
|
||||||
|
Index bytes matched both the registry digest header and Docker Hub publisher
|
||||||
|
metadata; both platform-manifest byte hashes matched the index. All eight
|
||||||
|
registry-only scans completed successfully with validated JSON, `--list-all-pkgs`,
|
||||||
|
`--scanners vuln`, an eight-minute/2GB image bound, an empty Docker configuration
|
||||||
|
and no inherited credentials. Exit zero means execution succeeded. Private
|
||||||
|
temporary paths and in-memory artifact cache isolated this follow-up from the
|
||||||
|
earlier scanner's artifact cache; its vulnerability database was read only.
|
||||||
|
|
||||||
|
Raw reports, logs, manifests, publisher metadata and the scanner script are in
|
||||||
|
`/home/zemion/.cache/govoplan-runtime-remediation.qfDSWHJh/`:
|
||||||
|
|
||||||
|
- `summary.json` SHA-256: `0d44390408ab35270e4430516f77bf11aa7877334eff2ef19e11e9a863fe5c56`.
|
||||||
|
- `frozen-images.json` SHA-256: `d0cb156f4a88998531ec55ab950067a3f1350ded648f07650c463af101dad467`.
|
||||||
|
- `scan_successors.py` SHA-256: `f64c69e06efc2ad7b5a657a25aa73f9ff586e3685737d525456bcecbe3ab5f07`.
|
||||||
|
|
||||||
|
Local retention is not permanent artifact hosting; preserve this evidence with
|
||||||
|
the eventual reviewed release. The JSON evidence records compressed registry
|
||||||
|
layer sizes; these are not expanded filesystem limits or final GovOPlaN sizes.
|
||||||
|
|
||||||
|
Installer regression checks cover the new generated image pin, legacy
|
||||||
|
specification fallback, preserved explicit images, generated topology and
|
||||||
|
configuration: `python -I -m unittest discover -s tests -p
|
||||||
|
test_deployment_installer.py` ran 45 tests successfully with one skip because
|
||||||
|
Core was not importable in that isolated test environment. The skipped Core
|
||||||
|
startup-configuration integration was subsequently rerun in the shared development
|
||||||
|
environment with Core available: all 45 installer tests passed with no skips,
|
||||||
|
including generated-environment startup validation. This is configuration
|
||||||
|
validation, not execution of the candidate image.
|
||||||
|
Both repositories passed `git diff --check`; the audit JSON and all eight
|
||||||
|
report hashes were checked against the retained evidence.
|
||||||
|
**Docker, Podman and HAProxy executables are unavailable on
|
||||||
|
this host**, so no image or HAProxy configuration was executed and no daemon was
|
||||||
|
installed. Publisher metadata and installer tests support the scoped source
|
||||||
|
patch; they do not establish binary or deployed compatibility.
|
||||||
|
|
||||||
|
## Gates that remain open
|
||||||
|
|
||||||
|
- Validate `haproxy -c` on generated local, existing-proxy and managed-ingress
|
||||||
|
configurations using the exact pinned image and target architectures. Run
|
||||||
|
bounded isolated checks without live mounts, secrets, privilege or external
|
||||||
|
network access. Then verify DNS discovery, readiness, forwarded headers,
|
||||||
|
replica routing and graceful termination in the intended runtime.
|
||||||
|
- Test the nginx candidate with both the website configuration and GovOPlaN
|
||||||
|
WebUI entrypoint/proxy configuration, including UID 101, writable temporary
|
||||||
|
paths, health paths, cache headers and static catalog bytes. Approve the
|
||||||
|
website nginx/NJS/Alpine version changes before changing its Dockerfile.
|
||||||
|
- Resolve Caddy, Node build-tool and all unchanged baseline dependencies with
|
||||||
|
updated publisher images or narrow reviewed applicability evidence. No
|
||||||
|
severity-wide exceptions or custom third-party rebuilds were introduced.
|
||||||
|
- Close the original source-built/static inventory gaps. HAProxy's 24-package
|
||||||
|
OS inventory still omits the source-built HAProxy executable. Node's npm
|
||||||
|
inventory still omits the Node executable/stdlib. Garage, CPython, Redis,
|
||||||
|
PostgreSQL and QEMU gaps are unchanged. Alpine 3.24 EOL metadata is still
|
||||||
|
missing from this scanner; zero findings is not complete coverage.
|
||||||
|
- Scan **final built** API/Web/website layers and the selected managed
|
||||||
|
dependencies on both architectures, then perform migration, worker,
|
||||||
|
readiness and ingress smoke checks. Record failure and unknown states.
|
||||||
|
Secrets, misconfiguration and image signature policy need separate checks.
|
||||||
|
- Obtain the website deployment host/operator and rebuild/restart authority,
|
||||||
|
preserving the exact immutable catalog/keyring/module-directory bytes and
|
||||||
|
verifying fresh public responses after an authorized rollout.
|
||||||
|
|
||||||
|
No images were built, executed, published or deployed; no running service,
|
||||||
|
release tag, signed manifest, CI image input or live infrastructure was changed.
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
# Security and performance follow-up — 8 September 2026
|
||||||
|
|
||||||
|
This follows the [original review](SECURITY_PERFORMANCE_REVIEW_2026-09-08.md)
|
||||||
|
and its post-release issue reconciliation. It describes new source work after
|
||||||
|
the frozen 0.1.45 release; it does not change published tags, packages, signed
|
||||||
|
catalogs or deployed images. Gitea remains the canonical state log.
|
||||||
|
|
||||||
|
## Implemented source slices
|
||||||
|
|
||||||
|
- [Core #297](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/297):
|
||||||
|
a shared disposable-process runner enforces wall/CPU/address-space/input/output
|
||||||
|
limits, bounded stderr, process-group cleanup and non-queuing per-process
|
||||||
|
admission. A private binary codec bounds decoding before allocating a full
|
||||||
|
object graph and preserves explicitly supported data types without pickle.
|
||||||
|
Read the owning Core `docs/BOUNDED_PROCESS_CONTRACT.md` before adding callers.
|
||||||
|
- Connectors XLSX parsing, Templates rendering, Files ZIP/TAR inspection and
|
||||||
|
extraction, and Dataflow reference previews/development execution now use
|
||||||
|
that boundary. Existing authorization, sessions, provider credentials,
|
||||||
|
idempotency and persistence remain in the parent. No unprotected inline
|
||||||
|
fallback is used. Each module contributes static EN/DE user/admin limits and
|
||||||
|
operational consequences through its manifest.
|
||||||
|
- Files snapshots authorized sources inside shared admission, validates private
|
||||||
|
staged members, and acknowledges each persisted member before decoding the
|
||||||
|
next. Numeric progress remains available. The acknowledgement is event-driven,
|
||||||
|
not a fixed sleep per member. Reads allocate by validated actual file size,
|
||||||
|
not by the configured ceiling. Failures reap children, clear private staging
|
||||||
|
and retain the existing transaction/blob cleanup and explicit retry behavior.
|
||||||
|
- Dataflow's normal reference preview formerly bypassed the backend wrapper;
|
||||||
|
it now enters the worker too. Nested source configurations cannot collide
|
||||||
|
merely because subflows reuse node IDs. Combined reference-source data is
|
||||||
|
checked before creating further columnar copies, while individual providers
|
||||||
|
retain their own authorized-read bounds. Staging/production still require
|
||||||
|
DuckDB; this change does not replace that separate backend.
|
||||||
|
- [Access #22](https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/22):
|
||||||
|
current-password change, session/CSRF rotation, cross-tenant session and human
|
||||||
|
API-key revocation, and optional administrator-assisted recovery. Recovery
|
||||||
|
codes are hashed, single-use, expire after 15 minutes, require a current local
|
||||||
|
System owner and explicit identity verification, and recheck current account,
|
||||||
|
membership, tenant and issuer authority at redemption. A password change also
|
||||||
|
invalidates outstanding codes issued by that account for other people. Audit
|
||||||
|
evidence and validation/error responses do not contain passwords or codes.
|
||||||
|
External-provider and service-account rules remain separate.
|
||||||
|
- The Access UI provides first-login/required change, self-service change,
|
||||||
|
policy-aware sign-in help, public code redemption and eligible owner issuance.
|
||||||
|
Core consumes an optional lazy auth-action capability rather than importing
|
||||||
|
Access internals. The required-action gate fails closed if its UI is missing.
|
||||||
|
- [Workflow Engine #3](https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/3):
|
||||||
|
full-history lists batch pinned revisions, while new summary and bounded
|
||||||
|
step/event endpoints preserve authorization and explicit pagination. Existing
|
||||||
|
full-history responses are not silently truncated. Exact inbox total semantics
|
||||||
|
are retained and their counting cost is documented.
|
||||||
|
- [Meta #55](https://git.add-ideas.de/GovOPlaN/govoplan/issues/55):
|
||||||
|
shared version/planning helpers recognize the existing nested developer
|
||||||
|
package, not invented root metadata. All tag batches enforce trusted private
|
||||||
|
source ownership, registered origins and clean main/upstream state. Meta
|
||||||
|
batches additionally require exact composition and matching Core evidence.
|
||||||
|
Whole-batch preflight,
|
||||||
|
frozen source receipts, annotated immutable tags, object-pinned atomic
|
||||||
|
publication and post-effect remote checks are covered with temporary local
|
||||||
|
repositories. Hidden Git index flags, unsafe ancestry, alternates and changed
|
||||||
|
Git-directory identities are rejected. Selected version/composition metadata
|
||||||
|
must be tracked, so ignored files cannot describe bytes absent from a tag.
|
||||||
|
Applicable unselected Core WebUI inputs have bounded, frozen read receipts;
|
||||||
|
backend-only releases do not read them. The existing local module-candidate
|
||||||
|
exception remains intact. The weaker legacy mutation path was removed.
|
||||||
|
Canonical whole-package preview and receipt-bound apply now cover Meta's
|
||||||
|
version preparation too. Core must already match the target. Preparation
|
||||||
|
requires a separate trusted checkout, explicit out-of-run confirmation and
|
||||||
|
unchanged source/tooling receipts; it cannot rewrite the running operator.
|
||||||
|
Plans place Meta after Core and explain the manual preparation/publication
|
||||||
|
steps instead of promising a durable self-update. Ambiguous partial writes
|
||||||
|
require reconciliation, without automatic rollback or retry.
|
||||||
|
- [Runtime-image follow-up](RUNTIME_IMAGE_REMEDIATION_2026-09-08.md): eight new
|
||||||
|
registry-only scans cover four exact candidates on amd64 and arm64. New
|
||||||
|
installer specifications select the patched same-line HAProxy digest;
|
||||||
|
existing specifications retain their explicit image. Other candidates and
|
||||||
|
unresolved inventory/deployment gates remain visible, not blanket-approved.
|
||||||
|
|
||||||
|
## Verification record
|
||||||
|
|
||||||
|
Targeted checks include actual child execution, catastrophic regex CPU,
|
||||||
|
aggregate memory exhaustion, TAR extension metadata, noisy output, malformed
|
||||||
|
transport/staging data, Unicode allocation limits, cancellation/callback
|
||||||
|
failures, descendant cleanup, rollback and explicit retries. Local mixed-owner
|
||||||
|
composition tests completed nine real children, rejected six overlapping
|
||||||
|
requests as busy, observed at most one unreaped child and recovered all slots.
|
||||||
|
This is local admission evidence, not a target deployment load certification.
|
||||||
|
|
||||||
|
Workflow fixtures serialize 40 different pinned revisions with five SQL reads;
|
||||||
|
summary lists use one query for 40 ordinary rows. Exact inbox totals for
|
||||||
|
40/400/4,000 candidates used one query, with measured local costs approximately
|
||||||
|
0.011/0.057/0.492 seconds. These are fixture measurements, not production SLOs.
|
||||||
|
|
||||||
|
The broader Core API smoke suite exposed three stale campaign assertions.
|
||||||
|
All three failures were reproduced against the unchanged private frozen 0.1.45
|
||||||
|
sources. Updated fixtures verify recipient-summary projection, detailed payload
|
||||||
|
separation and explicit fenced recovery of a confirmed stopped runtime; observing
|
||||||
|
SENDING alone must not make a claim recoverable. All 76 smoke tests then passed.
|
||||||
|
No production Campaign behavior was changed to satisfy these tests.
|
||||||
|
|
||||||
|
The final release-tool suite passed 279 tests and 68 subtests, including
|
||||||
|
temporary local remotes and adversarial source/tag/receipt changes. Rechecking
|
||||||
|
the whole batch before effects is deliberately conservative: its repeated
|
||||||
|
filesystem/Git/remote work grows quadratically with batch size. It is not a
|
||||||
|
new unattended publication path or permission to execute unreviewed source.
|
||||||
|
|
||||||
|
Strict interface inventory now reports no unclassified endpoints and exact
|
||||||
|
contextual help for all 133 high-risk controls. Seventeen password browser cases
|
||||||
|
include actual F1 help from the restricted screen, empty workspace scopes,
|
||||||
|
EN/DE layouts, Unicode boundaries and no credential values in help URLs.
|
||||||
|
The initial production bundle remains within the unchanged limits (512,036
|
||||||
|
raw bytes and 162,415 gzip bytes; 1,713 gzip bytes below its ceiling), with
|
||||||
|
46 optional descriptors and no eager optional-module imports.
|
||||||
|
|
||||||
|
The focused checker now includes the new Core process, mixed-owner admission,
|
||||||
|
Access password, Templates and Files worker tests, the repaired campaign smoke
|
||||||
|
cases, and browser-side auth/password transport contracts. The full focused run
|
||||||
|
passed, including 63 production module/build permutations and all 230 browser
|
||||||
|
cases. Its two opt-in Datasources PostgreSQL cases were skipped in that run
|
||||||
|
and subsequently passed against the isolated real database described below.
|
||||||
|
The final Meta preparation gate was added after that full run and verified
|
||||||
|
with the owning release-tool suite and the focused release-gate command.
|
||||||
|
Manifest validation passed for all 72 modules. The full focused log is
|
||||||
|
`/mnt/DATA/tmp/govoplan-security-followup-20260908-focused.log`.
|
||||||
|
|
||||||
|
The first follow-up quick audit captured an unchanged 79-repository snapshot
|
||||||
|
in `/mnt/DATA/tmp/govoplan-security-followup-quick-20260908-7s8Sgh/`.
|
||||||
|
All four required scanners completed, with zero missing/execution reports;
|
||||||
|
all 168 report checksums and 163 machine-readable reports were validated.
|
||||||
|
Gitleaks found no secrets in all 79 histories and 79 worktrees. Local Semgrep
|
||||||
|
rules reported zero findings. Production Bandit reported 65 low and four medium
|
||||||
|
warnings, and production Ruff retained 54 warnings. The two added Bandit
|
||||||
|
warnings identify the new Core subprocess import and invocation: trusted
|
||||||
|
server-owned arguments, no shell, and the documented resource/process boundary
|
||||||
|
were reviewed; warnings remain visible. This is report-only evidence, not a
|
||||||
|
warning-free audit or a penetration test. A final snapshot follows the
|
||||||
|
cross-module declaration/contextual-help corrections and release-tool checks.
|
||||||
|
|
||||||
|
That final audit completed on 8 September, 05:59:46–06:02:18 UTC, in
|
||||||
|
`/mnt/DATA/tmp/govoplan-security-final-quick-20260908-vAwQIh/`. All 79 start/end
|
||||||
|
source fingerprints were identical; all four scanners completed, all 168
|
||||||
|
registered report checksums matched, and all 163 JSON/SARIF reports parsed.
|
||||||
|
There were no missing reports or scanner execution errors. Semgrep and both
|
||||||
|
Gitleaks scopes again reported zero findings. Production counts were unchanged
|
||||||
|
from the first follow-up: Bandit 65 low/four medium and Ruff 54. Test-only
|
||||||
|
counts were Bandit 140 low/34 medium and Ruff 136. A separate frozen scan of
|
||||||
|
all ten changed Meta release/deployment Python files reported seven low Bandit
|
||||||
|
and four Ruff S603 warnings, with no execution errors. Its four argv-only
|
||||||
|
subprocess sites were reviewed; the preparation additions introduced no new
|
||||||
|
warnings. No findings were hidden or severity-wide exceptions added.
|
||||||
|
The audit manifest SHA-256 is
|
||||||
|
`a997b786239cd11443cb665d5f9041a968cc38f9d49171e68bb868bf2bd73310`;
|
||||||
|
its report-checksum list SHA-256 is
|
||||||
|
`dc590ca5b0a4e445019a05536d410226088d67b40d61cd7657bdef4a4eae56d8`.
|
||||||
|
|
||||||
|
The audit includes the eight committed feature/website source changes and the
|
||||||
|
final uncommitted Meta source. Only this evidence document was updated after
|
||||||
|
the source freeze ended; the final Meta commit and remote publication are
|
||||||
|
recorded in the linked Gitea issues, not inferred from local audit completion.
|
||||||
|
|
||||||
|
Fresh dependency audits are retained in
|
||||||
|
`/mnt/DATA/tmp/govoplan-dependency-final-20260908-d24LEK/`: all four full npm
|
||||||
|
lockfile audits (Core WebUI, Mail root/WebUI and website) report zero known
|
||||||
|
vulnerabilities. Installed Python auditing covers 137 distributions with zero
|
||||||
|
known vulnerabilities; 51 local GovOPlaN distributions lack PyPI advisory
|
||||||
|
coverage. Core's 46 linked packages are likewise not claimed covered by public
|
||||||
|
registry advisories. All 12 dependency-file hashes and the installed inventory
|
||||||
|
were unchanged. No packages were installed or automatically fixed.
|
||||||
|
|
||||||
|
Managed PostgreSQL 16.15 fixtures used private Unix sockets, synthetic roles
|
||||||
|
and databases, no TCP listener, per-case schemas and bounded SQL/lock waits.
|
||||||
|
Both previously skipped Datasources races passed. Twenty-one existing Access
|
||||||
|
password HTTP tests and four additional races passed on PostgreSQL: single-use
|
||||||
|
redemption, stale-session/password replacement, competing issuance, and issuer
|
||||||
|
password revocation during redemption. Four release/development migration checks
|
||||||
|
also passed for Access and Workflow, including credential preservation and
|
||||||
|
idempotent indexes. The four races are now owning opt-in Access regressions;
|
||||||
|
see `govoplan-access/docs/PASSWORD_RECOVERY_POSTGRES_TESTS.md`. These local
|
||||||
|
database checks do not certify a deployment, fleet load or external recovery
|
||||||
|
handover. With both explicit PostgreSQL test URLs enabled, the full Access suite
|
||||||
|
passed 122 tests and 18 subtests, and the full Datasources suite passed 57 tests,
|
||||||
|
without skips. Access retained 12 existing SQLite datetime-adapter warnings in
|
||||||
|
its separate SQLite migration cases. Both temporary PostgreSQL fixtures were
|
||||||
|
stopped and independently verified: no server process, private socket,
|
||||||
|
generated schema or synthetic cluster remains. Scripts, logs and shutdown
|
||||||
|
receipts are retained under `/home/zemion/.cache/govoplan-pg-security-20260908.RAUitg/`
|
||||||
|
and `/home/zemion/.cache/govoplan-pg-promoted-20260908.JZcIKL/`.
|
||||||
|
|
||||||
|
## Adoption and remaining gates
|
||||||
|
|
||||||
|
1. `AUTH_LOCAL_PASSWORD_RECOVERY_ENABLED` remains **false** by default. The
|
||||||
|
existing flag is still advisory until an operator explicitly adopts and
|
||||||
|
enables the complete recovery policy. Confirm who verifies identity and how
|
||||||
|
the one-time code is handed over; automated email recovery is not enabled.
|
||||||
|
Test first-login, lost-password, code expiry and administrator availability
|
||||||
|
in the target environment before enforcement.
|
||||||
|
2. Access migration `e9a2c5f8b1d4` adds recovery evidence; Workflow migration
|
||||||
|
`9e6b3f8a2c7d` adds summary-pagination indexes. Use normal backed-up upgrade
|
||||||
|
procedures and account for index-build cost. No manual live migration or
|
||||||
|
server restart was performed during this work. The user's existing devserver
|
||||||
|
has automatic reload, so live schema state must not be assumed unchanged.
|
||||||
|
3. Release preparation must assign new source/package versions and require a
|
||||||
|
Core version containing the new worker/auth contracts in the affected module
|
||||||
|
metadata, including matching WebUI assets. The old immutable release must
|
||||||
|
not be relabelled or treated as containing these APIs.
|
||||||
|
4. Resource limits are not an arbitrary-code, filesystem or network sandbox.
|
||||||
|
Admission is per API/worker process, not fleet-wide. Validate Linux/cgroup
|
||||||
|
memory, disk quotas, process counts, cancellation and legitimate large-file
|
||||||
|
workloads on the intended runtime before increasing concurrency. Core #297
|
||||||
|
retains this target-evidence follow-up.
|
||||||
|
5. Meta #52 and website #9 retain runtime-image/publication/deployment holds.
|
||||||
|
Docker/Podman/HAProxy executables are unavailable here. Final built images,
|
||||||
|
binary/source inventories, ingress behavior, migration/readiness/worker
|
||||||
|
smoke checks and the website's target/operator authority remain outstanding.
|
||||||
|
Zero findings in a detected package inventory is not full image coverage.
|
||||||
|
|
||||||
|
No real messages, IMAP appends, password resets, provider operations or deployment
|
||||||
|
actions were used as test fixtures. Development tests use temporary databases,
|
||||||
|
private temporary files, mock transports and managed test-browser servers.
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# Security and performance review — 8 September 2026
|
||||||
|
|
||||||
|
Coordinated status: [Core #296](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/296).
|
||||||
|
This records a workspace-wide automated scan, targeted manual boundary review,
|
||||||
|
and a verified implementation pass. It is not a penetration test, an exhaustive
|
||||||
|
line-by-line review, or a security certification. The audit was completed on
|
||||||
|
local, unpublished changes, preserving existing worktree changes. Subsequent
|
||||||
|
release preparation/publication is tracked in
|
||||||
|
[GovOPlaN #51](https://git.add-ideas.de/GovOPlaN/govoplan/issues/51) and the
|
||||||
|
[0.1.45 release notes](../releases/0.1.45.md).
|
||||||
|
|
||||||
|
## Implemented findings
|
||||||
|
|
||||||
|
| Area | Finding and change | Evidence / ownership |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Authentication — high | Preserve service-account provenance and current scope ceilings instead of recalculating them as ordinary membership permissions. Tenant API keys cannot retain canonical system permissions or unsafe wildcard grants. | Previously failing isolated regressions; [Access #21](https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/21). |
|
||||||
|
| Authentication — medium | Warm-cache API keys must follow the same explicit-header credential rules as cold authentication. A session cookie cannot turn an API key into a session credential. | Regression covering source-dependent authentication. |
|
||||||
|
| Browser authority/cache — medium | Clear reusable data on auth changes and write settlement; fence late 200/304 writes and obsolete 401 side effects. Honor server no-store/no-cache and explicit fresh-read requests. Interactive login/logout remove retained automation keys that could shadow cookie-session identity. | 23 real-client regressions. Unchanged settings keep their object identity, preventing profile-fetch loops. Core `docs/API_CLIENT_CACHE_CONTRACT.md`; owning Access EN/DE session/field documentation. |
|
||||||
|
| Spreadsheet resource exhaustion | Validate actual XLSX coordinates before openpyxl traversal; ignore misleading declared dimensions; count blank row gaps toward the existing limits. | [Connectors #18](https://git.add-ideas.de/GovOPlaN/govoplan-connectors/issues/18), 13 tests and 2 subtests. |
|
||||||
|
| Template resource exhaustion | Enforce the existing 5 MiB output budget during substitution and item construction, including UTF-8, HTML escaping and separators. | [Templates #7](https://git.add-ideas.de/GovOPlaN/govoplan-templates/issues/7), full 20 tests; independent 3,000-case valid-output comparison. |
|
||||||
|
| Archive resource exhaustion | Inspect regular TAR member limits before traversing payloads. Limit archive paths to 4,096 UTF-8 bytes / 128 components and count derived directories against entry limits. | [Files #46](https://git.add-ideas.de/GovOPlaN/govoplan-files/issues/46), 55 archive and 15 documentation tests. Extension-header decoding still needs stronger isolation. |
|
||||||
|
| Dataflow resource exhaustion | Reject LPAD/RPAD target lengths above the existing 1,000,000-byte preview budget before fill evaluation/allocation. Preserve final serialized-byte checks. | [Dataflow #22](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/22), full 104 tests and 39 subtests; 7 new guard tests independently rerun. |
|
||||||
|
| Docs performance / defense in depth | Batch revision reads per request, avoid loading pending draft bodies for readers, and validate tenant/entry/publication consistency while retaining owner/audience checks. | [Docs #22](https://git.add-ideas.de/GovOPlaN/govoplan-docs/issues/22), full 39 tests. |
|
||||||
|
| Notifications performance / defense in depth | Batch delivery-attempt loading while preserving recipient checks and rejecting inconsistent attempt references, including already-loaded relationships. | [Notifications #6](https://git.add-ideas.de/GovOPlaN/govoplan-notifications/issues/6), full 23 tests. |
|
||||||
|
| Session-list performance | Apply active/expiry predicates and the existing 100-row cap in SQL, before loading session history. | Query-shape regression in Access. |
|
||||||
|
| Reporting correctness | Use structural bind-name suffixes for recursive calculated measures, preserving valid dotted/hyphenated public keys and parameter uniqueness. | [Reporting #10](https://git.add-ideas.de/GovOPlaN/govoplan-reporting/issues/10), full 29 tests. |
|
||||||
|
| Audit hygiene | Redact Gitleaks logs and machine reports on current, history and legacy scanner paths. | 13 audit-wrapper tests enforce the flag. |
|
||||||
|
|
||||||
|
All changed module workflows/limits have owning EN/DE DocumentationTopic updates.
|
||||||
|
Independent review found no concrete regression in the backend changes.
|
||||||
|
|
||||||
|
## Measured performance changes
|
||||||
|
|
||||||
|
These are SQL-query counts in isolated 40-item fixtures, not production latency
|
||||||
|
or throughput claims. Authorization is still evaluated for each request.
|
||||||
|
|
||||||
|
| Projection | Before | After |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Docs reader entries | 41 SELECTs | 2 SELECTs |
|
||||||
|
| Docs editor entries | 81 SELECTs | 2 SELECTs |
|
||||||
|
| Notification list with attempts | 41 SELECTs | 2 SELECTs |
|
||||||
|
|
||||||
|
The Docs 401-entry batching regression uses 3 SELECTs. Resource guards reject
|
||||||
|
oversized work before the formerly expensive allocation/traversal. This does
|
||||||
|
not make every legitimate upload or campaign faster. Honoring no-cache can
|
||||||
|
increase server validation requests; ETags still avoid retransmitting unchanged
|
||||||
|
bodies. That authorization/freshness trade-off is deliberate.
|
||||||
|
|
||||||
|
The original audit snapshot measured 517,380 initial JavaScript bytes and
|
||||||
|
164,119 gzip bytes. Release preparation's pure-defaults split reduces this to
|
||||||
|
516,730 initial bytes and 163,908 gzip bytes. Restoring the missing Tasks
|
||||||
|
descriptor then measures 516,987 initial / 163,976 gzip bytes with all 46 module
|
||||||
|
descriptors lazy, within the unchanged 524,288 / 164,128 caps. The gzip margin is still small; future
|
||||||
|
startup work should reduce eager dependencies, not raise the cap automatically.
|
||||||
|
The full 209-case browser suite passed before the split, followed by 13 focused
|
||||||
|
browser checks after it. Radon recorded 238 rank-D-or-higher entries; complexity
|
||||||
|
is a review-priority signal, not a performance measurement.
|
||||||
|
|
||||||
|
## Dependency remediation
|
||||||
|
|
||||||
|
Core's full npm audit went from 30 affected package entries to zero. Most initial
|
||||||
|
entries were transitive effects of the same Tiptap advisory, not 30 independent
|
||||||
|
application exploits. The website went from two affected entries to zero; both
|
||||||
|
Mail lockfiles also report zero.
|
||||||
|
|
||||||
|
- Tiptap packages are aligned at 3.31.3, with direct minimum ranges raised to
|
||||||
|
3.30.4 in both development and release manifests, with a parity regression.
|
||||||
|
Added an actual installed-library prototype-attribute regression for
|
||||||
|
the [maintainer's security advisory](https://github.com/ueberdosis/tiptap/security/advisories/GHSA-cp6q-959q-f8rh).
|
||||||
|
- Core now resolves xmldom 0.9.12, browserslist 4.28.9 and nanoid 3.3.18.
|
||||||
|
The website's affected browserslist/nanoid dependencies are patched too.
|
||||||
|
- Development/audit requirements now require pip >=26.2; the local development
|
||||||
|
environment uses 26.2.1. The installed audit originally flagged
|
||||||
|
[CVE-2026-13346](https://github.com/advisories/GHSA-qwm4-qh6w-59xr), requiring an
|
||||||
|
attacker-controlled package index. This is an installation-tool vulnerability,
|
||||||
|
not evidence of an exposed application endpoint.
|
||||||
|
|
||||||
|
The final installed Python audit enumerated 188 distributions: 137 were
|
||||||
|
auditable with zero known vulnerabilities, and 51 local distributions were not
|
||||||
|
available in PyPI. Those skips are covered by source review, not by a claim of
|
||||||
|
dependency-advisory coverage. Production images and every optional dependency
|
||||||
|
combination were not independently resolved or scanned.
|
||||||
|
|
||||||
|
## Scan coverage and limitations
|
||||||
|
|
||||||
|
Evidence directory:
|
||||||
|
`/mnt/DATA/tmp/govoplan-security-performance-20260908-gsk8jn/`.
|
||||||
|
|
||||||
|
The final `final-quick/manifest.json` captures 79 repositories, tool versions,
|
||||||
|
start/end repository fingerprints, report checksums, 168 report artifacts and
|
||||||
|
163 validated JSON/SARIF reports. It records an unchanged workspace, complete
|
||||||
|
coverage for its four required scanners, no execution errors and no missing
|
||||||
|
reports. It ran in report-only mode: exit zero does **not** mean zero warnings.
|
||||||
|
|
||||||
|
- Final production Bandit: 447,008 Python lines; 67 warnings (63 low, 4 medium),
|
||||||
|
no high findings. Ruff security rules: 54 warnings. SQL-construction warnings
|
||||||
|
were reviewed against identifier/operator validation and bound values in
|
||||||
|
DuckDB/Reporting; no injection fix was warranted there. XML import warnings
|
||||||
|
were checked: feed/BPMN input parsing uses defusedxml; stdlib imports support
|
||||||
|
types/output construction. Operator-owned fenced-run argv is not a public
|
||||||
|
arbitrary-command endpoint. Xrechnung output buffering remains a follow-up.
|
||||||
|
Assertions and error-swallowing markers remain review/maintenance warnings,
|
||||||
|
not proof that all such code is harmless.
|
||||||
|
- Final local Semgrep rules: no findings. The broader OWASP-rule pass applied
|
||||||
|
272 rules to 4,169 tracked targets. Its seven warnings recommended weakening
|
||||||
|
owner-only 0700 permissions; they were rejected as false positives. One
|
||||||
|
Calendar rule timeout was rerun with a 60-second budget: zero findings/errors.
|
||||||
|
Bash and conformance TypeScript checks passed despite two scanner-specific
|
||||||
|
parser limitations. Ignored/dependency/generated paths are not a complete
|
||||||
|
line-by-line source audit.
|
||||||
|
- Gitleaks: 79 Git histories plus 79 worktrees, 158 redacted reports, zero
|
||||||
|
detected secrets. This does not establish that deployed credentials are safe
|
||||||
|
or that formerly exposed credentials have been rotated.
|
||||||
|
- Tool versions included Semgrep 1.176.1, Bandit 1.9.4, Ruff 0.15.21 and
|
||||||
|
Gitleaks 8.30.1. The downloaded Gitleaks binary archive matched the official
|
||||||
|
release SHA-256 before execution.
|
||||||
|
- The containerized full-toolbox path could not access Docker's daemon. Its
|
||||||
|
full-mode Trivy/misconfiguration and additional OSV scans were **not** run.
|
||||||
|
A subsequent [registry-only runtime image audit](RUNTIME_IMAGE_AUDIT_2026-09-08.md)
|
||||||
|
successfully scanned nine pinned candidates and two same-minor successors
|
||||||
|
for amd64 without Docker. It found unresolved vulnerabilities and inventory
|
||||||
|
gaps; runtime publication is held. This does not complete full-toolbox,
|
||||||
|
arm64, final-runtime-image or deployment coverage.
|
||||||
|
|
||||||
|
No live application probes, database changes, file operations, mail sends,
|
||||||
|
IMAP appends, imports, notification delivery, deployments, commits or pushes
|
||||||
|
were performed. Browser tests used isolated mocked fixtures. Package installs,
|
||||||
|
builds and temporary audit-tool installation were local development operations.
|
||||||
|
|
||||||
|
## Verification and remaining work
|
||||||
|
|
||||||
|
- 209/209 browser conformance tests pass; production Core/website builds,
|
||||||
|
conformance TypeScript, 24 Core client/dependency regressions, 4 real-client
|
||||||
|
Files reload checks, and 72/72 manifest checks pass.
|
||||||
|
- Access's full 91-test suite passed before the final documentation-only update;
|
||||||
|
the final documentation suite passed all 4 tests. Other module counts appear
|
||||||
|
above. The new authentication/resource tests include demonstrated pre-fix
|
||||||
|
failures rather than only structural assertions.
|
||||||
|
- The original focused workspace run stopped at the institutional
|
||||||
|
governance/Portal fixture (`tests/test_institutional_governance_journey.py:223`,
|
||||||
|
`IndexError`). Release preparation fixes its mixed clocks using the existing
|
||||||
|
temporal context, retaining validity-boundary exclusions; 7 journey tests and
|
||||||
|
ambient-year checks pass. Tracked in
|
||||||
|
[Meta #50](https://git.add-ideas.de/GovOPlaN/govoplan/issues/50).
|
||||||
|
- Campaign's apparent host-path issue was ruled out by existing tracked
|
||||||
|
API/build/snapshot guards and 11 passing tests under normal initialization.
|
||||||
|
Release preparation fixes the standalone import cycle through a deferred
|
||||||
|
resolver import without changing validation rules. Fresh-process coverage,
|
||||||
|
all 11 path tests and Campaign's full 611-test suite pass.
|
||||||
|
|
||||||
|
Next coordinated work:
|
||||||
|
|
||||||
|
1. [Hard resource isolation — Core #297](https://git.add-ideas.de/GovOPlaN/govoplan-core/issues/297):
|
||||||
|
regex CPU, aggregate allocation, TAR extension metadata, bounded workers and
|
||||||
|
cancellation, followed by production-like concurrent load tests.
|
||||||
|
2. [Forced password change/recovery — Access #22](https://git.add-ideas.de/GovOPlaN/govoplan-access/issues/22):
|
||||||
|
the current flag is advisory only. Do not enable enforcement without a usable
|
||||||
|
local-password/recovery flow and external-provider rules.
|
||||||
|
3. [Bound subprocess output — Xrechnung #2](https://git.add-ideas.de/GovOPlaN/govoplan-xrechnung/issues/2):
|
||||||
|
enforce the existing 2 MiB limit while draining stdout/stderr, not afterwards.
|
||||||
|
4. [Workflow revision batching/history projection — Workflow Engine #3](https://git.add-ideas.de/GovOPlaN/govoplan-workflow-engine/issues/3):
|
||||||
|
batch evidence lookups; separately define explicit history pagination and
|
||||||
|
authorized-total semantics. Docs/notification history volumes also remain.
|
||||||
|
5. Resolve the [runtime image audit](RUNTIME_IMAGE_AUDIT_2026-09-08.md) findings
|
||||||
|
tracked in [Meta #52](https://git.add-ideas.de/GovOPlaN/govoplan/issues/52)
|
||||||
|
and coverage gaps before lifting its publication hold; complete deployment
|
||||||
|
audits, review exposed development credentials and worker quotas, and
|
||||||
|
benchmark realistic tenant sizes/concurrency. The sanctions
|
||||||
|
transport's fixed HTTPS/redirect allowlist is not a demonstrated arbitrary-URL
|
||||||
|
issue, but migration to Core's pinned egress transport remains desirable.
|
||||||
|
|
||||||
|
Operational compatibility: tenant keys relying on accidental system/wildcard
|
||||||
|
permissions must be corrected rather than weakening the guard. Extreme sparse
|
||||||
|
spreadsheets, overly deep/long archive paths and oversized padding intermediates
|
||||||
|
can now fail early with diagnostics. No stored documents or configurations were
|
||||||
|
deleted or silently migrated.
|
||||||
|
|
||||||
|
## Post-release follow-up — 2026-09-08
|
||||||
|
|
||||||
|
The findings and scanner counts above describe the original audit snapshot.
|
||||||
|
The following source fixes are subsequent to the frozen `0.1.45` composition;
|
||||||
|
they do not change its immutable tags or published package bytes.
|
||||||
|
|
||||||
|
- [Xrechnung #2](https://git.add-ideas.de/GovOPlaN/govoplan-xrechnung/issues/2)
|
||||||
|
now enforces the existing shared 2 MiB stdout/stderr limit during execution
|
||||||
|
and kills/reaps the direct validator on overflow, timeout or cancellation.
|
||||||
|
Report reads are bounded to 16 MiB plus one probe byte before interpretation.
|
||||||
|
The 30-test module suite passes; noisy-child and report-read regressions were
|
||||||
|
also demonstrated to fail against the previous source. Owning EN/DE static
|
||||||
|
documentation is updated. POSIX pipe capture is required; disk quotas,
|
||||||
|
descendant isolation and process-level CPU/memory limits remain separate work.
|
||||||
|
- [Meta #54](https://git.add-ideas.de/GovOPlaN/govoplan/issues/54) now preserves
|
||||||
|
the last command's failure status after exhausted installer retries. Twelve
|
||||||
|
isolated stage/scenario combinations cover every retry call site, success,
|
||||||
|
backoff and caller termination under `set -e`. The test is included in the
|
||||||
|
focused checks and installer CI. See the bilingual
|
||||||
|
[installer retry note](../operations/WEBUI_RELEASE_DEPENDENCY_RETRIES.md).
|
||||||
|
|
||||||
|
These are unreleased follow-up source changes, not a new runtime release or
|
||||||
|
deployment. The runtime-image hold under Meta #52 remains in force; the
|
||||||
|
historical peer-dependency workaround still needs its separate review.
|
||||||
|
|
||||||
|
Further implementation and adoption gates are tracked in the
|
||||||
|
[security follow-up](SECURITY_FOLLOWUP_2026-09-08.md), including disposable
|
||||||
|
parsing/execution workers, opt-in password recovery, workflow read projections
|
||||||
|
and the newer runtime-image evidence. The original scanner counts above remain
|
||||||
|
historical and are not silently replaced by later test results.
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"purpose": "Audit evidence only; not a release manifest or active installer configuration.",
|
||||||
|
"observed_at": "2026-09-08T03:56:47.666808+00:00",
|
||||||
|
"runtime_publication_held": true,
|
||||||
|
"website_deployment_held": true,
|
||||||
|
"scan_execution_complete": true,
|
||||||
|
"coverage_complete": false,
|
||||||
|
"scanner": {
|
||||||
|
"name": "Trivy",
|
||||||
|
"version": "0.74.0",
|
||||||
|
"binary_sha256": "d89bcc6510a267f11b773398cbf1be5520ce39f9e8b6633178c4487f05b7d791",
|
||||||
|
"database_metadata": {
|
||||||
|
"Version": 2,
|
||||||
|
"NextUpdate": "2026-09-08T19:06:01.154199291Z",
|
||||||
|
"UpdatedAt": "2026-09-07T19:06:01.154199452Z",
|
||||||
|
"DownloadedAt": "2026-09-07T23:30:53.198039224Z"
|
||||||
|
},
|
||||||
|
"source": "remote",
|
||||||
|
"scanners": [
|
||||||
|
"vuln"
|
||||||
|
],
|
||||||
|
"list_all_packages": true,
|
||||||
|
"images_executed": false,
|
||||||
|
"existing_docker_credentials_used": false,
|
||||||
|
"timeout": "8m",
|
||||||
|
"maximum_image_size": "2GB"
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"private_directory": "/home/zemion/.cache/govoplan-runtime-remediation.qfDSWHJh",
|
||||||
|
"summary_sha256": "0d44390408ab35270e4430516f77bf11aa7877334eff2ef19e11e9a863fe5c56",
|
||||||
|
"frozen_images_sha256": "d0cb156f4a88998531ec55ab950067a3f1350ded648f07650c463af101dad467",
|
||||||
|
"scanner_script_sha256": "f64c69e06efc2ad7b5a657a25aa73f9ff586e3685737d525456bcecbe3ab5f07"
|
||||||
|
},
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"name": "haproxy",
|
||||||
|
"image": "docker.io/library/haproxy:3.2.23-alpine@sha256:6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e",
|
||||||
|
"disposition": "source_default_updated_binary_runtime_verification_pending",
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"manifest_digest": "sha256:0666a2c2f41d341084ed2da85392b48cdcd766adfa28231f31305724ed5c6ea5",
|
||||||
|
"config_digest": "sha256:9621d75e50a8f26d3738ae3cdbf15e98c6aa5cd48e6baca0699492de502156f5",
|
||||||
|
"compressed_layer_bytes": 20516844,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 24
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 0,
|
||||||
|
"MEDIUM": 0,
|
||||||
|
"LOW": 0,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 0,
|
||||||
|
"unique_cves": 0,
|
||||||
|
"report_sha256": "40cfc3db74e29f09723f38698660ccdd50ac935c8d6e1e75c6e0555b3e9361fb",
|
||||||
|
"log_sha256": "46881f695780f89c037d5b0b4dc0ded9ea4e459077c7658d80b786efc5754084"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "linux/arm64",
|
||||||
|
"manifest_digest": "sha256:cd20b9dc6b4713956a2a043997001a1948167d345e7c8d5bd5ff2e667166651f",
|
||||||
|
"config_digest": "sha256:19796bff8905d4a46c9463c576203b20cac8984d41b7b01ea9cbff55e8422c34",
|
||||||
|
"compressed_layer_bytes": 20970772,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 24
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 0,
|
||||||
|
"MEDIUM": 0,
|
||||||
|
"LOW": 0,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 0,
|
||||||
|
"unique_cves": 0,
|
||||||
|
"report_sha256": "0e1326c58abf358d592fa55c94333228ce1094edf4e489fcc4ece6e32d3320f6",
|
||||||
|
"log_sha256": "397c2fbf1044cf50733d68b4b9cc4eb68211d96f1f302d5e9f8af0d11fb0de1c"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "nginx-stable",
|
||||||
|
"image": "docker.io/nginxinc/nginx-unprivileged:1.30.4-alpine@sha256:442753882674b49ae2c1de83ed67896131c0777f56df5005e356e62bc3f7e7ce",
|
||||||
|
"disposition": "candidate_pending_compatibility",
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"manifest_digest": "sha256:b8c179cd3c2ae222a873dd59fbae240fadc03836cae5198afc9e9c19919c3880",
|
||||||
|
"config_digest": "sha256:8b5953dae38d27a76bca22373bb920fd6ce8d9d7da21578d2926e678002de8a0",
|
||||||
|
"compressed_layer_bytes": 25526590,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 70
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 0,
|
||||||
|
"MEDIUM": 0,
|
||||||
|
"LOW": 0,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 0,
|
||||||
|
"unique_cves": 0,
|
||||||
|
"report_sha256": "3ad164dae3cdf891b12e41451b8a8e65a5e1688824a7c01d848e1274363e6bf9",
|
||||||
|
"log_sha256": "0f99ff3d9b4396de48655bf8299df30c14ba0c579f480d37baa7d2f6a4c11f1d"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "linux/arm64",
|
||||||
|
"manifest_digest": "sha256:b6742a0cbd749add25346658991c3da06a8e38796949df120fed78db8c512576",
|
||||||
|
"config_digest": "sha256:4d8b10f5d2ff99e7aa5f161930d8a4693a86a26f288ec8c0d4cd47f2ef5af179",
|
||||||
|
"compressed_layer_bytes": 25892370,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 70
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 0,
|
||||||
|
"MEDIUM": 0,
|
||||||
|
"LOW": 0,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 0,
|
||||||
|
"unique_cves": 0,
|
||||||
|
"report_sha256": "506fdeb97525ed8e4d9ae538c42bab7aa2217f662a7135f0f12d16d20410c7a6",
|
||||||
|
"log_sha256": "c41ef9ea091d00f18c7a097404672591bee85e7477ae17d8f5ca771d8e42b2bb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "caddy",
|
||||||
|
"image": "docker.io/library/caddy:2.11.4-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648",
|
||||||
|
"disposition": "not_selected_remaining_fixable_findings",
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"manifest_digest": "sha256:98eb57d882ccd5213d1688764db10c1ca2c58a1ca3a6717a3411ad798f7a423a",
|
||||||
|
"config_digest": "sha256:af555904a0961945f16bb323a501457b13a4f7e9bde969b145b97da80b38ecbe",
|
||||||
|
"compressed_layer_bytes": 23907283,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.23.5"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gobinary",
|
||||||
|
"packages": 146
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 1,
|
||||||
|
"HIGH": 38,
|
||||||
|
"MEDIUM": 41,
|
||||||
|
"LOW": 12,
|
||||||
|
"UNKNOWN": 23
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 39,
|
||||||
|
"unique_cves": 68,
|
||||||
|
"report_sha256": "e483352a1d5b9b97950dcf92e4dfcf4600f5b09306af3d0640b10ca229a07779",
|
||||||
|
"log_sha256": "9cd599d6dd8c101b421fdeb57036cec1f69f815d765a8bf1f850ec60061036f4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "linux/arm64",
|
||||||
|
"manifest_digest": "sha256:1172d4213087d3fc30bafc7ff2c2896180eb0c41ff7f75f315568fb36cabdcba",
|
||||||
|
"config_digest": "sha256:6b08c1b9858ca9a7d99c1da13c3695081e0e604c6cf214ca26a7ce0e2c4fd9b4",
|
||||||
|
"compressed_layer_bytes": 22722712,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.23.5"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 32
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "gobinary",
|
||||||
|
"packages": 146
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 1,
|
||||||
|
"HIGH": 38,
|
||||||
|
"MEDIUM": 41,
|
||||||
|
"LOW": 12,
|
||||||
|
"UNKNOWN": 23
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 39,
|
||||||
|
"unique_cves": 68,
|
||||||
|
"report_sha256": "20e04d820e3b27d57575ea177e523e23109fd83344cdf57e184a4d2fad27e803",
|
||||||
|
"log_sha256": "a1d9c37aa7948c137db64040d95e5930c5859a8acd71ac5bc41ff168e2b270c5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "node-lts",
|
||||||
|
"image": "docker.io/library/node:24-alpine@sha256:e67514e5d0f6c46656005e1b693b2ec9d52e80b641307de684d4a015ba7a4eaf",
|
||||||
|
"disposition": "not_selected_remaining_fixable_findings",
|
||||||
|
"platforms": [
|
||||||
|
{
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"manifest_digest": "sha256:4caaaf42195bcd6f6f3559a413b20cb8f8ad089e231ee874cf7701643966689f",
|
||||||
|
"config_digest": "sha256:ee289c69ed1ac50a5a042112ea97f132800e2dd53e832da27784f00e45b3289c",
|
||||||
|
"compressed_layer_bytes": 58486244,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node-pkg",
|
||||||
|
"packages": 146
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 6,
|
||||||
|
"MEDIUM": 11,
|
||||||
|
"LOW": 12,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 6,
|
||||||
|
"unique_cves": 19,
|
||||||
|
"report_sha256": "c927d995dde700c92027f6328dc6c273f0f1445cd8154301480757cb962e02b2",
|
||||||
|
"log_sha256": "c7dc1900cbe39c9f91e228b2770bcbd394ec2914d2b3879478295fc7f8f77ab3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"platform": "linux/arm64",
|
||||||
|
"manifest_digest": "sha256:d3724e44ee368606d753e0027eb8d2a94fc1f275e5d9e4620178a12edb655f5f",
|
||||||
|
"config_digest": "sha256:722cc1507731edf58a4c0bc3e29553c44ce5774d0849242c1b237abc79926a0a",
|
||||||
|
"compressed_layer_bytes": 58935654,
|
||||||
|
"scan_exit_code": 0,
|
||||||
|
"os": {
|
||||||
|
"Family": "alpine",
|
||||||
|
"Name": "3.24.1"
|
||||||
|
},
|
||||||
|
"inventory": [
|
||||||
|
{
|
||||||
|
"type": "alpine",
|
||||||
|
"packages": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "node-pkg",
|
||||||
|
"packages": 146
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"counts": {
|
||||||
|
"CRITICAL": 0,
|
||||||
|
"HIGH": 6,
|
||||||
|
"MEDIUM": 11,
|
||||||
|
"LOW": 12,
|
||||||
|
"UNKNOWN": 0
|
||||||
|
},
|
||||||
|
"fixable_high_critical": 6,
|
||||||
|
"unique_cves": 19,
|
||||||
|
"report_sha256": "e5f7799761f23cefc36929381b4186eeb3afb46a2a559c789d12a7eea5dc30d0",
|
||||||
|
"log_sha256": "fd24671f0da4d3b20dc8bcc2718531e6f6e19155d49bed15958965e21dd10813"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ This document preserves and normalizes product ideas and user-story notes that
|
|||||||
inform GovOPlaN without turning a private note file into a second backlog.
|
inform GovOPlaN without turning a private note file into a second backlog.
|
||||||
Gitea issues remain the source of live work state; the stable platform direction
|
Gitea issues remain the source of live work state; the stable platform direction
|
||||||
remains in [Platform Core Ideas](PLATFORM_CORE_IDEAS.md), the
|
remains in [Platform Core Ideas](PLATFORM_CORE_IDEAS.md), the
|
||||||
[Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md),
|
[Connected Governance Platform Roadmap](reference/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md),
|
||||||
and the [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md).
|
and the [Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
|
||||||
The register was reconciled on 2026-08-06 from:
|
The register was reconciled on 2026-08-06 from:
|
||||||
@@ -11,7 +11,7 @@ is not an automatic dependency of every journey.
|
|||||||
|
|
||||||
The institutional semantics and source-authority model applied to these stages
|
The institutional semantics and source-authority model applied to these stages
|
||||||
are defined in the
|
are defined in the
|
||||||
[Institutional Governance Target Architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
[Institutional Governance Target Architecture](../architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md).
|
||||||
|
|
||||||
The stages are ordered, but they are not monolithic releases. Each stage is
|
The stages are ordered, but they are not monolithic releases. Each stage is
|
||||||
delivered as small, reviewable, green increments and is complete only when its
|
delivered as small, reviewable, green increments and is complete only when its
|
||||||
@@ -39,6 +39,32 @@ identify the journey it improves, or provide security, operability, recovery,
|
|||||||
accessibility, or usability evidence that those journeys require. Work that
|
accessibility, or usability evidence that those journeys require. Work that
|
||||||
does neither stays in the backlog until a concrete consumer exists.
|
does neither stays in the backlog until a concrete consumer exists.
|
||||||
|
|
||||||
|
The maintained service-to-decision scenario is the German resident parking
|
||||||
|
permit (`Anwohnerparkausweis`), pinned by
|
||||||
|
`tests/fixtures/resident_parking_permit_journey.json`. It replaces generic
|
||||||
|
permit examples as acceptance evidence and fixes the service, exact Form
|
||||||
|
revision, digital and assisted intake, Case and Workflow handoff, formal
|
||||||
|
Decision, Postbox delivery, and Records target. Changing this flagship scenario
|
||||||
|
is a product decision; implementations may add further scenarios without
|
||||||
|
weakening or silently replacing its acceptance gates.
|
||||||
|
|
||||||
|
The reference fixes an email-link applicant-status profile. The exact
|
||||||
|
published Form revision names the linked email field and bounded expiry/request
|
||||||
|
limits. Submission issues a tracking grant, a matching request delegates mail
|
||||||
|
delivery to Notifications using a hash-only short-lived secret, and Portal
|
||||||
|
presents only the public lifecycle projection. Forms Runtime's module tests
|
||||||
|
also cover authenticated-only and permanent-link variants; the flagship keeps
|
||||||
|
email-link mode because it exercises identity minimization, delivery,
|
||||||
|
revocation, resend, expiry, and non-enumerating failure behavior in one slice.
|
||||||
|
|
||||||
|
The Case-to-payment handoff now has an executable first contract as well. The
|
||||||
|
flagship requests a fixed EUR obligation through `payments.requests`, retains
|
||||||
|
the Case and Workflow context references, proves exact replay, and reconciles a
|
||||||
|
full offline receipt against a Files-owned immutable evidence reference. This
|
||||||
|
does not simulate online checkout or accounting: provider callbacks, partial
|
||||||
|
payments, corrections, refunds, Ledger posting, and XRechnung remain separate
|
||||||
|
governed slices.
|
||||||
|
|
||||||
The Records vertical now supplies the journey's native file plan, immutable
|
The Records vertical now supplies the journey's native file plan, immutable
|
||||||
record and item revisions, chronology, close/reopen, retention calculation,
|
record and item revisions, chronology, close/reopen, retention calculation,
|
||||||
holds, appraisal, independent disposition approval, recovery-ledger evidence,
|
holds, appraisal, independent disposition approval, recovery-ledger evidence,
|
||||||
@@ -48,8 +74,12 @@ all three contribute metadata-only native Search projections that can be
|
|||||||
rebuilt from authoritative state. The executable fixtures prove those native
|
rebuilt from authoritative state. The executable fixtures prove those native
|
||||||
transitions without claiming archival custody. A persisted Workflow Engine
|
transitions without claiming archival custody. A persisted Workflow Engine
|
||||||
handoff is now reloaded through the Tasks aggregation surface and remains
|
handoff is now reloaded through the Tasks aggregation surface and remains
|
||||||
visible until the authoritative Workflow transition completes. The journey
|
visible until the authoritative Workflow transition completes. Authenticated
|
||||||
still needs pinned-composition reconstruction evidence and one target-tested
|
assisted intake now uses the same exact Form revision and validation as digital
|
||||||
|
intake while retaining purpose, authority, party, channel, accessibility,
|
||||||
|
source, correction, and payload-bound read-back evidence across a session
|
||||||
|
restart. The journey still needs browser accessibility evidence for both
|
||||||
|
channels, pinned-composition reconstruction evidence, and one target-tested
|
||||||
archive profile.
|
archive profile.
|
||||||
|
|
||||||
## Why this sequence
|
## Why this sequence
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# GovOPlaN Roadmap
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
GovOPlaN should become the connective, governance-aware operating layer of an
|
||||||
|
institution: people complete services and work without learning the module
|
||||||
|
graph, while the institution can explain authority, policy, source data,
|
||||||
|
effects, evidence, and recovery.
|
||||||
|
|
||||||
|
This is the concise product roadmap. It states durable outcomes and sequence,
|
||||||
|
not release dates or issue state. Use [Strategy Status](STRATEGY_STATUS.md) for
|
||||||
|
the current reconciliation and Gitea issues for active work. The
|
||||||
|
[detailed connected-platform vision](reference/CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md)
|
||||||
|
retains stakeholder perspectives, configuration archetypes, and the complete
|
||||||
|
outcome-story catalogue.
|
||||||
|
|
||||||
|
## Product Promise
|
||||||
|
|
||||||
|
GovOPlaN will:
|
||||||
|
|
||||||
|
1. model institutional context, responsibility, authority, and time;
|
||||||
|
2. turn incoming information into owned, reviewable human and machine work;
|
||||||
|
3. connect native and external systems without obscuring the source of truth;
|
||||||
|
4. preserve decisions, effects, records, corrections, and recovery evidence;
|
||||||
|
5. support digital, assisted, paper, message, calendar, and system channels as
|
||||||
|
paths through the same governed work; and
|
||||||
|
6. package successful configurations so institutions can adopt them without
|
||||||
|
code forks or loss of local autonomy.
|
||||||
|
|
||||||
|
It will not replace every specialist system, copy all data into one master
|
||||||
|
database, infer authority from membership, or claim production maturity from
|
||||||
|
repository breadth.
|
||||||
|
|
||||||
|
## Outcome Horizons
|
||||||
|
|
||||||
|
| Horizon | Outcome | Completion evidence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Trustworthy baseline | A pinned composition can be installed, upgraded, operated, explained, and recovered. | Signed artifacts, clean install/upgrade, provider failure tests, restore drill, coherent UI, and target evidence |
|
||||||
|
| Connected work | Intake becomes accountable work with context, assignment, review, communication, and evidence. | One digital and assisted service reaches a decision and eAkte without losing responsibility or state |
|
||||||
|
| Reusable products | Complete service, communication, and data outcomes ship as governed configuration packages. | Two materially different deployments adapt packages without code forks |
|
||||||
|
| Institutional assurance | Records, transparency, privacy, risk, regulated review, and reporting connect to real operations. | A consequential decision can be reconstructed, corrected, retained, and disclosed under policy |
|
||||||
|
| Federated ecosystem | Autonomous installations exchange signed data and configuration across explicit trust boundaries. | Paired-instance exchange, reconciliation, supported deployment profiles, and independent evidence |
|
||||||
|
|
||||||
|
## Current Sequence
|
||||||
|
|
||||||
|
The sequence is outcome-led. Shared foundation work enters when one of these
|
||||||
|
proofs needs it.
|
||||||
|
|
||||||
|
1. **Enforce the platform quality contract.** German is the reference locale;
|
||||||
|
help, accessibility, temporal browsing, purpose-aware access, retention,
|
||||||
|
institutional context, optional-module combinations, and recovery behavior
|
||||||
|
become measurable release gates.
|
||||||
|
2. **Complete governed communication.** Prove recipient selection, Campaign,
|
||||||
|
Files, Mail, function-bound Postbox delivery, acknowledgement, uncertain
|
||||||
|
outcomes, correction, filing, and recovery against a named target.
|
||||||
|
3. **Complete the monthly-data and sanctions journey.** Acquire immutable
|
||||||
|
source snapshots, validate and reconcile data interactively, preserve
|
||||||
|
lineage and review, publish reports and files, and deliver accepted results.
|
||||||
|
4. **Complete inclusive service to decision.** Accept digital or assisted
|
||||||
|
input, establish actor and purpose, persist human handoffs, decide, notify,
|
||||||
|
and reconstruct the exact eAkte under current authorization.
|
||||||
|
5. **Complete discovery and external coexistence.** Finish native PostgreSQL
|
||||||
|
search coverage, prove reauthorization and reindexing, then prove one
|
||||||
|
external product connector and one paired GovOPlaN federation exchange.
|
||||||
|
6. **Prove production operation.** Complete multi-host, provider, restore,
|
||||||
|
accessibility, volume, key-custody, and independently signed target
|
||||||
|
evidence before raising maturity claims.
|
||||||
|
|
||||||
|
## Continuous Foundation
|
||||||
|
|
||||||
|
Every journey applies the same boundaries:
|
||||||
|
|
||||||
|
- modules cooperate through versioned Core contracts and typed references;
|
||||||
|
- permissions, policy, institutional context, purpose, and current authority
|
||||||
|
are evaluated before presenting or acting on data;
|
||||||
|
- requested actions, durable intent, observed effects, unknown outcomes,
|
||||||
|
retries, reconciliation, and correction remain distinct;
|
||||||
|
- Workflow Engine coordinates stable module-owned actions and human handoffs;
|
||||||
|
it does not become a second owner of domain state;
|
||||||
|
- Files owns managed bytes, Records owns institutional filing and retention,
|
||||||
|
and source systems retain explicitly declared authority;
|
||||||
|
- focused views and product areas reduce interface complexity without granting
|
||||||
|
access or hiding material consequences;
|
||||||
|
- configuration packages include terminology, forms, policies, workflows,
|
||||||
|
views, reports, providers, documentation, migration, and evidence; and
|
||||||
|
- maturity advances from scaffold to vertical slice, reference-ready,
|
||||||
|
supported, and LTS only with evidence appropriate to each claim.
|
||||||
|
|
||||||
|
## Decision Rule
|
||||||
|
|
||||||
|
A roadmap item should answer all of the following before implementation:
|
||||||
|
|
||||||
|
1. Which real journey and actor outcome does it improve?
|
||||||
|
2. Which module or external system owns each object and source of truth?
|
||||||
|
3. Which institutional, temporal, purpose, and policy context applies?
|
||||||
|
4. Which effects, evidence, retention, failure, and recovery states result?
|
||||||
|
5. Which package and target evidence will prove the outcome?
|
||||||
|
|
||||||
|
If those answers are missing, retain the idea in the Product Input Register or
|
||||||
|
Gitea discovery work rather than opening an unbounded implementation program.
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Reconciled on | 2026-08-06 |
|
| Reconciled on | 2026-08-17 |
|
||||||
| Source scope | Local workspace manifests, source inventory, focused journey checks, signed release evidence, and live Gitea issue state |
|
| Source scope | Local workspace manifests, source inventory, focused journey checks, signed release evidence, and live Gitea issue state |
|
||||||
| Stable direction | [Platform Core Ideas](PLATFORM_CORE_IDEAS.md) and [Connected Governance Platform Roadmap](CONNECTED_GOVERNANCE_PLATFORM_ROADMAP.md) |
|
| Stable direction | [Platform Core Ideas](PLATFORM_CORE_IDEAS.md) and [Roadmap](ROADMAP.md) |
|
||||||
| Collected product input | [Product Input Register](PRODUCT_INPUT_REGISTER.md) |
|
| Collected product input | [Product Input Register](PRODUCT_INPUT_REGISTER.md) |
|
||||||
| Delivery source | Gitea issues |
|
| Delivery source | Gitea issues |
|
||||||
|
|
||||||
@@ -16,11 +16,14 @@ evidence remain authoritative for specific maturity claims.
|
|||||||
|
|
||||||
## Portfolio Snapshot
|
## Portfolio Snapshot
|
||||||
|
|
||||||
- 66 source module manifests were loadable and architecture-declared.
|
- 67 source module manifests were loadable and architecture-declared.
|
||||||
- 48 modules declared `vertical_slice`; 18 declared `scaffold`.
|
- 50 modules declared `vertical_slice`; 17 declared `scaffold`.
|
||||||
- No module declared `reference_ready`, `supported`, or `lts`.
|
- No module declared `reference_ready`, `supported`, or `lts`.
|
||||||
- The live portfolio had 133 open issues, including 37 priority-P1 items.
|
- The coordinated package version was `0.1.18`, with version alignment passing
|
||||||
- 118 open issues had no milestone, so issue labels do not yet express a
|
across all 78 release repositories.
|
||||||
|
- The live portfolio had 137 open issues: 42 priority-P1, 92 priority-P2, and
|
||||||
|
3 priority-P3 items. Every open issue had labels.
|
||||||
|
- 129 open issues had no milestone, so issue labels do not yet express a
|
||||||
reliable completion sequence on their own.
|
reliable completion sequence on their own.
|
||||||
- Three product package manifests existed: governed communication, governed
|
- Three product package manifests existed: governed communication, governed
|
||||||
data and assurance, and service to decision. None had crossed the complete
|
data and assurance, and service to decision. None had crossed the complete
|
||||||
@@ -31,20 +34,21 @@ document.
|
|||||||
|
|
||||||
## Interface And Contract Evidence
|
## Interface And Contract Evidence
|
||||||
|
|
||||||
The 2026-08-06 source inventory found:
|
The 2026-08-17 source inventory found:
|
||||||
|
|
||||||
- 1,307 UI fields and 1,291 UI actions;
|
- 1,344 UI fields and 1,331 UI actions;
|
||||||
- 8,157 stable interface declarations with no duplicate IDs;
|
- 8,412 stable interface declarations with no duplicate IDs;
|
||||||
- 43 frontend routes and 920 backend endpoints;
|
- 43 frontend routes and 943 backend endpoints;
|
||||||
- no public WebUI surfaces missing runtime declarations;
|
- no public WebUI surfaces missing runtime declarations;
|
||||||
- no stale runtime route declarations;
|
- no stale runtime route declarations;
|
||||||
- no unclassified endpoint without a static UI reference;
|
- no unclassified endpoint without a static UI reference;
|
||||||
- all 1,307 fields with a resolvable F1 context; 1,143 remain candidates for
|
- all 1,344 fields with a resolvable F1 context; 175 have statically specific
|
||||||
richer field-specific content beyond page/module fallback;
|
help and 1,169 remain candidates for richer field-specific content beyond
|
||||||
|
page/module fallback;
|
||||||
- German (`de`) as the complete reference locale and no used key missing from
|
- German (`de`) as the complete reference locale and no used key missing from
|
||||||
the required German or English catalogs;
|
the required German or English catalogs;
|
||||||
- 3 module information-governance dimensions classified as `enforced`, 1 as
|
- 3 module information-governance dimensions classified as `enforced`, 1 as
|
||||||
`partial`, and 260 as `contract_only`.
|
`partial`, and 264 as `contract_only`.
|
||||||
This is an honest platform-wide baseline, not a claim that temporal,
|
This is an honest platform-wide baseline, not a claim that temporal,
|
||||||
purpose, retention, and institutional-context adoption is complete.
|
purpose, retention, and institutional-context adoption is complete.
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ reconciliation, governed export/delivery, and browser-level handoff evidence.
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| No reference-ready product package | The platform cannot yet make a bounded supported-product claim | Complete one named target composition and evidence bundle |
|
| No reference-ready product package | The platform cannot yet make a bounded supported-product claim | Complete one named target composition and evidence bundle |
|
||||||
| Human-work spine is only an MVP | Tasks aggregates explicit work plus Workflow, Approval, and unread Postbox projections, but broad domain coverage, deadline escalation, assignment lifecycle, and focused product UX remain | Extend source providers through the three reference journeys and prove overdue/reassignment behavior in browser tests |
|
| Human-work spine is only an MVP | Tasks aggregates explicit work plus Workflow, Approval, and unread Postbox projections, but broad domain coverage, deadline escalation, assignment lifecycle, and focused product UX remain | Extend source providers through the three reference journeys and prove overdue/reassignment behavior in browser tests |
|
||||||
| Records/eAkte target integration incomplete | Native lifecycle, retention, holds, approval, recovery, and transfer simulation are implemented, but real custody is not proved | Target-test one archive/xdomea profile and complete the assisted reference journey |
|
| Records/eAkte target integration incomplete | Native lifecycle, retention, holds, approval, recovery, and transfer simulation are implemented, but real custody is not proved | Target-test one archive/xdomea profile and browser-test the now server-enforced assisted reference journey |
|
||||||
| Cross-cutting governance adoption uneven | Historical and purpose-sensitive behavior varies by module | Enforced adoption declarations and route/query/effect migration |
|
| Cross-cutting governance adoption uneven | Historical and purpose-sensitive behavior varies by module | Enforced adoption declarations and route/query/effect migration |
|
||||||
| Explicit help/accessibility depth incomplete | German/reference and F1 association gates now pass, but generic fallback remains too common | High-risk German help content and browser/a11y matrix |
|
| Explicit help/accessibility depth incomplete | German/reference and F1 association gates now pass, but generic fallback remains too common | High-risk German help content and browser/a11y matrix |
|
||||||
| Real federation absent | Cross-institution exchange remains connector-specific | Paired-instance signed exchange and reconciliation proof |
|
| Real federation absent | Cross-institution exchange remains connector-specific | Paired-instance signed exchange and reconciliation proof |
|
||||||
@@ -107,8 +111,9 @@ reconciliation, governed export/delivery, and browser-level handoff evidence.
|
|||||||
2. Complete governed communication and Postbox against a named target.
|
2. Complete governed communication and Postbox against a named target.
|
||||||
3. Complete the monthly-data flow and use it as the data foundation for
|
3. Complete the monthly-data flow and use it as the data foundation for
|
||||||
sanctions screening.
|
sanctions screening.
|
||||||
4. Complete the browser and resumable-work proof for the digital and assisted
|
4. Complete the browser proof for the digital and assisted service-to-decision
|
||||||
service-to-decision journey with its existing exact eAkte filing contracts.
|
journey; server-side assisted resume, provenance, correction, and read-back
|
||||||
|
enforcement now complement its existing exact eAkte filing contracts.
|
||||||
5. Complete native PostgreSQL search coverage for remaining journey-owned
|
5. Complete native PostgreSQL search coverage for remaining journey-owned
|
||||||
objects and prove reauthorization and reindex operations at target volume;
|
objects and prove reauthorization and reindex operations at target volume;
|
||||||
keep OpenSearch optional. Communication, Records, service-to-decision,
|
keep OpenSearch optional. Communication, Records, service-to-decision,
|
||||||
+29
-26
@@ -1,30 +1,32 @@
|
|||||||
# GovOPlaN Connected Governance Platform Roadmap
|
# GovOPlaN Detailed Connected-Platform Vision
|
||||||
|
|
||||||
## Purpose and status
|
## Purpose and status
|
||||||
|
|
||||||
This document describes the long-term product destination for GovOPlaN from an
|
This reference catalogue describes the long-term product destination from an
|
||||||
outcome and stakeholder perspective. It answers what a completely connected
|
outcome and stakeholder perspective. It preserves the detailed perspectives,
|
||||||
governance platform should enable, how the same platform can be configured for
|
configuration archetypes, stories, horizons, and maturity notes behind the
|
||||||
different institutions, and which capability horizons lead from the current
|
concise [Roadmap](../ROADMAP.md).
|
||||||
baseline to that destination.
|
|
||||||
|
|
||||||
It is a durable direction, not a release promise or a substitute for issue
|
It is not a release promise, live plan, or second status source. The concise
|
||||||
tracking. Live work state belongs in Gitea issues. The
|
roadmap owns the current durable sequence, Strategy Status owns the reconciled
|
||||||
|
state, and Gitea issues own work state. Where dated detail here differs from
|
||||||
|
those sources, those sources take precedence. The
|
||||||
[Core master roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/GOVOPLAN_MASTER_ROADMAP.md)
|
[Core master roadmap](https://git.add-ideas.de/GovOPlaN/govoplan-core/src/branch/main/docs/GOVOPLAN_MASTER_ROADMAP.md)
|
||||||
remains the technical module and wave sequence; this document supplies the
|
remains the technical module and wave sequence; this document supplies the
|
||||||
cross-product vision that sequence serves.
|
cross-product vision that sequence serves.
|
||||||
|
|
||||||
Read it together with:
|
Read it together with:
|
||||||
|
|
||||||
- the [institutional governance target architecture](INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
- the [concise product roadmap](../ROADMAP.md)
|
||||||
- the [selected reference-journey program](REFERENCE_JOURNEY_PROGRAM.md)
|
- the [institutional governance target architecture](../../architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md)
|
||||||
- the [current strategy status](STRATEGY_STATUS.md)
|
- the [selected reference-journey program](../REFERENCE_JOURNEY_PROGRAM.md)
|
||||||
- the [pinned Campaign capability and infrastructure fit assessment](CAPABILITY_AND_INFRASTRUCTURE_FIT.md)
|
- the [current strategy status](../STRATEGY_STATUS.md)
|
||||||
- the [interface pattern language](INTERFACE_PATTERN_LANGUAGE.md)
|
- the [generated, pinned Campaign capability and infrastructure fit assessment](../../evidence/snapshots/CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md)
|
||||||
- the [interface surface inventory](INTERFACE_SURFACE_INVENTORY.md)
|
- the [interface pattern language](../../architecture/INTERFACE_PATTERN_LANGUAGE.md)
|
||||||
- the [module contract and install model](MODULE_CONTRACTS_AND_INSTALLS.md)
|
- the [interface surface inventory](../../evidence/snapshots/INTERFACE_SURFACE_INVENTORY.md)
|
||||||
- the [repository and module index](REPOSITORY_INDEX.md)
|
- the [module contract and install model](../../operations/MODULE_CONTRACTS_AND_INSTALLS.md)
|
||||||
- the [Gitea issue workflow](GITEA_ISSUES.md)
|
- the [repository and module index](../../project/REPOSITORY_INDEX.md)
|
||||||
|
- the [Gitea issue workflow](../../project/GITEA_ISSUES.md)
|
||||||
|
|
||||||
### How to read this roadmap
|
### How to read this roadmap
|
||||||
|
|
||||||
@@ -42,14 +44,15 @@ Read it together with:
|
|||||||
- Use [Near-term portfolio order](#near-term-portfolio-order) for the bridge to
|
- Use [Near-term portfolio order](#near-term-portfolio-order) for the bridge to
|
||||||
implementation and [Product decisions](#product-decisions-to-make-progressively)
|
implementation and [Product decisions](#product-decisions-to-make-progressively)
|
||||||
for choices that can remain deferred.
|
for choices that can remain deferred.
|
||||||
- Use the [dated strategic review](STRATEGIC_REVIEW_2026-08-05.md) to understand
|
- Use the [dated strategic review](../../archive/2026-08/STRATEGIC_REVIEW_2026-08-05.md) to understand
|
||||||
why the current convergence and reference-journey order was chosen.
|
why the current convergence and reference-journey order was chosen.
|
||||||
|
|
||||||
### Planning ownership
|
### Planning ownership
|
||||||
|
|
||||||
| Question | Canonical source |
|
| Question | Canonical source |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| What product should GovOPlaN become, for whom, in which configurations, and through which outcome horizons? | This meta roadmap |
|
| What product should GovOPlaN become and in which durable sequence? | The concise Roadmap |
|
||||||
|
| Which stakeholder perspectives, configuration archetypes, and detailed outcome stories inform that direction? | This reference catalogue |
|
||||||
| Which module owns a capability, which technical wave should deliver it, and what implementation gates apply? | The Core master roadmap and owning-module concepts |
|
| Which module owns a capability, which technical wave should deliver it, and what implementation gates apply? | The Core master roadmap and owning-module concepts |
|
||||||
| What is actively planned, blocked, implemented, or closed now? | Gitea issues and the dated reconciliation in `STRATEGY_STATUS.md` |
|
| What is actively planned, blocked, implemented, or closed now? | Gitea issues and the dated reconciliation in `STRATEGY_STATUS.md` |
|
||||||
| What can a named composition credibly claim in a target environment? | A dated capability/infrastructure fit assessment |
|
| What can a named composition credibly claim in a target environment? | A dated capability/infrastructure fit assessment |
|
||||||
@@ -110,7 +113,7 @@ safe modules -> connected work -> reusable services -> institutional assurance -
|
|||||||
```
|
```
|
||||||
|
|
||||||
The active implementation path is the
|
The active implementation path is the
|
||||||
[Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md), selected on
|
[Reference Journey Program](../REFERENCE_JOURNEY_PROGRAM.md), selected on
|
||||||
2026-07-21. Its five stages do not replace these product horizons: they are the
|
2026-07-21. Its five stages do not replace these product horizons: they are the
|
||||||
ordered demonstrations through which the shared platform contracts and horizon
|
ordered demonstrations through which the shared platform contracts and horizon
|
||||||
gates are to be proved. Connector safety, identity/function semantics,
|
gates are to be proved. Connector safety, identity/function semantics,
|
||||||
@@ -266,7 +269,7 @@ Diagnostics minimize personal data and link to governed evidence when deeper
|
|||||||
inspection is authorized.
|
inspection is authorized.
|
||||||
|
|
||||||
The complete installation and lifecycle journey is specified in the
|
The complete installation and lifecycle journey is specified in the
|
||||||
[System Administrator Lifecycle User Story](SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md):
|
[System Administrator Lifecycle User Story](../SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md):
|
||||||
one-command Core-baseline bootstrap, signed online module installation and updates,
|
one-command Core-baseline bootstrap, signed online module installation and updates,
|
||||||
stateless scale-out, versioned configuration transfer, undo, and reproducible
|
stateless scale-out, versioned configuration transfer, undo, and reproducible
|
||||||
environment-promotion recipes.
|
environment-promotion recipes.
|
||||||
@@ -950,7 +953,7 @@ first analytical product prove Horizons 2 and 3; governed BI adds assurance and
|
|||||||
ecosystem capabilities across Horizons 3–5; collaborative documents combine
|
ecosystem capabilities across Horizons 3–5; collaborative documents combine
|
||||||
the evidence spine, service packages, and records assurance across Horizons
|
the evidence spine, service packages, and records assurance across Horizons
|
||||||
2–4. The detailed mapping and gates are in the
|
2–4. The detailed mapping and gates are in the
|
||||||
[Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md).
|
[Reference Journey Program](../REFERENCE_JOURNEY_PROGRAM.md).
|
||||||
|
|
||||||
### Current baseline: modular pilot foundations
|
### Current baseline: modular pilot foundations
|
||||||
|
|
||||||
@@ -976,7 +979,7 @@ checkouts.
|
|||||||
Priorities:
|
Priorities:
|
||||||
|
|
||||||
1. Deliver the first slices of the
|
1. Deliver the first slices of the
|
||||||
[System Administrator Lifecycle User Story](SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md):
|
[System Administrator Lifecycle User Story](../SYSTEM_ADMINISTRATOR_LIFECYCLE_USER_STORY.md):
|
||||||
a verified full-package distribution with only the Core baseline active,
|
a verified full-package distribution with only the Core baseline active,
|
||||||
first-run control plane, read-only online
|
first-run control plane, read-only online
|
||||||
module directory, and durable plan/confirm/install progress.
|
module directory, and durable plan/confirm/install progress.
|
||||||
@@ -1220,7 +1223,7 @@ provides all applicable evidence below.
|
|||||||
## Near-term portfolio order
|
## Near-term portfolio order
|
||||||
|
|
||||||
This order is now selected. Detailed slices and gates are in the
|
This order is now selected. Detailed slices and gates are in the
|
||||||
[Reference Journey Program](REFERENCE_JOURNEY_PROGRAM.md). Workflow Engine and
|
[Reference Journey Program](../REFERENCE_JOURNEY_PROGRAM.md). Workflow Engine and
|
||||||
the optional editor may support these stages, but Workflow work enters the
|
the optional editor may support these stages, but Workflow work enters the
|
||||||
portfolio only through an explicit bounded package or reference journey.
|
portfolio only through an explicit bounded package or reference journey.
|
||||||
|
|
||||||
@@ -1357,6 +1360,6 @@ evidence remains—and the product can prove that explanation at runtime.
|
|||||||
|
|
||||||
The volatile release and backlog appendix that originally accompanied this
|
The volatile release and backlog appendix that originally accompanied this
|
||||||
roadmap has been removed so the durable direction cannot become a competing
|
roadmap has been removed so the durable direction cannot become a competing
|
||||||
status source. The [Strategic Review 2026-08-05](STRATEGIC_REVIEW_2026-08-05.md)
|
status source. The [Strategic Review 2026-08-05](../../archive/2026-08/STRATEGIC_REVIEW_2026-08-05.md)
|
||||||
retains the dated assessment and reasoning. Current reconciliation belongs in
|
retains the dated assessment and reasoning. Current reconciliation belongs in
|
||||||
[Strategy Status](STRATEGY_STATUS.md), and live work state belongs in Gitea.
|
[Strategy Status](../STRATEGY_STATUS.md), and live work state belongs in Gitea.
|
||||||
@@ -4,84 +4,89 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan"
|
name = "govoplan"
|
||||||
version = "0.1.18"
|
version = "0.1.46"
|
||||||
description = "Developer convenience package for a versioned GovOPlaN composition"
|
description = "Developer convenience package for a versioned GovOPlaN composition"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { text = "AGPL-3.0-or-later" }
|
license = { text = "AGPL-3.0-or-later" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core[server]==0.1.18",
|
"govoplan-core[server]==0.1.46",
|
||||||
"govoplan-tenancy==0.1.18",
|
"govoplan-tenancy==0.1.22",
|
||||||
"govoplan-organizations==0.1.18",
|
"govoplan-organizations==0.1.21",
|
||||||
"govoplan-identity==0.1.18",
|
"govoplan-identity==0.1.21",
|
||||||
"govoplan-idm==0.1.18",
|
"govoplan-idm==0.1.26",
|
||||||
"govoplan-access==0.1.18",
|
"govoplan-access==0.1.25",
|
||||||
"govoplan-admin==0.1.18",
|
"govoplan-admin==0.1.23",
|
||||||
"govoplan-policy==0.1.18",
|
"govoplan-policy==0.1.23",
|
||||||
"govoplan-audit==0.1.18",
|
"govoplan-audit==0.1.20",
|
||||||
"govoplan-dashboard==0.1.18",
|
"govoplan-dashboard==0.1.20",
|
||||||
"govoplan-files==0.1.18",
|
"govoplan-files==0.1.27",
|
||||||
"govoplan-mail==0.1.18",
|
"govoplan-mail==0.1.28",
|
||||||
"govoplan-campaign==0.1.18",
|
"govoplan-campaign==0.1.29",
|
||||||
"govoplan-calendar==0.1.18",
|
"govoplan-calendar==0.1.24",
|
||||||
"govoplan-docs==0.1.18",
|
"govoplan-docs==0.1.23",
|
||||||
"govoplan-ops==0.1.18",
|
"govoplan-ops==0.1.22",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
full = [
|
full = [
|
||||||
"govoplan-addresses==0.1.18",
|
"govoplan-addresses==0.1.23",
|
||||||
"govoplan-approvals==0.1.18",
|
"govoplan-approvals==0.1.21",
|
||||||
"govoplan-assets==0.1.18",
|
"govoplan-assets==0.1.20",
|
||||||
"govoplan-booking==0.1.18",
|
"govoplan-booking==0.1.20",
|
||||||
"govoplan-cases==0.1.18",
|
"govoplan-cases==0.1.25",
|
||||||
"govoplan-certificates==0.1.18",
|
"govoplan-certificates==0.1.20",
|
||||||
"govoplan-committee==0.1.18",
|
"govoplan-committee==0.1.22",
|
||||||
"govoplan-connectors==0.1.18",
|
"govoplan-connectors==0.1.27",
|
||||||
"govoplan-consultation==0.1.18",
|
"govoplan-consultation==0.1.20",
|
||||||
"govoplan-contracts==0.1.18",
|
"govoplan-contracts==0.1.20",
|
||||||
"govoplan-dataflow==0.1.18",
|
"govoplan-dataflow==0.1.25",
|
||||||
"govoplan-datasources==0.1.18",
|
"govoplan-datasources==0.1.26",
|
||||||
"govoplan-decisions==0.1.18",
|
"govoplan-decisions==0.1.19",
|
||||||
"govoplan-dist-lists==0.1.18",
|
"govoplan-dist-lists==0.1.21",
|
||||||
"govoplan-encryption==0.1.18",
|
"govoplan-dms==0.1.20",
|
||||||
"govoplan-evaluation==0.1.18",
|
"govoplan-encryption==0.1.20",
|
||||||
"govoplan-facilities==0.1.18",
|
"govoplan-erp==0.1.20",
|
||||||
"govoplan-forms==0.1.18",
|
"govoplan-evaluation==0.1.20",
|
||||||
"govoplan-forms-runtime==0.1.18",
|
"govoplan-facilities==0.1.20",
|
||||||
"govoplan-grants==0.1.18",
|
"govoplan-fit-connect==0.1.20",
|
||||||
"govoplan-helpdesk==0.1.18",
|
"govoplan-forms==0.1.23",
|
||||||
"govoplan-identity-trust==0.1.18",
|
"govoplan-forms-runtime==0.1.22",
|
||||||
"govoplan-inspections==0.1.18",
|
"govoplan-grants==0.1.20",
|
||||||
"govoplan-learning==0.1.18",
|
"govoplan-helpdesk==0.1.21",
|
||||||
"govoplan-mandates==0.1.18",
|
"govoplan-identity-trust==0.1.21",
|
||||||
"govoplan-notifications==0.1.18",
|
"govoplan-inspections==0.1.20",
|
||||||
"govoplan-parties==0.1.18",
|
"govoplan-learning==0.1.20",
|
||||||
"govoplan-permits==0.1.18",
|
"govoplan-mandates==0.1.19",
|
||||||
"govoplan-poll==0.1.18",
|
"govoplan-notifications==0.1.20",
|
||||||
"govoplan-portal==0.1.18",
|
"govoplan-parties==0.1.19",
|
||||||
"govoplan-postbox==0.1.18",
|
"govoplan-payments==0.1.22",
|
||||||
"govoplan-procurement==0.1.18",
|
"govoplan-permits==0.1.20",
|
||||||
"govoplan-projects==0.1.18",
|
"govoplan-poll==0.1.20",
|
||||||
"govoplan-quick-access==0.1.18",
|
"govoplan-portal==0.1.22",
|
||||||
"govoplan-records==0.1.19",
|
"govoplan-postbox==0.1.23",
|
||||||
"govoplan-reporting==0.1.18",
|
"govoplan-procurement==0.1.20",
|
||||||
"govoplan-resources==0.1.18",
|
"govoplan-projects==0.1.20",
|
||||||
"govoplan-rest==0.1.18",
|
"govoplan-quick-access==0.1.21",
|
||||||
"govoplan-risk-compliance==0.1.18",
|
"govoplan-records==0.1.24",
|
||||||
"govoplan-scheduling==0.1.18",
|
"govoplan-reporting==0.1.22",
|
||||||
"govoplan-search==0.1.18",
|
"govoplan-resources==0.1.20",
|
||||||
"govoplan-services==0.1.18",
|
"govoplan-rest==0.1.19",
|
||||||
"govoplan-soap==0.1.18",
|
"govoplan-risk-compliance==0.1.21",
|
||||||
"govoplan-tasks==0.1.19",
|
"govoplan-scheduling==0.1.22",
|
||||||
"govoplan-templates==0.1.18",
|
"govoplan-search==0.1.20",
|
||||||
"govoplan-tickets==0.1.18",
|
"govoplan-services==0.1.19",
|
||||||
"govoplan-transparency==0.1.18",
|
"govoplan-soap==0.1.19",
|
||||||
"govoplan-views==0.1.18",
|
"govoplan-tasks==0.1.23",
|
||||||
"govoplan-voting==0.1.18",
|
"govoplan-templates==0.1.22",
|
||||||
"govoplan-wiki==0.1.18",
|
"govoplan-tickets==0.1.23",
|
||||||
"govoplan-workflow==0.1.18",
|
"govoplan-transparency==0.1.20",
|
||||||
"govoplan-workflow-engine==0.1.18",
|
"govoplan-views==0.1.22",
|
||||||
|
"govoplan-voting==0.1.21",
|
||||||
|
"govoplan-wiki==0.1.22",
|
||||||
|
"govoplan-workflow==0.1.23",
|
||||||
|
"govoplan-workflow-engine==0.1.21",
|
||||||
|
"govoplan-xrechnung==0.1.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -9,6 +9,34 @@ resolution -> approval/deliberation -> formal Decision -> observed delivery
|
|||||||
effect -> record and review references
|
effect -> record and review references
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The maintained concrete scenario is a German resident parking permit
|
||||||
|
(`Anwohnerparkausweis`). Its versioned fixture is
|
||||||
|
`tests/fixtures/resident_parking_permit_journey.json`. It pins the service,
|
||||||
|
exact Form revision, resident inputs, digital and assisted channels, Case type,
|
||||||
|
human review handoff, formal outcome, Postbox delivery channel, and Records
|
||||||
|
filing/retention target. Generic permit wording is no longer acceptance
|
||||||
|
evidence for this package.
|
||||||
|
|
||||||
|
The package is now executable rather than metadata-only. Its Access fragments
|
||||||
|
create the bounded resident-permit clerk role, collect only the tenant-local
|
||||||
|
responsibility group key and name, create that group, and bind the role. The
|
||||||
|
Forms-owned fragment carries a digest-bound German-reference application schema
|
||||||
|
and imports it as a tenant-local draft with source provenance. Reapplying the
|
||||||
|
same source digest is a no-op; replacing an unrelated local definition remains
|
||||||
|
blocked unless the reviewed package explicitly selects a new revision. Normal
|
||||||
|
Forms review and publication are still required before the definition can serve
|
||||||
|
new applications. The Workflow Engine-owned fragment materializes and activates
|
||||||
|
the tenant review baseline, resolves the chosen responsibility group into each
|
||||||
|
human handoff, and preserves the evidence, decision, and EUR 30 payment-review
|
||||||
|
steps as a replay-safe contributed definition.
|
||||||
|
|
||||||
|
Services, Cases, Payments, Tasks, and the optional delivery and Records modules
|
||||||
|
already execute the pinned journey through their runtime
|
||||||
|
contracts, but their reusable configuration fragments are not yet claimed by
|
||||||
|
this package. Until those module-owned configuration providers are added, the
|
||||||
|
package preflight deliberately distinguishes the installed runtime composition
|
||||||
|
from the Access, Forms, and Workflow configurations it can currently materialize.
|
||||||
|
|
||||||
An installed Forms and Forms Runtime pair adds an alternative governed entry
|
An installed Forms and Forms Runtime pair adds an alternative governed entry
|
||||||
path before case/workflow handoff:
|
path before case/workflow handoff:
|
||||||
|
|
||||||
@@ -17,6 +45,14 @@ Service discovery -> exact Form revision -> validated draft/submission
|
|||||||
-> receipt and handoff evidence -> Case or Workflow owner
|
-> receipt and handoff evidence -> Case or Workflow owner
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The assisted path now creates an authenticated, resumable session against that
|
||||||
|
same exact Form revision. It records channel, affected and represented parties,
|
||||||
|
authority, purpose, notice, responsible function, language, accessibility
|
||||||
|
support, and field provenance. Submission fails closed until an immutable
|
||||||
|
read-back outcome matches the current revision, values, attachments, and
|
||||||
|
signatures. Saving a correction therefore requires a fresh confirmation rather
|
||||||
|
than silently reusing old evidence.
|
||||||
|
|
||||||
Services, Cases, Parties, Mandates, Committee, and Decisions retain immutable
|
Services, Cases, Parties, Mandates, Committee, and Decisions retain immutable
|
||||||
provider-owned revisions for the parts they own. Portal, Cases, and Committee
|
provider-owned revisions for the parts they own. Portal, Cases, and Committee
|
||||||
consume capabilities for cross-module semantics only. The package does not
|
consume capabilities for cross-module semantics only. The package does not
|
||||||
@@ -51,11 +87,26 @@ The executable fixture in
|
|||||||
`tests/test_institutional_governance_journey.py` proves SQL-backed Service,
|
`tests/test_institutional_governance_journey.py` proves SQL-backed Service,
|
||||||
Case, Party, Mandate, Committee meeting/agendum/vote/minute, and Decision state.
|
Case, Party, Mandate, Committee meeting/agendum/vote/minute, and Decision state.
|
||||||
`tests/test_institutional_service_journey.py` separately proves exact Portal
|
`tests/test_institutional_service_journey.py` separately proves exact Portal
|
||||||
Form launch, persisted submission provenance, idempotent replay, and a durable
|
Form launch, persisted submission provenance, idempotent replay, resumable
|
||||||
Workflow handoff that remains visible through Tasks after the database session
|
assisted intake with enforced read-back evidence, and a durable Workflow handoff
|
||||||
is reopened and disappears only after the Workflow Engine records completion.
|
that remains visible through Tasks after the database session is reopened and
|
||||||
|
disappears only after the Workflow Engine records completion.
|
||||||
|
Core's production-component browser conformance suite additionally executes the
|
||||||
|
German self-service and assisted Anwohnerparkausweis paths at desktop and mobile
|
||||||
|
widths. It proves native keyboard order, accessible names and landmarks, WCAG
|
||||||
|
2.1 A/AA automation, responsive geometry, first-draft persistence, and mixed
|
||||||
|
per-field person/document/system provenance. Physical screen-reader spot checks
|
||||||
|
remain target-environment release evidence.
|
||||||
Module-level Records source tests prove exact Form submission, Case revision,
|
Module-level Records source tests prove exact Form submission, Case revision,
|
||||||
and Decision revision filing. Target-environment browser accessibility,
|
and Decision revision filing. Target-environment browser accessibility,
|
||||||
production identity and delivery, a named archive profile, and recovery evidence
|
production identity and delivery, a named archive profile, and recovery evidence
|
||||||
are still required before this product package may claim `reference_ready`
|
are still required before this product package may claim `reference_ready`
|
||||||
maturity.
|
maturity.
|
||||||
|
|
||||||
|
The generic package orchestrator stops at the first provider apply or health
|
||||||
|
blocker. Access and Forms may commit in separate provider transactions, so the
|
||||||
|
operator must retain the reviewed pre-apply database snapshot until verification
|
||||||
|
is complete. The Admin result reports no-op, snapshot-required, or partial-apply
|
||||||
|
recovery state and never describes this as atomic cross-module undo. Exported
|
||||||
|
fragments carry source/module/operator/scope provenance; supplied values and
|
||||||
|
credentials are not serialized into that provenance.
|
||||||
|
|||||||
@@ -8,36 +8,366 @@
|
|||||||
"category": "institutional-governance",
|
"category": "institutional-governance",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"required_modules": [
|
"required_modules": [
|
||||||
|
{"module_id": "access"},
|
||||||
{"module_id": "audit"},
|
{"module_id": "audit"},
|
||||||
{"module_id": "cases"},
|
{"module_id": "cases"},
|
||||||
{"module_id": "decisions"},
|
{"module_id": "decisions"},
|
||||||
|
{"module_id": "forms"},
|
||||||
|
{"module_id": "forms_runtime"},
|
||||||
{"module_id": "mandates"},
|
{"module_id": "mandates"},
|
||||||
{"module_id": "parties"},
|
{"module_id": "parties"},
|
||||||
|
{"module_id": "payments"},
|
||||||
{"module_id": "policy"},
|
{"module_id": "policy"},
|
||||||
{"module_id": "portal"},
|
{"module_id": "portal"},
|
||||||
{"module_id": "services"}
|
{"module_id": "services"},
|
||||||
|
{"module_id": "tasks"},
|
||||||
|
{"module_id": "workflow_engine"}
|
||||||
],
|
],
|
||||||
"required_capabilities": [
|
"required_capabilities": [
|
||||||
|
"access.configuration",
|
||||||
"cases.party_context",
|
"cases.party_context",
|
||||||
"cases.service_intake",
|
"cases.service_intake",
|
||||||
"decisions.registry",
|
"decisions.registry",
|
||||||
|
"forms.configuration",
|
||||||
|
"forms.definitions",
|
||||||
"mandates.resolver",
|
"mandates.resolver",
|
||||||
"parties.resolver",
|
"parties.resolver",
|
||||||
|
"payments.requests",
|
||||||
"portal.service_directory",
|
"portal.service_directory",
|
||||||
"services.availability",
|
"services.availability",
|
||||||
"services.definitions"
|
"services.definitions",
|
||||||
|
"workflow.configuration"
|
||||||
],
|
],
|
||||||
"optional_modules": [
|
"optional_modules": [
|
||||||
{"module_id": "approvals"},
|
{"module_id": "approvals"},
|
||||||
{"module_id": "committee"},
|
{"module_id": "committee"},
|
||||||
{"module_id": "files"},
|
{"module_id": "files"},
|
||||||
{"module_id": "forms"},
|
|
||||||
{"module_id": "forms_runtime"},
|
|
||||||
{"module_id": "postbox"},
|
{"module_id": "postbox"},
|
||||||
{"module_id": "records"},
|
{"module_id": "records"},
|
||||||
{"module_id": "search"},
|
{"module_id": "search"}
|
||||||
{"module_id": "tasks"},
|
],
|
||||||
{"module_id": "workflow_engine"}
|
"data_requirements": [
|
||||||
|
{
|
||||||
|
"key": "responsible_group_slug",
|
||||||
|
"label": "Responsible permit group key",
|
||||||
|
"data_type": "string",
|
||||||
|
"required": true,
|
||||||
|
"secret": false,
|
||||||
|
"description": "Tenant-local stable key for the group that reviews resident parking permit applications."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "responsible_group_name",
|
||||||
|
"label": "Responsible permit group name",
|
||||||
|
"data_type": "string",
|
||||||
|
"required": true,
|
||||||
|
"secret": false,
|
||||||
|
"description": "Human-readable tenant-local name shown for the responsible permit group."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fragments": [
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"fragment_type": "roles",
|
||||||
|
"fragment_id": "resident-parking-permit-clerk",
|
||||||
|
"payload": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"slug": "resident-parking-permit-clerk",
|
||||||
|
"name": "Resident parking permit clerk",
|
||||||
|
"description": "Reviews resident parking permit submissions, workflow handoffs, cases, decisions, and payment evidence.",
|
||||||
|
"permissions": [
|
||||||
|
"cases:case:read",
|
||||||
|
"cases:case:create",
|
||||||
|
"cases:case:update",
|
||||||
|
"decisions:decision:read",
|
||||||
|
"decisions:decision:write",
|
||||||
|
"forms:definition:read",
|
||||||
|
"forms_runtime:workspace:read",
|
||||||
|
"forms_runtime:workspace:write",
|
||||||
|
"payments:payment:read",
|
||||||
|
"payments:payment:write",
|
||||||
|
"tasks:item:read",
|
||||||
|
"tasks:item:write",
|
||||||
|
"workflow:definition:read",
|
||||||
|
"workflow:instance:read",
|
||||||
|
"workflow:instance:start",
|
||||||
|
"workflow:instance:transition"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"fragment_type": "groups",
|
||||||
|
"fragment_id": "resident-parking-permit-responsibility",
|
||||||
|
"payload": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"slug": {"$data": "responsible_group_slug"},
|
||||||
|
"name": {"$data": "responsible_group_name"},
|
||||||
|
"description": "Tenant-local responsibility group for the resident parking permit reference journey."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "access",
|
||||||
|
"fragment_type": "group_role_assignments",
|
||||||
|
"fragment_id": "resident-parking-permit-clerk-assignment",
|
||||||
|
"payload": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"group": {"$data": "responsible_group_slug"},
|
||||||
|
"role": "resident-parking-permit-clerk"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "forms",
|
||||||
|
"fragment_type": "definition",
|
||||||
|
"fragment_id": "resident-parking-permit-application",
|
||||||
|
"payload": {
|
||||||
|
"on_conflict": "new_revision",
|
||||||
|
"change_reason": "Install the reviewed resident parking permit reference form.",
|
||||||
|
"fragment": {
|
||||||
|
"kind": "govoplan.forms.definition",
|
||||||
|
"contract_version": "0.1.0",
|
||||||
|
"definition": {
|
||||||
|
"reference": {
|
||||||
|
"kind": "form",
|
||||||
|
"owner_module": "forms",
|
||||||
|
"object_id": "resident-parking-permit-application",
|
||||||
|
"tenant_id": "reference-package",
|
||||||
|
"version": "3",
|
||||||
|
"valid_at": null,
|
||||||
|
"label": null
|
||||||
|
},
|
||||||
|
"key": "resident-parking-permit-application",
|
||||||
|
"temporal": {
|
||||||
|
"revision": "3",
|
||||||
|
"valid_from": null,
|
||||||
|
"valid_to": null,
|
||||||
|
"recorded_at": "2026-08-22T00:00:00+00:00",
|
||||||
|
"superseded_at": null,
|
||||||
|
"change_reason": "Reference package revision."
|
||||||
|
},
|
||||||
|
"title": "Resident parking permit",
|
||||||
|
"description": "Apply for a resident parking permit through a digital or assisted channel.",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "applicant_name",
|
||||||
|
"label": "Name",
|
||||||
|
"value_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"help_text": null,
|
||||||
|
"options": [],
|
||||||
|
"constraints": {"min_length": 2, "max_length": 200},
|
||||||
|
"default_value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "applicant_email",
|
||||||
|
"label": "Email",
|
||||||
|
"value_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"help_text": null,
|
||||||
|
"options": [],
|
||||||
|
"constraints": {"format": "email"},
|
||||||
|
"default_value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "residence_address",
|
||||||
|
"label": "Primary residence",
|
||||||
|
"value_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"help_text": null,
|
||||||
|
"options": [],
|
||||||
|
"constraints": {"max_length": 500},
|
||||||
|
"default_value": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "licence_plate",
|
||||||
|
"label": "Licence plate",
|
||||||
|
"value_type": "text",
|
||||||
|
"required": true,
|
||||||
|
"help_text": null,
|
||||||
|
"options": [],
|
||||||
|
"constraints": {"max_length": 20},
|
||||||
|
"default_value": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publication_state": "published",
|
||||||
|
"allow_drafts": true,
|
||||||
|
"max_attachments": 4,
|
||||||
|
"signature_requirement": "none",
|
||||||
|
"policy_refs": [
|
||||||
|
"law:resident-parking-permit",
|
||||||
|
"records:resident-parking-permit"
|
||||||
|
],
|
||||||
|
"handoff_kinds": ["case", "workflow"],
|
||||||
|
"metadata": {},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"key": "application",
|
||||||
|
"title": "Application",
|
||||||
|
"description": null,
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"key": "applicant-and-vehicle",
|
||||||
|
"title": "Applicant and vehicle",
|
||||||
|
"description": null,
|
||||||
|
"field_keys": [
|
||||||
|
"applicant_name",
|
||||||
|
"applicant_email",
|
||||||
|
"residence_address",
|
||||||
|
"licence_plate"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localizations": [
|
||||||
|
{
|
||||||
|
"locale": "de",
|
||||||
|
"title": "Anwohnerparkausweis beantragen",
|
||||||
|
"description": "Einen Anwohnerparkausweis digital oder mit Unterstützung beantragen.",
|
||||||
|
"field_labels": {
|
||||||
|
"applicant_name": "Name",
|
||||||
|
"applicant_email": "E-Mail-Adresse",
|
||||||
|
"residence_address": "Hauptwohnsitz",
|
||||||
|
"licence_plate": "Kennzeichen"
|
||||||
|
},
|
||||||
|
"field_help_texts": {},
|
||||||
|
"option_labels": {},
|
||||||
|
"page_titles": {"application": "Antrag"},
|
||||||
|
"section_titles": {
|
||||||
|
"applicant-and-vehicle": "Antragstellende Person und Fahrzeug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fallback_locale": "de"
|
||||||
|
},
|
||||||
|
"definition_sha256": "7dc108002d532c07e5e7f3b14029a9d4deb3836ebb65d97fb6b51166a70e0ed4",
|
||||||
|
"provenance": {
|
||||||
|
"owner_module": "forms",
|
||||||
|
"tenant_id": "reference-package",
|
||||||
|
"form_id": "resident-parking-permit-application",
|
||||||
|
"revision": "3",
|
||||||
|
"exported_at": "2026-08-22T12:00:00+00:00",
|
||||||
|
"exported_by": "GovOPlaN reference package"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "workflow_engine",
|
||||||
|
"fragment_type": "workflow_definitions",
|
||||||
|
"fragment_id": "resident-parking-permit-workflow",
|
||||||
|
"payload": {
|
||||||
|
"schema_version": 1,
|
||||||
|
"origin_module_id": "configuration_package.service_to_decision",
|
||||||
|
"origin_module_version": "0.1.0",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"definition_key": "resident-parking-permit-review",
|
||||||
|
"name": "Resident parking permit review",
|
||||||
|
"description": "Review evidence, record the formal decision, and verify payment evidence for the resident parking permit reference journey.",
|
||||||
|
"scope_type": "tenant",
|
||||||
|
"allow_start": true,
|
||||||
|
"allow_reuse": true,
|
||||||
|
"allow_automation": false,
|
||||||
|
"execution_mode": "guided",
|
||||||
|
"activate_on_install": true,
|
||||||
|
"graph": {
|
||||||
|
"schema_version": 1,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "start",
|
||||||
|
"type": "workflow.start.manual",
|
||||||
|
"label": "Application received",
|
||||||
|
"config": {"input_schema_ref": "form:resident-parking-permit-application"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review-evidence",
|
||||||
|
"type": "workflow.review",
|
||||||
|
"label": "Review application evidence",
|
||||||
|
"config": {
|
||||||
|
"title": "Review resident parking permit evidence",
|
||||||
|
"reviewer": {
|
||||||
|
"kind": "group",
|
||||||
|
"id": {"$data": "responsible_group_slug"},
|
||||||
|
"label": {"$data": "responsible_group_name"}
|
||||||
|
},
|
||||||
|
"due_after": "P14D",
|
||||||
|
"required_evidence": [
|
||||||
|
"identity",
|
||||||
|
"primary_residence",
|
||||||
|
"vehicle_registration"
|
||||||
|
],
|
||||||
|
"view_surface_ids": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "record-decision",
|
||||||
|
"type": "workflow.activity",
|
||||||
|
"label": "Record formal decision",
|
||||||
|
"config": {
|
||||||
|
"title": "Record the resident parking permit decision",
|
||||||
|
"instructions": "Record the operative result, reasoning, legal basis, remedy, and exact evidence references through the Decisions capability.",
|
||||||
|
"assignee": {
|
||||||
|
"kind": "group",
|
||||||
|
"id": {"$data": "responsible_group_slug"},
|
||||||
|
"label": {"$data": "responsible_group_name"}
|
||||||
|
},
|
||||||
|
"due_after": "P7D",
|
||||||
|
"view_surface_ids": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "verify-payment",
|
||||||
|
"type": "workflow.activity",
|
||||||
|
"label": "Verify payment evidence",
|
||||||
|
"config": {
|
||||||
|
"title": "Verify the resident parking permit fee",
|
||||||
|
"instructions": "Verify the EUR 30.00 obligation, immutable receipt evidence, currency, amount, and transaction reference before completion.",
|
||||||
|
"assignee": {
|
||||||
|
"kind": "group",
|
||||||
|
"id": {"$data": "responsible_group_slug"},
|
||||||
|
"label": {"$data": "responsible_group_name"}
|
||||||
|
},
|
||||||
|
"due_after": "P14D",
|
||||||
|
"view_surface_ids": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "completed",
|
||||||
|
"type": "workflow.end.completed",
|
||||||
|
"label": "Permit journey complete",
|
||||||
|
"config": {"output_mapping": {}}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{"id": "start-review", "source": "start", "target": "review-evidence"},
|
||||||
|
{"id": "review-decision", "source": "review-evidence", "source_port": "approved", "target": "record-decision"},
|
||||||
|
{"id": "decision-payment", "source": "record-decision", "target": "verify-payment"},
|
||||||
|
{"id": "payment-completed", "source": "verify-payment", "target": "completed"}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"reference_journey": "resident-parking-permit",
|
||||||
|
"locale": "de-DE",
|
||||||
|
"payment_amount_minor": 3000,
|
||||||
|
"payment_currency": "EUR"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"reference_package": "product.service-to-decision",
|
||||||
|
"form_id": "resident-parking-permit-application"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"evidence": [
|
"evidence": [
|
||||||
{
|
{
|
||||||
@@ -45,6 +375,11 @@
|
|||||||
"reference": "packages/product/service-to-decision/README.md",
|
"reference": "packages/product/service-to-decision/README.md",
|
||||||
"summary": "Defines the package boundary, authority path, recovery contract, and known operational limits."
|
"summary": "Defines the package boundary, authority path, recovery contract, and known operational limits."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"kind": "target_test",
|
||||||
|
"reference": "tests/fixtures/resident_parking_permit_journey.json",
|
||||||
|
"summary": "Pins the resident parking permit actors, channels, exact inputs, work item, formal outcome, filing target, and remaining manual acceptance gates."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"kind": "target_test",
|
"kind": "target_test",
|
||||||
"reference": "tests/test_institutional_governance_journey.py",
|
"reference": "tests/test_institutional_governance_journey.py",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ bandit>=1.8,<2
|
|||||||
click>=8.3.3
|
click>=8.3.3
|
||||||
filelock>=3.20.3
|
filelock>=3.20.3
|
||||||
idna>=3.15
|
idna>=3.15
|
||||||
pip>=26.1.2
|
pip>=26.2
|
||||||
pip-audit>=2.9,<3
|
pip-audit>=2.9,<3
|
||||||
python-multipart>=0.0.31
|
python-multipart>=0.0.31
|
||||||
radon>=6,<7
|
radon>=6,<7
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
-e ../govoplan-parties
|
-e ../govoplan-parties
|
||||||
-e ../govoplan-mandates
|
-e ../govoplan-mandates
|
||||||
-e ../govoplan-decisions
|
-e ../govoplan-decisions
|
||||||
|
-e ../govoplan-payments
|
||||||
-e ../govoplan-connectors
|
-e ../govoplan-connectors
|
||||||
-e ../govoplan-datasources
|
-e ../govoplan-datasources
|
||||||
-e ../govoplan-dataflow
|
-e ../govoplan-dataflow
|
||||||
@@ -55,7 +56,7 @@ httpx2>=2.5,<3
|
|||||||
filelock>=3.20.3
|
filelock>=3.20.3
|
||||||
idna>=3.15
|
idna>=3.15
|
||||||
jsonschema>=4,<5
|
jsonschema>=4,<5
|
||||||
pip>=26.1.2
|
pip>=26.2
|
||||||
pip-audit>=2.9,<3
|
pip-audit>=2.9,<3
|
||||||
pytest>=9.0.3,<10
|
pytest>=9.0.3,<10
|
||||||
pygments>=2.20,<3
|
pygments>=2.20,<3
|
||||||
|
|||||||
+15
-15
@@ -1,18 +1,18 @@
|
|||||||
# Whole-product release install from immutable, independently versioned module tags.
|
# Whole-product release install from immutable, independently versioned module tags.
|
||||||
# Only add a module after its referenced tag has been published.
|
# Only add a module after its referenced tag has been published.
|
||||||
../govoplan-core[server]
|
../govoplan-core[server]
|
||||||
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.18
|
govoplan-tenancy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tenancy.git@v0.1.22
|
||||||
govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.18
|
govoplan-organizations @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git@v0.1.21
|
||||||
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.18
|
govoplan-identity @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-identity.git@v0.1.21
|
||||||
govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.18
|
govoplan-idm @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git@v0.1.26
|
||||||
govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.18
|
govoplan-access @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git@v0.1.25
|
||||||
govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.18
|
govoplan-admin @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git@v0.1.23
|
||||||
govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.18
|
govoplan-policy @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git@v0.1.23
|
||||||
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.18
|
govoplan-audit @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git@v0.1.20
|
||||||
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.18
|
govoplan-dashboard @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git@v0.1.20
|
||||||
govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.18
|
govoplan-files @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git@v0.1.27
|
||||||
govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.18
|
govoplan-mail @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git@v0.1.28
|
||||||
govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.18
|
govoplan-campaign @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git@v0.1.29
|
||||||
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.18
|
govoplan-calendar @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git@v0.1.24
|
||||||
govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.18
|
govoplan-docs @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git@v0.1.23
|
||||||
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.18
|
govoplan-ops @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git@v0.1.22
|
||||||
|
|||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
{
|
||||||
|
"id": "resident-parking-permit-berlin-style-reference",
|
||||||
|
"title": "Resident parking permit",
|
||||||
|
"title_de": "Anwohnerparkausweis",
|
||||||
|
"locale": "de-DE",
|
||||||
|
"service": {
|
||||||
|
"object_id": "resident-parking-permit",
|
||||||
|
"key": "resident_parking_permit.apply",
|
||||||
|
"version": "6",
|
||||||
|
"audience": "resident",
|
||||||
|
"required_evidence_types": [
|
||||||
|
"application",
|
||||||
|
"identity",
|
||||||
|
"primary_residence",
|
||||||
|
"vehicle_registration"
|
||||||
|
],
|
||||||
|
"channels": ["portal", "assisted"]
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"object_id": "resident-parking-permit-application",
|
||||||
|
"version": "3",
|
||||||
|
"fields": {
|
||||||
|
"applicant_name": "Ada Lovelace",
|
||||||
|
"applicant_email": "ada.lovelace@example.test",
|
||||||
|
"residence_address": "Musterstrasse 17, 10115 Berlin",
|
||||||
|
"licence_plate": "B-AL 1843"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status_access": {
|
||||||
|
"mode": "email_link",
|
||||||
|
"email_field_key": "applicant_email",
|
||||||
|
"token_ttl_seconds": 1800,
|
||||||
|
"request_limit_per_hour": 3
|
||||||
|
},
|
||||||
|
"assisted_intake": {
|
||||||
|
"channel": "counter",
|
||||||
|
"affected_party_ref": "party:resident-ada-lovelace",
|
||||||
|
"represented_party_ref": null,
|
||||||
|
"authority_basis": "self",
|
||||||
|
"purpose": "Apply for a resident parking permit.",
|
||||||
|
"legal_basis_ref": "law:resident-parking-permit",
|
||||||
|
"consent_basis": "in-person-confirmation",
|
||||||
|
"notice_given": true,
|
||||||
|
"responsible_function_ref": "function:parking-permits",
|
||||||
|
"language": "de",
|
||||||
|
"accessibility_needs": ["plain-language"],
|
||||||
|
"confirmation_method": "written_preview",
|
||||||
|
"confirmation_outcome": "confirmed"
|
||||||
|
},
|
||||||
|
"case": {
|
||||||
|
"type_key": "resident-parking-permit-application",
|
||||||
|
"number": "RPP-2026-0001",
|
||||||
|
"initial_status": "intake",
|
||||||
|
"decided_status": "decided",
|
||||||
|
"deadline_days": 30
|
||||||
|
},
|
||||||
|
"workflow": {
|
||||||
|
"definition_name": "Resident parking permit decision",
|
||||||
|
"work_item_title": "Decide the resident parking permit application",
|
||||||
|
"instructions": "Review identity, primary residence, vehicle evidence, and the effective local rule before recording the decision."
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"type": "resident-parking-permit",
|
||||||
|
"operative_result": "Resident parking permit granted.",
|
||||||
|
"reasoning": "Identity, primary residence, vehicle registration, and the effective local rule were verified.",
|
||||||
|
"delivery_channel": "postbox",
|
||||||
|
"remedy": "review:administrative-court"
|
||||||
|
},
|
||||||
|
"payment": {
|
||||||
|
"mode": "manual",
|
||||||
|
"amount_minor": 3000,
|
||||||
|
"currency": "EUR",
|
||||||
|
"subject": "Resident parking permit fee",
|
||||||
|
"due_days": 14,
|
||||||
|
"evidence_owner": "files"
|
||||||
|
},
|
||||||
|
"records": {
|
||||||
|
"file_plan_key": "traffic.resident-parking-permits",
|
||||||
|
"retention_policy_ref": "records:resident-parking-permit"
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"automated": [
|
||||||
|
"The published service and exact form revision drive digital intake.",
|
||||||
|
"An authenticated assisted session uses the same exact form and validation rules while retaining purpose, authority, channel, party, accessibility, source, correction, and read-back provenance.",
|
||||||
|
"The configured applicant email issues a short-lived, hash-only status link through Notifications and exposes only the bounded status timeline.",
|
||||||
|
"An idempotent replay returns the same persisted submission.",
|
||||||
|
"The human review handoff survives a database-session restart and remains visible in Tasks until completion.",
|
||||||
|
"The production self-service and assisted WebUI paths preserve keyboard order, accessible names, WCAG 2.1 A/AA automation, and responsive geometry at desktop and mobile widths.",
|
||||||
|
"The assisted operator can assign independent source, confidence, and governed declaring-party, document, or system references to every populated field before immutable read-back.",
|
||||||
|
"The formal decision retains party, mandate, legal-basis, evidence, delivery, review, and exact revision references.",
|
||||||
|
"The Case-bound payment handoff creates a replay-safe obligation and accepts a full manual receipt only with exact amount, currency, transaction reference, and immutable evidence.",
|
||||||
|
"Forms Runtime, Cases, and Decisions can expose exact snapshots for explicit eAkte filing."
|
||||||
|
],
|
||||||
|
"manual_or_target": [
|
||||||
|
"Perform physical screen-reader spot checks for the digital journey at desktop and mobile widths.",
|
||||||
|
"Perform physical screen-reader spot checks for the assisted operator journey at desktop and mobile widths.",
|
||||||
|
"Open, resend, expire, and revoke the applicant status link with keyboard and screen reader at desktop and mobile widths.",
|
||||||
|
"Verify the configured Postbox or external delivery provider, including unknown outcome and reconciliation.",
|
||||||
|
"Restore the pinned composition and reconstruct the exact form, case, decision, delivery evidence, and eAkte chronology.",
|
||||||
|
"Transfer through a named archive profile and retain independently signed target evidence."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { findTypeOnlyJsxImports } from "../tools/checks/check-jsx-value-imports.mjs";
|
||||||
|
|
||||||
|
const findings = (source) => findTypeOnlyJsxImports([{ path: "/fixture.tsx", source }]).map((item) => item.component);
|
||||||
|
assert.deepEqual(findings('import type { FormGrid, AuthInfo } from "@govoplan/core-webui"; const page = <Dialog><FormGrid /></Dialog>;'), ["FormGrid"]);
|
||||||
|
assert.deepEqual(findings('import { type FormGrid as Layout } from "ui"; const page = <Layout>Content</Layout>;'), ["Layout"]);
|
||||||
|
assert.deepEqual(findings('import type Layout from "ui"; const page = <Layout />;'), ["Layout"]);
|
||||||
|
assert.deepEqual(findings('import type * as ui from "ui"; const page = <ui.Layout />;'), ["ui.Layout"]);
|
||||||
|
assert.deepEqual(findings('import type { FormGrid } from "ui"; const page = <div title={<FormGrid />} />;'), ["FormGrid"]);
|
||||||
|
assert.deepEqual(findings('import { FormGrid, type AuthInfo } from "ui"; const page = <FormGrid />;'), []);
|
||||||
|
assert.deepEqual(findings('import type { FormGrid } from "ui"; function Page({ FormGrid }: Props) { return <FormGrid />; }'), []);
|
||||||
|
assert.deepEqual(findings('import type * as ui from "ui"; function Page(ui: RuntimeControls) { return <ui.Layout />; }'), []);
|
||||||
|
assert.deepEqual(findings('import type { Layout } from "ui"; const page: Layout = {};'), []);
|
||||||
|
assert.deepEqual(findings('import type { input } from "ui"; const page = <input />;'), []);
|
||||||
|
console.log("JSX runtime-import AST regression tests passed (10 cases).");
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
||||||
|
if str(tools_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
|
from govoplan_assessment.report_generator import ( # noqa: E402
|
||||||
|
AssessmentGenerationError,
|
||||||
|
load_bounded_json,
|
||||||
|
render_assessment_markdown,
|
||||||
|
validate_report_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityFitGenerationTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.assessment = json.loads(
|
||||||
|
(META_ROOT / "docs" / "capability-fit-current.json").read_text("utf-8")
|
||||||
|
)
|
||||||
|
cls.schema = json.loads(
|
||||||
|
(META_ROOT / "docs" / "capability-fit.schema.json").read_text("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_current_assessment_renders_every_conclusion_from_one_input(self) -> None:
|
||||||
|
validate_report_input(assessment=self.assessment, schema=self.schema)
|
||||||
|
|
||||||
|
first = render_assessment_markdown(self.assessment)
|
||||||
|
second = render_assessment_markdown(deepcopy(self.assessment))
|
||||||
|
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertIn("## Facts", first)
|
||||||
|
self.assertIn("## Decisions", first)
|
||||||
|
self.assertIn("## Unresolved decisions", first)
|
||||||
|
self.assertIn("## Risks and residual risks", first)
|
||||||
|
self.assertIn("## Proof-of-concept and promotion checks", first)
|
||||||
|
self.assertIn("### Controlled Campaign pilot", first)
|
||||||
|
self.assertIn("### Small-production candidate", first)
|
||||||
|
self.assertIn("## Functional matrix context", first)
|
||||||
|
self.assertIn("### Manual workarounds", first)
|
||||||
|
self.assertIn("### Blockers", first)
|
||||||
|
self.assertIn("Workflow and workflow-driven user stories", first)
|
||||||
|
for status in self.schema["$defs"]["status"]["enum"]:
|
||||||
|
self.assertIn(f"`{status}`", first)
|
||||||
|
for collection in ("capabilities", "infrastructure", "data_flows"):
|
||||||
|
for item in self.assessment[collection]:
|
||||||
|
self.assertIn(item["id"], first)
|
||||||
|
infrastructure_ids = {
|
||||||
|
item["id"] for item in self.assessment["infrastructure"]
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"runtime.web_api",
|
||||||
|
"runtime.worker",
|
||||||
|
"runtime.scheduler",
|
||||||
|
"data.postgresql",
|
||||||
|
"queue.redis",
|
||||||
|
"storage.local",
|
||||||
|
"storage.object",
|
||||||
|
"edge.proxy_tls",
|
||||||
|
"identity.access",
|
||||||
|
"security.secret_store",
|
||||||
|
"connectors.mail",
|
||||||
|
"operations.monitoring",
|
||||||
|
"operations.audit",
|
||||||
|
"operations.backup_restore",
|
||||||
|
"operations.disaster_recovery",
|
||||||
|
}.issubset(infrastructure_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_questionnaire_retains_all_required_fit_dimensions(self) -> None:
|
||||||
|
ids = {
|
||||||
|
item["id"]
|
||||||
|
for answers in self.assessment["questionnaire"].values()
|
||||||
|
for item in answers
|
||||||
|
}
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"outcome.reference_journey",
|
||||||
|
"scope.users_tenants_organizations",
|
||||||
|
"data.classification",
|
||||||
|
"data.retention",
|
||||||
|
"data.privacy_security_disclosure",
|
||||||
|
"identity.protocols_lifecycle",
|
||||||
|
"integration.protocols_network",
|
||||||
|
"workload.tenants_users_concurrency",
|
||||||
|
"workload.campaign_volume_peaks",
|
||||||
|
"workload.files_jobs_audit_growth_retention",
|
||||||
|
"workload.connector_traffic_batches",
|
||||||
|
"availability.rto_rpo",
|
||||||
|
"hosting.network_constraints",
|
||||||
|
"operations.ownership",
|
||||||
|
"procurement.constraints",
|
||||||
|
}.issubset(ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_duplicate_keys_and_sensitive_fields_fail_closed(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
duplicate = Path(directory) / "duplicate.json"
|
||||||
|
duplicate.write_text('{"id": 1, "id": 2}', encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(AssessmentGenerationError, "Duplicate JSON key"):
|
||||||
|
load_bounded_json(duplicate, label="assessment")
|
||||||
|
|
||||||
|
unsafe = deepcopy(self.assessment)
|
||||||
|
unsafe["password"] = "must-not-render"
|
||||||
|
permissive = deepcopy(self.schema)
|
||||||
|
permissive["additionalProperties"] = True
|
||||||
|
with self.assertRaisesRegex(AssessmentGenerationError, "sensitive field"):
|
||||||
|
validate_report_input(assessment=unsafe, schema=permissive)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -5,9 +5,17 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_core.core.configuration_packages import (
|
from govoplan_core.core.configuration_packages import (
|
||||||
|
ConfigurationApplyResult,
|
||||||
|
ConfigurationExportResult,
|
||||||
ConfigurationPackageManifest,
|
ConfigurationPackageManifest,
|
||||||
|
ConfigurationPlanItem,
|
||||||
ConfigurationPreflightContext,
|
ConfigurationPreflightContext,
|
||||||
|
ConfigurationPreflightResult,
|
||||||
|
ConfigurationProviderDescription,
|
||||||
configuration_package_claim_issues,
|
configuration_package_claim_issues,
|
||||||
dry_run_configuration_package,
|
dry_run_configuration_package,
|
||||||
)
|
)
|
||||||
@@ -54,10 +62,19 @@ class ConfigurationPackageArtifactTests(unittest.TestCase):
|
|||||||
f"Missing repository for {requirement.module_id}",
|
f"Missing repository for {requirement.module_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
provider_module_ids = tuple(
|
||||||
|
sorted({fragment.module_id for fragment in manifest.fragments})
|
||||||
|
)
|
||||||
|
providers = tuple(_ArtifactProvider(module_id) for module_id in provider_module_ids)
|
||||||
|
supplied_data = {
|
||||||
|
str(item["key"]): _sample_value(item)
|
||||||
|
for item in manifest.data_requirements
|
||||||
|
}
|
||||||
result = dry_run_configuration_package(
|
result = dry_run_configuration_package(
|
||||||
manifest,
|
manifest,
|
||||||
(),
|
providers,
|
||||||
ConfigurationPreflightContext(
|
ConfigurationPreflightContext(
|
||||||
|
supplied_data=supplied_data,
|
||||||
installed_modules={
|
installed_modules={
|
||||||
item.module_id: item.version or "workspace"
|
item.module_id: item.version or "workspace"
|
||||||
for item in manifest.required_modules
|
for item in manifest.required_modules
|
||||||
@@ -74,6 +91,155 @@ class ConfigurationPackageArtifactTests(unittest.TestCase):
|
|||||||
self.assertIn("product.governed-data-assurance", package_ids)
|
self.assertIn("product.governed-data-assurance", package_ids)
|
||||||
self.assertIn("product.service-to-decision", package_ids)
|
self.assertIn("product.service-to-decision", package_ids)
|
||||||
|
|
||||||
|
def test_service_to_decision_package_imports_its_form_as_an_idempotent_local_draft(self) -> None:
|
||||||
|
from govoplan_forms.backend.configuration_provider import (
|
||||||
|
_apply_definition,
|
||||||
|
_preflight_definition,
|
||||||
|
)
|
||||||
|
from govoplan_forms.backend.db.models import FormDefinitionRevision
|
||||||
|
from govoplan_forms.backend.service import get_form_definition
|
||||||
|
|
||||||
|
package = ConfigurationPackageManifest.from_mapping(json.loads(
|
||||||
|
(PACKAGE_ROOT / "product/service-to-decision/package.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
))
|
||||||
|
fragment = next(
|
||||||
|
item
|
||||||
|
for item in package.fragments
|
||||||
|
if item.module_id == "forms" and item.fragment_type == "definition"
|
||||||
|
)
|
||||||
|
context = ConfigurationPreflightContext(
|
||||||
|
tenant_id="tenant-reference-test",
|
||||||
|
operator_user_id="operator-1",
|
||||||
|
operator_scopes=frozenset({"system:governance:write"}),
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
FormDefinitionRevision.__table__.create(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
try:
|
||||||
|
preflight = _preflight_definition(session, fragment, context)
|
||||||
|
applied = _apply_definition(session, fragment, context)
|
||||||
|
session.commit()
|
||||||
|
replay = _apply_definition(session, fragment, context)
|
||||||
|
imported = get_form_definition(
|
||||||
|
session,
|
||||||
|
type("Principal", (), {"tenant_id": "tenant-reference-test"})(),
|
||||||
|
form_id="resident-parking-permit-application",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
self.assertFalse(any(item.severity == "blocker" for item in preflight.diagnostics))
|
||||||
|
self.assertEqual("create", preflight.plan[0].action)
|
||||||
|
self.assertEqual(1, len(applied.created_refs))
|
||||||
|
self.assertEqual({}, replay.created_refs)
|
||||||
|
self.assertIsNotNone(imported)
|
||||||
|
assert imported is not None
|
||||||
|
self.assertEqual("tenant-reference-test", imported.reference.tenant_id)
|
||||||
|
self.assertEqual("draft", imported.publication_state)
|
||||||
|
self.assertEqual("de", imported.fallback_locale)
|
||||||
|
|
||||||
|
def test_service_to_decision_package_materializes_its_tenant_workflow_idempotently(self) -> None:
|
||||||
|
from govoplan_core.core.configuration_packages import _resolve_fragment_data_references
|
||||||
|
from govoplan_workflow_engine.backend.configuration_provider import (
|
||||||
|
apply_workflow_definitions,
|
||||||
|
preflight_workflow_definitions,
|
||||||
|
)
|
||||||
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowDefinitionRevision,
|
||||||
|
)
|
||||||
|
|
||||||
|
package = ConfigurationPackageManifest.from_mapping(json.loads(
|
||||||
|
(PACKAGE_ROOT / "product/service-to-decision/package.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
))
|
||||||
|
fragment = next(
|
||||||
|
item
|
||||||
|
for item in package.fragments
|
||||||
|
if item.module_id == "workflow_engine"
|
||||||
|
)
|
||||||
|
context = ConfigurationPreflightContext(
|
||||||
|
tenant_id="tenant-reference-test",
|
||||||
|
supplied_data={
|
||||||
|
"responsible_group_slug": "traffic-permits",
|
||||||
|
"responsible_group_name": "Traffic permits",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resolved = _resolve_fragment_data_references(
|
||||||
|
fragment,
|
||||||
|
context.supplied_data,
|
||||||
|
)
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
WorkflowDefinition.__table__.create(engine)
|
||||||
|
WorkflowDefinitionRevision.__table__.create(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
try:
|
||||||
|
preflight = preflight_workflow_definitions(session, resolved, context)
|
||||||
|
applied = apply_workflow_definitions(
|
||||||
|
session,
|
||||||
|
resolved,
|
||||||
|
context,
|
||||||
|
registry=None,
|
||||||
|
)
|
||||||
|
replay = apply_workflow_definitions(
|
||||||
|
session,
|
||||||
|
resolved,
|
||||||
|
context,
|
||||||
|
registry=None,
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
self.assertFalse(any(item.severity == "blocker" for item in preflight.diagnostics))
|
||||||
|
self.assertEqual("create", preflight.plan[0].action)
|
||||||
|
self.assertIn("resident-parking-permit-review", applied.created_refs)
|
||||||
|
self.assertEqual({}, replay.created_refs)
|
||||||
|
self.assertEqual({}, replay.updated_refs)
|
||||||
|
|
||||||
|
|
||||||
|
class _ArtifactProvider:
|
||||||
|
def __init__(self, module_id: str) -> None:
|
||||||
|
self.module_id = module_id
|
||||||
|
|
||||||
|
def describe(self) -> ConfigurationProviderDescription:
|
||||||
|
return ConfigurationProviderDescription(module_id=self.module_id)
|
||||||
|
|
||||||
|
def preflight(self, fragment, context) -> ConfigurationPreflightResult:
|
||||||
|
del context
|
||||||
|
return ConfigurationPreflightResult(plan=(ConfigurationPlanItem(
|
||||||
|
action="create",
|
||||||
|
module_id=fragment.module_id,
|
||||||
|
fragment_type=fragment.fragment_type,
|
||||||
|
fragment_id=fragment.fragment_id,
|
||||||
|
),))
|
||||||
|
|
||||||
|
def apply(self, fragment, supplied_data, context) -> ConfigurationApplyResult:
|
||||||
|
del fragment, supplied_data, context
|
||||||
|
return ConfigurationApplyResult()
|
||||||
|
|
||||||
|
def export(self, selection, context) -> ConfigurationExportResult:
|
||||||
|
del selection, context
|
||||||
|
return ConfigurationExportResult()
|
||||||
|
|
||||||
|
def health(self, import_result, context):
|
||||||
|
del import_result, context
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_value(requirement: dict[str, object]) -> object:
|
||||||
|
data_type = str(requirement.get("data_type") or requirement.get("type") or "string")
|
||||||
|
if data_type == "boolean":
|
||||||
|
return False
|
||||||
|
if data_type in {"integer", "number"}:
|
||||||
|
return 1
|
||||||
|
return f"fixture-{requirement['key']}"
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -33,6 +35,11 @@ from govoplan_deploy.bundle import ( # noqa: E402
|
|||||||
)
|
)
|
||||||
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
from govoplan_deploy.cli import _receipt_uses_direct_web_port, main # noqa: E402
|
||||||
import govoplan_deploy.cli as deployment_cli # noqa: E402
|
import govoplan_deploy.cli as deployment_cli # noqa: E402
|
||||||
|
from govoplan_deploy.capabilities import ( # noqa: E402
|
||||||
|
capability_change_impacts,
|
||||||
|
infrastructure_capability_document,
|
||||||
|
infrastructure_dependency_inventory_from_mapping,
|
||||||
|
)
|
||||||
from govoplan_deploy.cluster_evidence import ( # noqa: E402
|
from govoplan_deploy.cluster_evidence import ( # noqa: E402
|
||||||
collect_kubernetes_evidence,
|
collect_kubernetes_evidence,
|
||||||
)
|
)
|
||||||
@@ -83,6 +90,41 @@ def _kubernetes_test_deployment(component: str, replicas: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dependency_inventory(
|
||||||
|
installation_id: str,
|
||||||
|
*,
|
||||||
|
generated_at: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"installation_id": installation_id,
|
||||||
|
"generated_at": (generated_at or datetime.now(UTC)).isoformat(),
|
||||||
|
"complete": True,
|
||||||
|
"inspected_capability_ids": ["coordination.redis", "mail.smtp"],
|
||||||
|
"providers": [
|
||||||
|
{
|
||||||
|
"module_id": "mail",
|
||||||
|
"state": "complete",
|
||||||
|
"capability_ids": ["mail.smtp"],
|
||||||
|
"dependency_count": 1,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"capability_id": "mail.smtp",
|
||||||
|
"module_id": "mail",
|
||||||
|
"dependency_type": "smtp_endpoint",
|
||||||
|
"dependency_ref": "endpoint:17",
|
||||||
|
"state": "active",
|
||||||
|
"scope": "system",
|
||||||
|
"summary": "Persisted SMTP endpoint has one credential binding.",
|
||||||
|
"metrics": {"credential_binding_count": 1},
|
||||||
|
"required_action": "Rebind or migrate this SMTP endpoint.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DeploymentInstallerTests(unittest.TestCase):
|
class DeploymentInstallerTests(unittest.TestCase):
|
||||||
def test_kubernetes_evidence_requires_two_node_spread_and_safe_runtime(
|
def test_kubernetes_evidence_requires_two_node_spread_and_safe_runtime(
|
||||||
self,
|
self,
|
||||||
@@ -414,6 +456,27 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertNotIn("db-secret", rendered)
|
self.assertNotIn("db-secret", rendered)
|
||||||
self.assertNotIn("redis-secret", rendered)
|
self.assertNotIn("redis-secret", rendered)
|
||||||
self.assertNotIn("object-secret", rendered)
|
self.assertNotIn("object-secret", rendered)
|
||||||
|
capability_config = next(
|
||||||
|
item
|
||||||
|
for item in manifest["items"]
|
||||||
|
if item["kind"] == "ConfigMap"
|
||||||
|
and item["metadata"]["name"].endswith("infrastructure-capabilities")
|
||||||
|
)
|
||||||
|
capability_payload = json.loads(
|
||||||
|
capability_config["data"]["infrastructure-capabilities.json"]
|
||||||
|
)
|
||||||
|
self.assertEqual(1, capability_payload["schema_version"])
|
||||||
|
self.assertNotIn("db-secret", json.dumps(capability_payload))
|
||||||
|
api_container = deployments["govoplan-cluster-api"]["spec"]["template"]["spec"]["containers"][0]
|
||||||
|
self.assertIn(
|
||||||
|
{
|
||||||
|
"name": "deployment-capabilities",
|
||||||
|
"mountPath": "/etc/govoplan/deployment/infrastructure-capabilities.json",
|
||||||
|
"subPath": "infrastructure-capabilities.json",
|
||||||
|
"readOnly": True,
|
||||||
|
},
|
||||||
|
api_container["volumeMounts"],
|
||||||
|
)
|
||||||
self.assertNotIn("PersistentVolumeClaim", kinds)
|
self.assertNotIn("PersistentVolumeClaim", kinds)
|
||||||
self.assertNotIn("StatefulSet", kinds)
|
self.assertNotIn("StatefulSet", kinds)
|
||||||
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
self.assertEqual(3, deployments["govoplan-cluster-api"]["spec"]["replicas"])
|
||||||
@@ -717,12 +780,21 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertIn("redis", compose["services"])
|
self.assertIn("redis", compose["services"])
|
||||||
self.assertIn("worker", compose["services"])
|
self.assertIn("worker", compose["services"])
|
||||||
self.assertIn("load-balancer", compose["services"])
|
self.assertIn("load-balancer", compose["services"])
|
||||||
|
self.assertEqual(
|
||||||
|
"haproxy:3.2.23-alpine@sha256:"
|
||||||
|
"6343ce34a132a5dceaa24767d739df2bd519f8f7c1079ae39e4821334e8eb42e",
|
||||||
|
compose["services"]["load-balancer"]["image"],
|
||||||
|
)
|
||||||
self.assertNotIn("test-mail", compose["services"])
|
self.assertNotIn("test-mail", compose["services"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["127.0.0.1:8080:8080"],
|
["127.0.0.1:8080:8080"],
|
||||||
compose["services"]["load-balancer"]["ports"],
|
compose["services"]["load-balancer"]["ports"],
|
||||||
)
|
)
|
||||||
self.assertNotIn("ports", compose["services"]["web"])
|
self.assertNotIn("ports", compose["services"]["web"])
|
||||||
|
self.assertIn(
|
||||||
|
"./infrastructure-capabilities.json:/etc/govoplan/deployment/infrastructure-capabilities.json:ro",
|
||||||
|
compose["services"]["api"]["volumes"],
|
||||||
|
)
|
||||||
self.assertEqual(1, compose["services"]["api"]["scale"])
|
self.assertEqual(1, compose["services"]["api"]["scale"])
|
||||||
self.assertEqual(1, compose["services"]["web"]["scale"])
|
self.assertEqual(1, compose["services"]["web"]["scale"])
|
||||||
|
|
||||||
@@ -948,6 +1020,85 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
reconciled["GARAGE_RPC_SECRET"],
|
reconciled["GARAGE_RPC_SECRET"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_infrastructure_capability_document_exposes_refs_not_secrets(self) -> None:
|
||||||
|
spec = default_spec(
|
||||||
|
installation_id="govoplan-shared",
|
||||||
|
postgres_mode="external",
|
||||||
|
redis_mode="external",
|
||||||
|
storage_mode="s3",
|
||||||
|
mail_mode="external-relay",
|
||||||
|
module_set="full",
|
||||||
|
)
|
||||||
|
values = initial_secrets(
|
||||||
|
spec,
|
||||||
|
supplied={
|
||||||
|
"DATABASE_URL": "postgresql+psycopg://user:database-secret@db.example.test/govoplan",
|
||||||
|
"REDIS_URL": "rediss://:redis-secret@redis.example.test/0",
|
||||||
|
"FILE_STORAGE_S3_ENDPOINT_URL": "https://s3.example.test",
|
||||||
|
"FILE_STORAGE_S3_REGION": "eu-test-1",
|
||||||
|
"FILE_STORAGE_S3_ACCESS_KEY_ID": "object-key",
|
||||||
|
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "object-secret",
|
||||||
|
"FILE_STORAGE_S3_BUCKET": "govoplan",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
document = infrastructure_capability_document(spec, values)
|
||||||
|
rendered = json.dumps(document, sort_keys=True)
|
||||||
|
capabilities = {item["id"]: item for item in document["capabilities"]}
|
||||||
|
|
||||||
|
self.assertNotIn("database-secret", rendered)
|
||||||
|
self.assertNotIn("redis-secret", rendered)
|
||||||
|
self.assertNotIn("object-secret", rendered)
|
||||||
|
self.assertNotIn("object-key", rendered)
|
||||||
|
self.assertEqual("externally_supplied", capabilities["database.postgresql"]["state"])
|
||||||
|
self.assertEqual("db.example.test", capabilities["database.postgresql"]["endpoint"]["host"])
|
||||||
|
self.assertEqual(["env:DATABASE_URL"], capabilities["database.postgresql"]["secret_refs"])
|
||||||
|
self.assertEqual("available_unconfigured", capabilities["mail.smtp"]["state"])
|
||||||
|
self.assertEqual("mail.smtp-profile", document["post_install_tasks"][0]["id"])
|
||||||
|
|
||||||
|
def test_capability_impact_detects_external_endpoint_rebinding(self) -> None:
|
||||||
|
spec = default_spec(postgres_mode="external", module_set="full")
|
||||||
|
previous = infrastructure_capability_document(
|
||||||
|
spec,
|
||||||
|
{"DATABASE_URL": "postgresql://user:old-secret@old-db.example.test/govoplan"},
|
||||||
|
)
|
||||||
|
desired = infrastructure_capability_document(
|
||||||
|
spec,
|
||||||
|
{"DATABASE_URL": "postgresql://user:new-secret@new-db.example.test/govoplan"},
|
||||||
|
)
|
||||||
|
|
||||||
|
impacts = {
|
||||||
|
item.capability_id: item
|
||||||
|
for item in capability_change_impacts(previous, desired)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual("reconfigure", impacts["database.postgresql"].action)
|
||||||
|
self.assertIn("changed endpoint binding", impacts["database.postgresql"].detail)
|
||||||
|
self.assertNotIn("old-secret", impacts["database.postgresql"].detail)
|
||||||
|
self.assertNotIn("new-secret", impacts["database.postgresql"].detail)
|
||||||
|
|
||||||
|
def test_capability_impact_includes_provider_dependency_evidence(self) -> None:
|
||||||
|
previous_spec = default_spec(mail_mode="test-mail", module_set="full")
|
||||||
|
desired_spec = default_spec(mail_mode="disabled", module_set="full")
|
||||||
|
inventory = infrastructure_dependency_inventory_from_mapping(
|
||||||
|
_dependency_inventory(previous_spec.installation_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
impacts = {
|
||||||
|
item.capability_id: item
|
||||||
|
for item in capability_change_impacts(
|
||||||
|
infrastructure_capability_document(previous_spec, {}),
|
||||||
|
infrastructure_capability_document(desired_spec, {}),
|
||||||
|
dependency_inventory=inventory,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
mail = impacts["mail.smtp"]
|
||||||
|
self.assertTrue(mail.inventory_inspected)
|
||||||
|
self.assertEqual("endpoint:17", mail.actual_dependencies[0].dependency_ref)
|
||||||
|
self.assertIn("mail:endpoint:17", mail.detail)
|
||||||
|
self.assertIn("Rebind or migrate", mail.required_action)
|
||||||
|
|
||||||
def test_replica_counts_drive_compose_and_load_balancer_discovery(self) -> None:
|
def test_replica_counts_drive_compose_and_load_balancer_discovery(self) -> None:
|
||||||
spec = default_spec(
|
spec = default_spec(
|
||||||
storage_mode="garage",
|
storage_mode="garage",
|
||||||
@@ -999,8 +1150,26 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, parsed.replicas.web)
|
self.assertEqual(1, parsed.replicas.web)
|
||||||
self.assertEqual(1, parsed.replicas.worker)
|
self.assertEqual(1, parsed.replicas.worker)
|
||||||
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
self.assertEqual("managed", parsed.components.load_balancer.mode)
|
||||||
|
self.assertEqual(
|
||||||
|
default_spec().components.load_balancer.image,
|
||||||
|
parsed.components.load_balancer.image,
|
||||||
|
)
|
||||||
self.assertEqual("local", parsed.ingress.mode)
|
self.assertEqual("local", parsed.ingress.mode)
|
||||||
|
|
||||||
|
def test_load_balancer_patch_does_not_rewrite_an_existing_image(self) -> None:
|
||||||
|
for image in (
|
||||||
|
"haproxy:3.2.21-alpine",
|
||||||
|
"registry.example.test/haproxy@sha256:" + "a" * 64,
|
||||||
|
):
|
||||||
|
with self.subTest(image=image):
|
||||||
|
saved = default_spec(load_balancer_image=image).to_dict()
|
||||||
|
|
||||||
|
restored = parse_spec(json.loads(json.dumps(saved)))
|
||||||
|
compose = render_compose(restored)
|
||||||
|
|
||||||
|
self.assertEqual(image, restored.components.load_balancer.image)
|
||||||
|
self.assertEqual(image, compose["services"]["load-balancer"]["image"])
|
||||||
|
|
||||||
def test_compose_contains_no_secret_values(self) -> None:
|
def test_compose_contains_no_secret_values(self) -> None:
|
||||||
spec = default_spec()
|
spec = default_spec()
|
||||||
values = initial_secrets(spec)
|
values = initial_secrets(spec)
|
||||||
@@ -1077,10 +1246,14 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
paths = bundle_paths(Path(directory))
|
paths = bundle_paths(Path(directory))
|
||||||
paths.root.chmod(0o700)
|
paths.root.chmod(0o700)
|
||||||
first_spec = default_spec(mail_mode="test-mail")
|
first_spec = default_spec(mail_mode="test-mail", module_set="full")
|
||||||
first_environment = initial_secrets(first_spec)
|
first_environment = initial_secrets(first_spec)
|
||||||
write_env(paths.env, first_environment)
|
write_env(paths.env, first_environment)
|
||||||
first_plan = build_plan(first_spec, paths, include_host_checks=False)
|
first_plan = build_plan(first_spec, paths, include_host_checks=False)
|
||||||
|
first_capabilities = infrastructure_capability_document(
|
||||||
|
first_spec,
|
||||||
|
first_environment,
|
||||||
|
)
|
||||||
atomic_write(
|
atomic_write(
|
||||||
paths.receipt,
|
paths.receipt,
|
||||||
canonical_json(
|
canonical_json(
|
||||||
@@ -1091,12 +1264,17 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
first_plan.desired_environment_fingerprint
|
first_plan.desired_environment_fingerprint
|
||||||
),
|
),
|
||||||
"services": list(render_compose(first_spec)["services"]),
|
"services": list(render_compose(first_spec)["services"]),
|
||||||
|
"infrastructure_capabilities": first_capabilities,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
mode=0o600,
|
mode=0o600,
|
||||||
)
|
)
|
||||||
|
|
||||||
second_spec = default_spec(redis_mode="disabled", mail_mode="disabled")
|
second_spec = default_spec(
|
||||||
|
redis_mode="disabled",
|
||||||
|
mail_mode="disabled",
|
||||||
|
module_set="full",
|
||||||
|
)
|
||||||
write_env(
|
write_env(
|
||||||
paths.env,
|
paths.env,
|
||||||
reconcile_runtime_environment(second_spec, first_environment),
|
reconcile_runtime_environment(second_spec, first_environment),
|
||||||
@@ -1112,6 +1290,79 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
{"redis", "worker", "scheduler", "test-mail"},
|
{"redis", "worker", "scheduler", "test-mail"},
|
||||||
removed,
|
removed,
|
||||||
)
|
)
|
||||||
|
impacts = {
|
||||||
|
item.capability_id: item
|
||||||
|
for item in second_plan.capability_impacts
|
||||||
|
}
|
||||||
|
self.assertEqual("remove", impacts["coordination.redis"].action)
|
||||||
|
self.assertEqual("remove", impacts["mail.smtp"].action)
|
||||||
|
self.assertIn("mail", impacts["mail.smtp"].dependent_modules)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
check.id == "capability.change.mail.smtp"
|
||||||
|
and check.level == "warning"
|
||||||
|
for check in second_plan.checks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(second_plan.blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
check.id == "capability.dependency_inventory.missing"
|
||||||
|
and check.level == "error"
|
||||||
|
for check in second_plan.checks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
atomic_write(
|
||||||
|
paths.dependency_inventory,
|
||||||
|
canonical_json(_dependency_inventory(second_spec.installation_id)),
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
evidenced_plan = build_plan(
|
||||||
|
second_spec,
|
||||||
|
paths,
|
||||||
|
include_host_checks=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
any(
|
||||||
|
check.level == "error"
|
||||||
|
and check.id.startswith("capability.dependency_inventory.")
|
||||||
|
for check in evidenced_plan.checks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"endpoint:17",
|
||||||
|
{
|
||||||
|
item.capability_id: item
|
||||||
|
for item in evidenced_plan.capability_impacts
|
||||||
|
}["mail.smtp"].actual_dependencies[0].dependency_ref,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
check.id == "capability.dependency_inventory.current"
|
||||||
|
and check.level == "ok"
|
||||||
|
for check in evidenced_plan.checks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stale = _dependency_inventory(
|
||||||
|
second_spec.installation_id,
|
||||||
|
generated_at=datetime.now(UTC) - timedelta(minutes=6),
|
||||||
|
)
|
||||||
|
atomic_write(
|
||||||
|
paths.dependency_inventory,
|
||||||
|
canonical_json(stale),
|
||||||
|
mode=0o600,
|
||||||
|
)
|
||||||
|
stale_plan = build_plan(second_spec, paths, include_host_checks=False)
|
||||||
|
self.assertTrue(stale_plan.blocked)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
check.id == "capability.dependency_inventory.stale"
|
||||||
|
for check in stale_plan.checks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def test_secret_change_is_planned_without_exposing_secret_values(self) -> None:
|
def test_secret_change_is_planned_without_exposing_secret_values(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
@@ -1221,6 +1472,54 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
)[0],
|
)[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_cli_collects_bounded_private_dependency_inventory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
|
root = Path(directory) / "installation"
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
run_cli(
|
||||||
|
[
|
||||||
|
"init",
|
||||||
|
"--non-interactive",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
]
|
||||||
|
)[0],
|
||||||
|
)
|
||||||
|
payload = _dependency_inventory("govoplan-local")
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.geturl.return_value = "https://ops.example.test/inventory"
|
||||||
|
response.read.return_value = json.dumps(payload).encode("utf-8")
|
||||||
|
fetch = MagicMock(return_value=response)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict(os.environ, {"TEST_OPS_KEY": "secret-api-key"}),
|
||||||
|
patch.object(deployment_cli, "urlopen", fetch),
|
||||||
|
):
|
||||||
|
result, stdout, stderr = run_cli(
|
||||||
|
[
|
||||||
|
"collect-infrastructure-inventory",
|
||||||
|
"--directory",
|
||||||
|
str(root),
|
||||||
|
"--ops-url",
|
||||||
|
"https://ops.example.test/inventory",
|
||||||
|
"--api-key-env",
|
||||||
|
"TEST_OPS_KEY",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result, stderr)
|
||||||
|
self.assertIn("1 record(s)", stdout)
|
||||||
|
evidence_path = root / "infrastructure-dependency-inventory.json"
|
||||||
|
self.assertEqual(0o600, stat.S_IMODE(evidence_path.stat().st_mode))
|
||||||
|
self.assertNotIn(
|
||||||
|
"secret-api-key",
|
||||||
|
evidence_path.read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
|
request = fetch.call_args.args[0]
|
||||||
|
self.assertEqual("secret-api-key", request.get_header("X-api-key"))
|
||||||
|
|
||||||
def test_cli_requires_external_url_when_switching_from_managed(self) -> None:
|
def test_cli_requires_external_url_when_switching_from_managed(self) -> None:
|
||||||
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
with tempfile.TemporaryDirectory(prefix="govoplan-deploy-test-") as directory:
|
||||||
root = Path(directory) / "installation"
|
root = Path(directory) / "installation"
|
||||||
@@ -1480,6 +1779,11 @@ class DeploymentInstallerTests(unittest.TestCase):
|
|||||||
receipt["listen"],
|
receipt["listen"],
|
||||||
)
|
)
|
||||||
self.assertNotIn("installer", receipt["services"])
|
self.assertNotIn("installer", receipt["services"])
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
receipt["infrastructure_capabilities"]["schema_version"],
|
||||||
|
)
|
||||||
|
self.assertTrue((root / "infrastructure-capabilities.json").is_file())
|
||||||
|
|
||||||
def test_installation_root_symlink_is_rejected(self) -> None:
|
def test_installation_root_symlink_is_rejected(self) -> None:
|
||||||
if not hasattr(Path, "symlink_to"):
|
if not hasattr(Path, "symlink_to"):
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOCS = ROOT / "docs"
|
||||||
|
MARKDOWN_LINK = re.compile(
|
||||||
|
r"!?\[[^\n]*?\]\((?P<destination><[^>]+>|[^)\s]+)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentationStructureTests(unittest.TestCase):
|
||||||
|
def test_documentation_root_has_one_human_entry_point(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
[path.name for path in sorted(DOCS.glob("*.md"))],
|
||||||
|
["README.md"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_documentation_front_doors_exist(self) -> None:
|
||||||
|
expected = (
|
||||||
|
DOCS / "strategy" / "PLATFORM_CORE_IDEAS.md",
|
||||||
|
DOCS / "strategy" / "ROADMAP.md",
|
||||||
|
DOCS / "strategy" / "STRATEGY_STATUS.md",
|
||||||
|
DOCS / "strategy" / "REFERENCE_JOURNEY_PROGRAM.md",
|
||||||
|
)
|
||||||
|
self.assertFalse([path for path in expected if not path.is_file()])
|
||||||
|
|
||||||
|
def test_local_markdown_links_resolve(self) -> None:
|
||||||
|
broken: list[str] = []
|
||||||
|
sources = [ROOT / "README.md", *sorted(DOCS.rglob("*.md"))]
|
||||||
|
for source in sources:
|
||||||
|
for line_number, line in enumerate(
|
||||||
|
source.read_text(encoding="utf-8").splitlines(),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
for match in MARKDOWN_LINK.finditer(line):
|
||||||
|
destination = match.group("destination")
|
||||||
|
if destination.startswith("<") and destination.endswith(">"):
|
||||||
|
destination = destination[1:-1]
|
||||||
|
path_text = destination.split("#", 1)[0]
|
||||||
|
if (
|
||||||
|
not path_text
|
||||||
|
or path_text.startswith(("/", "mailto:", "data:"))
|
||||||
|
or "://" in path_text
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
target = (source.parent / path_text).resolve()
|
||||||
|
if not target.is_relative_to(ROOT):
|
||||||
|
continue
|
||||||
|
if not target.exists():
|
||||||
|
broken.append(
|
||||||
|
f"{source.relative_to(ROOT)}:{line_number}: {destination}"
|
||||||
|
)
|
||||||
|
self.assertEqual(broken, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from contextlib import ExitStack
|
||||||
|
import csv
|
||||||
|
from copy import deepcopy
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from urllib.parse import quote
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||||
|
|
||||||
|
from govoplan_release import full_catalog # noqa: E402
|
||||||
|
from govoplan_release.artifact_identity import selected_artifact_identity_issues # noqa: E402
|
||||||
|
from govoplan_release.catalog import canonical_hash # noqa: E402
|
||||||
|
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||||
|
from govoplan_release.registry_reference import registry_entry_source # noqa: E402
|
||||||
|
from govoplan_release.selective_catalog import ( # noqa: E402
|
||||||
|
load_authenticated_catalog_base, public_key_base64, signature,
|
||||||
|
)
|
||||||
|
from govoplan_release.source_provenance import ( # noqa: E402
|
||||||
|
SourceTagProvenanceIssue, catalog_source_selection,
|
||||||
|
registered_source_origin_issues,
|
||||||
|
)
|
||||||
|
from govoplan_release.version_alignment import candidate_catalog_version_issues # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class FullRegistryCatalogTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name)
|
||||||
|
self.web = self.root / "addideas-govoplan-website"
|
||||||
|
self.wheels = self.root / "wheels"
|
||||||
|
self.npm = self.root / "npm"
|
||||||
|
self.wheels.mkdir(mode=0o700)
|
||||||
|
self.npm.mkdir(mode=0o700)
|
||||||
|
self.key = Ed25519PrivateKey.generate()
|
||||||
|
self.keypath = self.root / "key.pem"
|
||||||
|
self.keypath.write_bytes(self.key.private_bytes(
|
||||||
|
serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
))
|
||||||
|
self.keypath.chmod(0o600)
|
||||||
|
self.keyring = {
|
||||||
|
"keyring_version": "1", "keys": [{
|
||||||
|
"key_id": "known-key", "public_key": public_key_base64(self.key),
|
||||||
|
"status": "active",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
self.base = {
|
||||||
|
"catalog_version": "1", "channel": "stable", "sequence": 1,
|
||||||
|
"core_release": {"version": "1.0.0"}, "modules": [],
|
||||||
|
"release": {},
|
||||||
|
}
|
||||||
|
self.write_base()
|
||||||
|
self.package_set = {
|
||||||
|
"schema_version": "1", "release_version": "1.2.3", "profile": "full",
|
||||||
|
"registries": {
|
||||||
|
"python": "https://git.add-ideas.de/api/packages/GovOPlaN/pypi/simple",
|
||||||
|
"npm": "https://git.add-ideas.de/api/packages/GovOPlaN/npm/",
|
||||||
|
},
|
||||||
|
"python": [self.package("govoplan-core"), self.package("govoplan-demo")],
|
||||||
|
"webui": [self.package("govoplan-core", webui=True)],
|
||||||
|
}
|
||||||
|
self.seal(self.package_set, "package_set_sha256")
|
||||||
|
self.lock = {
|
||||||
|
"schema_version": "1", "release_version": "1.2.3", "profile": "full",
|
||||||
|
"registries": self.package_set["registries"],
|
||||||
|
"package_set_sha256": self.package_set["package_set_sha256"],
|
||||||
|
"python": [], "webui": [],
|
||||||
|
}
|
||||||
|
for row in self.package_set["python"]:
|
||||||
|
path = self.wheel(row["name"])
|
||||||
|
url = full_catalog._tool("resolve-package-artifacts")["_python_artifact_url"](
|
||||||
|
self.package_set["registries"]["python"], package=row, filename=path.name,
|
||||||
|
)
|
||||||
|
self.lock["python"].append(self.artifact(row, path, url))
|
||||||
|
npm_package = self.package_set["webui"][0]
|
||||||
|
npm_path = self.npm / "govoplan-core-webui-1.2.3.tgz"
|
||||||
|
self.tarball(npm_path, "@govoplan/core-webui", "1.2.3")
|
||||||
|
url = self.package_set["registries"]["npm"] + quote(npm_package["name"], safe="") + "/-/1.2.3/core-webui-1.2.3.tgz"
|
||||||
|
row = self.artifact(npm_package, npm_path, url)
|
||||||
|
row["integrity"] = "sha512-" + base64.b64encode(hashlib.sha512(npm_path.read_bytes()).digest()).decode()
|
||||||
|
self.lock["webui"].append(row)
|
||||||
|
self.seal(self.lock, "lock_sha256")
|
||||||
|
self.set_path = self.root / "package-set.json"
|
||||||
|
self.lock_path = self.root / "package-lock.json"
|
||||||
|
self.write_inputs()
|
||||||
|
self.output = self.root / "candidate"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def package(repo: str, *, webui: bool = False) -> dict:
|
||||||
|
row = {
|
||||||
|
"name": "@govoplan/core-webui" if webui else repo, "version": "1.2.3",
|
||||||
|
"repository": repo, "tag": "v1.2.3",
|
||||||
|
"commit": ("a" if repo == "govoplan-core" else "b") * 40,
|
||||||
|
}
|
||||||
|
if not webui:
|
||||||
|
row["extras"] = ["server"] if repo == "govoplan-core" else []
|
||||||
|
return row
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def artifact(package: dict, path: Path, url: str) -> dict:
|
||||||
|
encoded = path.read_bytes()
|
||||||
|
return {**package, "filename": path.name, "url": url,
|
||||||
|
"sha256": hashlib.sha256(encoded).hexdigest(), "size": len(encoded)}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def seal(payload: dict, field: str) -> None:
|
||||||
|
payload.pop(field, None)
|
||||||
|
payload[field] = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||||
|
|
||||||
|
def write_inputs(self) -> None:
|
||||||
|
self.set_path.write_text(json.dumps(self.package_set))
|
||||||
|
self.lock_path.write_text(json.dumps(self.lock))
|
||||||
|
|
||||||
|
def write_base(self) -> None:
|
||||||
|
self.base.pop("signatures", None)
|
||||||
|
self.base["signatures"] = [signature(self.base, key_id="known-key", private_key=self.key)]
|
||||||
|
folder = self.web / "public/catalogs/v1"
|
||||||
|
(folder / "channels").mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "channels/stable.json").write_text(json.dumps(self.base))
|
||||||
|
(folder / "keyring.json").write_text(json.dumps(self.keyring))
|
||||||
|
|
||||||
|
def wheel(self, package: str) -> Path:
|
||||||
|
stem = package.replace("-", "_")
|
||||||
|
info = f"{stem}-1.2.3.dist-info"
|
||||||
|
files = {
|
||||||
|
f"{stem}/__init__.py": b"VALUE = 1\n",
|
||||||
|
f"{info}/METADATA": f"Metadata-Version: 2.1\nName: {package}\nVersion: 1.2.3\n".encode(),
|
||||||
|
f"{info}/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||||
|
}
|
||||||
|
record = io.StringIO()
|
||||||
|
writer = csv.writer(record, lineterminator="\n")
|
||||||
|
for name, value in files.items():
|
||||||
|
writer.writerow((name, "", len(value)))
|
||||||
|
writer.writerow((f"{info}/RECORD", "", ""))
|
||||||
|
files[f"{info}/RECORD"] = record.getvalue().encode()
|
||||||
|
path = self.wheels / f"{stem}-1.2.3-py3-none-any.whl"
|
||||||
|
with zipfile.ZipFile(path, "w") as archive:
|
||||||
|
for name, value in files.items():
|
||||||
|
archive.writestr(name, value)
|
||||||
|
return path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def tarball(path: Path, name: str, version: str, *, duplicate: bool = False) -> None:
|
||||||
|
encoded = json.dumps({"name": name, "version": version}).encode()
|
||||||
|
with tarfile.open(path, "w:gz") as archive:
|
||||||
|
for _ in range(2 if duplicate else 1):
|
||||||
|
member = tarfile.TarInfo("package/package.json")
|
||||||
|
member.size = len(encoded)
|
||||||
|
archive.addfile(member, io.BytesIO(encoded))
|
||||||
|
|
||||||
|
def build(self, *, provenance_errors=(), origin_errors=()) -> dict:
|
||||||
|
registry_generator = full_catalog._tool("generate-release-catalog")
|
||||||
|
tools = {name: dict(full_catalog._tool(name)) for name in (
|
||||||
|
"generate-release-catalog", "generate-release-package-set", "resolve-package-artifacts",
|
||||||
|
)}
|
||||||
|
tools["generate-release-package-set"]["generate_package_set"] = lambda **kwargs: self.package_set
|
||||||
|
self.provenance = {
|
||||||
|
row["repository"]: {"commit_sha": row["commit"], "tag_object_sha": str(index + 1) * 40}
|
||||||
|
for index, row in enumerate(self.package_set["python"])
|
||||||
|
}
|
||||||
|
entry = {
|
||||||
|
"module_id": "demo", "name": "Demo", "version": "1.2.3",
|
||||||
|
"python_package": "govoplan-demo",
|
||||||
|
"python_ref": "govoplan-demo @ git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-demo.git@v1.2.3",
|
||||||
|
}
|
||||||
|
with ExitStack() as stack:
|
||||||
|
stack.enter_context(patch.object(full_catalog, "_tool", side_effect=lambda name: tools[name]))
|
||||||
|
stack.enter_context(patch.dict(registry_generator["_catalog_payload"].__globals__, {
|
||||||
|
"synthesize_repository_catalog_entries": lambda **kwargs: (dict(entry),),
|
||||||
|
}))
|
||||||
|
stack.enter_context(patch.object(full_catalog, "enforce_selected_version_alignment"))
|
||||||
|
stack.enter_context(patch.object(full_catalog, "registered_source_origin_issues", return_value=origin_errors))
|
||||||
|
self.provenance_check = stack.enter_context(patch.object(full_catalog, "source_tag_provenance_issues", return_value=provenance_errors))
|
||||||
|
stack.enter_context(patch.object(full_catalog, "selected_source_provenance", return_value=self.provenance))
|
||||||
|
return full_catalog.build_full_registry_candidate(
|
||||||
|
package_set_path=self.set_path, package_lock_path=self.lock_path,
|
||||||
|
wheelhouse=self.wheels, webui_packages=self.npm, output_dir=self.output,
|
||||||
|
selected_repositories=("govoplan-core",),
|
||||||
|
signing_keys=(f"known-key={self.keypath}",), workspace_root=self.root,
|
||||||
|
)
|
||||||
|
|
||||||
|
def candidate(self) -> dict:
|
||||||
|
return json.loads((self.output / "channels/stable.json").read_text())
|
||||||
|
|
||||||
|
def test_full_candidate_uses_registry_bytes_and_preserves_unchanged_tag_provenance(self) -> None:
|
||||||
|
result = self.build()
|
||||||
|
candidate = self.candidate()
|
||||||
|
self.assertEqual("ready", result["status"])
|
||||||
|
self.assertEqual(2, result["package_count"])
|
||||||
|
self.assertEqual(1, result["selected_count"])
|
||||||
|
self.assertEqual(self.keyring, json.loads((self.output / "keyring.json").read_text()))
|
||||||
|
self.assertEqual(canonical_hash(self.keyring), candidate["release"]["keyring_sha256"])
|
||||||
|
self.assertEqual(2, len(candidate["release"]["artifacts"]))
|
||||||
|
self.assertIn("/pypi/files/", candidate["core_release"]["python_ref"])
|
||||||
|
self.assertEqual((), candidate_catalog_version_issues(candidate))
|
||||||
|
self.assertEqual((), selected_artifact_identity_issues(candidate))
|
||||||
|
sources = catalog_source_selection(candidate)
|
||||||
|
self.assertEqual((), sources.issues)
|
||||||
|
self.assertEqual({"govoplan-core": "1.2.3"}, sources.selected_versions)
|
||||||
|
self.assertEqual({"govoplan-core": "1.2.3", "govoplan-demo": "1.2.3"}, sources.all_versions)
|
||||||
|
self.assertEqual("b" * 40, sources.selected_commits["govoplan-demo"])
|
||||||
|
self.assertEqual("2" * 40, sources.selected_tag_objects["govoplan-demo"])
|
||||||
|
self.assertEqual(2, self.provenance_check.call_count)
|
||||||
|
for call in self.provenance_check.call_args_list:
|
||||||
|
self.assertEqual({"govoplan-core"}, call.kwargs["require_head_repos"])
|
||||||
|
for path in [self.output, *self.output.rglob("*")]:
|
||||||
|
self.assertEqual(0o700 if path.is_dir() else 0o600, path.stat().st_mode & 0o777)
|
||||||
|
|
||||||
|
def test_legacy_base_is_authenticated_but_remains_rejected_by_selective(self) -> None:
|
||||||
|
self.build()
|
||||||
|
with self.assertRaisesRegex(ValueError, "does not pin"):
|
||||||
|
load_authenticated_catalog_base(
|
||||||
|
base_catalog=None, base_keyring=None, web_root=self.web,
|
||||||
|
channel="stable", public_base_url="https://unused.example",
|
||||||
|
signer_public_keys={"known-key": public_key_base64(self.key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_injected_key_or_mismatched_pinned_keyring_is_rejected(self) -> None:
|
||||||
|
for mutation in ("extra-key", "bad-hash"):
|
||||||
|
with self.subTest(mutation=mutation):
|
||||||
|
if mutation == "extra-key":
|
||||||
|
self.keyring["keys"].append({"key_id": "injected", "status": "active", "public_key": public_key_base64(Ed25519PrivateKey.generate())})
|
||||||
|
else:
|
||||||
|
self.keyring["keys"] = self.keyring["keys"][:1]
|
||||||
|
self.base["release"]["keyring_sha256"] = "f" * 64
|
||||||
|
self.write_base()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.build()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_tampered_base_signature_is_rejected(self) -> None:
|
||||||
|
path = self.web / "public/catalogs/v1/channels/stable.json"
|
||||||
|
payload = json.loads(path.read_text())
|
||||||
|
payload["sequence"] = 999
|
||||||
|
path.write_text(json.dumps(payload))
|
||||||
|
with self.assertRaisesRegex(ValueError, "signature verification"):
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
def test_wrong_registry_bytes_and_reused_candidate_fail_closed(self) -> None:
|
||||||
|
self.build()
|
||||||
|
original = (self.output / "channels/stable.json").read_bytes()
|
||||||
|
with self.assertRaisesRegex(ValueError, "must not already exist"):
|
||||||
|
self.build()
|
||||||
|
self.assertEqual(original, (self.output / "channels/stable.json").read_bytes())
|
||||||
|
self.output = self.root / "candidate-2"
|
||||||
|
wheel = self.wheels / self.lock["python"][0]["filename"]
|
||||||
|
with wheel.open("ab") as stream:
|
||||||
|
stream.write(b"tampered")
|
||||||
|
with self.assertRaisesRegex(ValueError, "bytes differ"):
|
||||||
|
self.build()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_webui_identity_and_url_changes_are_rejected(self) -> None:
|
||||||
|
row = self.lock["webui"][0]
|
||||||
|
original = row["url"]
|
||||||
|
for url in (
|
||||||
|
original + "?alternate=true", original.replace("/-/1.2.3/", "/-/9.9.9/"),
|
||||||
|
original.replace("/npm/", "/npm/../other/"),
|
||||||
|
original.replace("/npm/", "/npm/%2e%2e/other/"),
|
||||||
|
original.replace("/npm/", "/npm/%252e%252e/other/"),
|
||||||
|
):
|
||||||
|
with self.subTest(url=url):
|
||||||
|
row["url"] = url
|
||||||
|
self.seal(self.lock, "lock_sha256")
|
||||||
|
self.write_inputs()
|
||||||
|
with self.assertRaisesRegex(ValueError, "URL differs"):
|
||||||
|
self.build()
|
||||||
|
|
||||||
|
def test_duplicate_missing_and_symlinked_artifacts_are_rejected(self) -> None:
|
||||||
|
row = self.lock["webui"][0]
|
||||||
|
self.lock["webui"].append(dict(row))
|
||||||
|
with self.assertRaisesRegex(ValueError, "duplicate/missing"):
|
||||||
|
full_catalog.verify_registry_artifacts(package_set=self.package_set, lock=self.lock, wheelhouse=self.wheels, webui_packages=self.npm)
|
||||||
|
self.lock["webui"].pop()
|
||||||
|
path = self.npm / row["filename"]
|
||||||
|
moved = self.root / "moved.tgz"
|
||||||
|
path.rename(moved)
|
||||||
|
path.symlink_to(moved)
|
||||||
|
with self.assertRaises(OSError):
|
||||||
|
full_catalog.verify_registry_artifacts(package_set=self.package_set, lock=self.lock, wheelhouse=self.wheels, webui_packages=self.npm)
|
||||||
|
|
||||||
|
def test_archive_metadata_is_bounded_and_not_ambiguous(self) -> None:
|
||||||
|
path = self.npm / "duplicate.tgz"
|
||||||
|
self.tarball(path, "@govoplan/core-webui", "1.2.3", duplicate=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||||
|
full_catalog._inspect_npm_metadata(path, name="@govoplan/core-webui", version="1.2.3")
|
||||||
|
|
||||||
|
def test_origin_or_tag_provenance_failure_prevents_output(self) -> None:
|
||||||
|
issue = SourceTagProvenanceIssue("govoplan-core", "v1.2.3", "wrong immutable identity")
|
||||||
|
for kwargs in ({"origin_errors": (issue,)}, {"provenance_errors": (issue,)}):
|
||||||
|
with self.subTest(kwargs=kwargs), self.assertRaisesRegex(ValueError, "gate failed"):
|
||||||
|
self.build(**kwargs)
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_registered_source_origin_requires_exact_fetch_and_push_targets(self) -> None:
|
||||||
|
repo = self.root / "govoplan-core"
|
||||||
|
repo.mkdir()
|
||||||
|
spec = RepositorySpec("govoplan-core", "system", "kernel", "git@example.test:trusted/core.git", "govoplan-core")
|
||||||
|
with patch("govoplan_release.source_provenance.load_repository_specs", return_value=(spec,)):
|
||||||
|
for targets in ((spec.remote, spec.remote), ("git@evil.test:core.git", spec.remote), (spec.remote, "git@evil.test:core.git")):
|
||||||
|
with self.subTest(targets=targets), patch("govoplan_release.source_provenance.git_text", side_effect=targets):
|
||||||
|
issues = registered_source_origin_issues(repo_versions={"govoplan-core": "1.2.3"}, workspace=self.root, remote="origin")
|
||||||
|
self.assertEqual(targets != (spec.remote, spec.remote), bool(issues))
|
||||||
|
|
||||||
|
def test_registry_metadata_cannot_cross_wire_webui_or_archive_identity(self) -> None:
|
||||||
|
self.build()
|
||||||
|
candidate = self.candidate()
|
||||||
|
entry = candidate["core_release"]
|
||||||
|
entry["webui_package"] = "@govoplan/files-webui"
|
||||||
|
with self.assertRaisesRegex(ValueError, "another source repository"):
|
||||||
|
registry_entry_source(entry)
|
||||||
|
candidate = self.candidate()
|
||||||
|
candidate["release"]["artifacts"][0]["archive_sha256"] = "f" * 64
|
||||||
|
self.assertIn("matching inspected wheel", " ".join(selected_artifact_identity_issues(candidate)))
|
||||||
|
|
||||||
|
def test_registry_version_and_selected_source_identity_must_agree(self) -> None:
|
||||||
|
self.build()
|
||||||
|
for field in ("commit_sha", "tag_object_sha"):
|
||||||
|
candidate = self.candidate()
|
||||||
|
candidate["release"]["selected_units"][0][field] = "f" * 40
|
||||||
|
self.assertIn("differs", " ".join(issue.message for issue in catalog_source_selection(candidate).issues))
|
||||||
|
candidate = self.candidate()
|
||||||
|
candidate["modules"][0]["artifact_integrity"]["python"]["git_ref"] = "v9.9.9"
|
||||||
|
self.assertTrue(candidate_catalog_version_issues(candidate))
|
||||||
|
|
||||||
|
def test_repeated_module_projections_must_bind_identical_python_and_webui_bytes(self) -> None:
|
||||||
|
self.build()
|
||||||
|
for kind in ("python", "webui"):
|
||||||
|
for reversed_order in (False, True):
|
||||||
|
with self.subTest(kind=kind, reversed_order=reversed_order):
|
||||||
|
candidate = self.candidate()
|
||||||
|
entry = deepcopy(candidate["core_release"])
|
||||||
|
entry["module_id"] = "another-core-projection"
|
||||||
|
artifact = entry["artifact_integrity"][kind]
|
||||||
|
artifact["sha256"] = "f" * 64
|
||||||
|
if kind == "python":
|
||||||
|
entry["python_ref"] = artifact["ref"] = entry["python_ref"].split("#sha256=", 1)[0] + "#sha256=" + "f" * 64
|
||||||
|
if reversed_order:
|
||||||
|
original = candidate["core_release"]
|
||||||
|
candidate["core_release"] = entry
|
||||||
|
entry = original
|
||||||
|
candidate["modules"].append(entry)
|
||||||
|
self.assertIn(f"conflicting {kind}", " ".join(selected_artifact_identity_issues(candidate)))
|
||||||
|
self.assertTrue(candidate_catalog_version_issues(candidate))
|
||||||
|
candidate = self.candidate()
|
||||||
|
repeated = deepcopy(candidate["modules"][0])
|
||||||
|
repeated["module_id"] = "second-demo-projection"
|
||||||
|
candidate["modules"].append(repeated)
|
||||||
|
self.assertEqual((), selected_artifact_identity_issues(candidate))
|
||||||
|
self.assertEqual((), candidate_catalog_version_issues(candidate))
|
||||||
|
|
||||||
|
def test_metadata_inspection_uses_the_opened_archive_not_a_replaced_path(self) -> None:
|
||||||
|
path = self.npm / "original.tgz"
|
||||||
|
replacement = self.npm / "replacement.tgz"
|
||||||
|
saved = self.npm / "saved.tgz"
|
||||||
|
self.tarball(path, "@govoplan/incorrect-webui", "1.2.3")
|
||||||
|
self.tarball(replacement, "@govoplan/core-webui", "1.2.3")
|
||||||
|
real_open = tarfile.open
|
||||||
|
|
||||||
|
def replace_path(*args, **kwargs):
|
||||||
|
self.assertIn("fileobj", kwargs)
|
||||||
|
path.rename(saved)
|
||||||
|
replacement.rename(path)
|
||||||
|
return real_open(*args, **kwargs)
|
||||||
|
|
||||||
|
with patch("govoplan_release.full_catalog.tarfile.open", side_effect=replace_path):
|
||||||
|
with self.assertRaisesRegex(ValueError, "metadata differs"):
|
||||||
|
full_catalog._inspect_npm_metadata(path, name="@govoplan/core-webui", version="1.2.3")
|
||||||
|
|
||||||
|
def test_registry_url_provenance_rejects_package_version_and_traversal_mismatch(self) -> None:
|
||||||
|
self.build()
|
||||||
|
for old, new in (
|
||||||
|
("/govoplan-core/1.2.3/", "/govoplan-files/1.2.3/"),
|
||||||
|
("/govoplan-core/1.2.3/", "/govoplan-core/9.9.9/"),
|
||||||
|
("/pypi/files/", "/pypi/files/%2e%2e/"),
|
||||||
|
("/pypi/files/", "/pypi/files/%252e%252e/"),
|
||||||
|
):
|
||||||
|
with self.subTest(new=new):
|
||||||
|
entry = self.candidate()["core_release"]
|
||||||
|
artifact = entry["artifact_integrity"]["python"]
|
||||||
|
artifact["url"] = artifact["url"].replace(old, new)
|
||||||
|
artifact["ref"] = entry["python_ref"] = entry["python_ref"].replace(old, new)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
registry_entry_source(entry)
|
||||||
|
|
||||||
|
def test_package_set_must_match_fixed_meta_pins_not_just_its_own_hash(self) -> None:
|
||||||
|
payload = deepcopy(self.package_set)
|
||||||
|
payload["python"][1]["version"] = "9.9.9"
|
||||||
|
self.seal(payload, "package_set_sha256")
|
||||||
|
self.set_path.write_text(json.dumps(payload))
|
||||||
|
with self.assertRaisesRegex(ValueError, "exact Meta full pins"):
|
||||||
|
self.build()
|
||||||
|
self.assertFalse(self.output.exists())
|
||||||
|
|
||||||
|
def test_package_set_git_reads_ignore_caller_redirection(self) -> None:
|
||||||
|
tool = full_catalog._tool("generate-release-package-set")
|
||||||
|
with patch.dict(os.environ, {"GIT_DIR": "/outside", "GIT_CONFIG_GLOBAL": "/outside/config", "PATH": "/outside/bin"}):
|
||||||
|
with patch("subprocess.check_output", return_value="a" * 40 + "\n") as execute:
|
||||||
|
self.assertEqual("a" * 40, tool["_git"](self.root, "rev-parse", "HEAD"))
|
||||||
|
self.assertEqual("/usr/bin/git", execute.call_args.args[0][0])
|
||||||
|
self.assertNotIn("GIT_DIR", execute.call_args.kwargs["env"])
|
||||||
|
self.assertEqual(os.devnull, execute.call_args.kwargs["env"]["GIT_CONFIG_GLOBAL"])
|
||||||
|
self.assertEqual("/usr/bin:/bin", execute.call_args.kwargs["env"]["PATH"])
|
||||||
|
|
||||||
|
def test_unchanged_real_annotated_ancestor_is_valid_but_selecting_it_requires_head(self) -> None:
|
||||||
|
from tests.test_release_source_provenance import git, git_text, make_repo
|
||||||
|
from govoplan_release.source_provenance import source_tag_provenance_issues
|
||||||
|
|
||||||
|
workspace = self.root / "source-workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
repo, _remote = make_repo(workspace, self.root, "govoplan-access", "1.2.3")
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "reviewed immutable package")
|
||||||
|
git(repo, "push", "origin", "refs/tags/v1.2.3")
|
||||||
|
tagged_commit = git_text(repo, "rev-parse", "HEAD")
|
||||||
|
(repo / "workflow-only.txt").write_text("post-tag workflow repair\n")
|
||||||
|
git(repo, "add", "workflow-only.txt")
|
||||||
|
git(repo, "commit", "-m", "repair workflow without replacing package")
|
||||||
|
git(repo, "push", "origin", "main")
|
||||||
|
common = {
|
||||||
|
"repo_versions": {"govoplan-access": "1.2.3"}, "workspace": workspace,
|
||||||
|
"expected_commits": {"govoplan-access": tagged_commit},
|
||||||
|
"expected_tag_objects": {"govoplan-access": git_text(repo, "rev-parse", "refs/tags/v1.2.3")},
|
||||||
|
}
|
||||||
|
self.assertEqual((), source_tag_provenance_issues(**common))
|
||||||
|
issues = source_tag_provenance_issues(**common, require_head_repos=("govoplan-access",))
|
||||||
|
self.assertIn("not selected HEAD", " ".join(issue.message for issue in issues))
|
||||||
|
|
||||||
|
def test_tagged_manifest_synthesis_ignores_local_git_replacement_objects(self) -> None:
|
||||||
|
from tests.test_release_source_provenance import git, git_text, make_repo
|
||||||
|
from govoplan_release.catalog_entry_synthesis import materialized_source_tree
|
||||||
|
|
||||||
|
workspace = self.root / "materialization-workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
repo, _remote = make_repo(workspace, self.root, "govoplan-access", "1.2.3")
|
||||||
|
original = (repo / "pyproject.toml").read_text()
|
||||||
|
tagged_commit = git_text(repo, "rev-parse", "HEAD")
|
||||||
|
git(repo, "tag", "-a", "v1.2.3", "-m", "reviewed immutable package")
|
||||||
|
(repo / "pyproject.toml").write_text(original.replace("1.2.3", "9.9.9"))
|
||||||
|
git(repo, "add", "pyproject.toml")
|
||||||
|
git(repo, "commit", "-m", "unreviewed replacement tree")
|
||||||
|
git(repo, "replace", tagged_commit, git_text(repo, "rev-parse", "HEAD"))
|
||||||
|
with patch.dict(os.environ, {"GIT_DIR": str(self.root / "outside"), "PATH": "/outside/bin"}):
|
||||||
|
with materialized_source_tree(repo, source_ref="v1.2.3") as source:
|
||||||
|
self.assertEqual(original, (source / "pyproject.toml").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -23,6 +25,11 @@ from govoplan_core.core.institutional import (
|
|||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
service_launch_capability,
|
service_launch_capability,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.temporal import (
|
||||||
|
TemporalDataContext,
|
||||||
|
bind_temporal_data_context,
|
||||||
|
reset_temporal_data_context,
|
||||||
|
)
|
||||||
from govoplan_cases.backend.party_context import CasePartyContext
|
from govoplan_cases.backend.party_context import CasePartyContext
|
||||||
from govoplan_cases.backend.db.models import (
|
from govoplan_cases.backend.db.models import (
|
||||||
CaseAccessGrant,
|
CaseAccessGrant,
|
||||||
@@ -66,6 +73,11 @@ from govoplan_services.backend.service import SqlServiceDefinitionProvider, reco
|
|||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
|
JOURNEY = json.loads(
|
||||||
|
(Path(__file__).parent / "fixtures/resident_parking_permit_journey.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _reference(
|
def _reference(
|
||||||
@@ -127,6 +139,13 @@ class _Registry:
|
|||||||
|
|
||||||
|
|
||||||
class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
# Portal's effective_at does not replace the SQL provider's request-local
|
||||||
|
# read clock. Keep both on the journey date, without bypassing validity
|
||||||
|
# filtering or extending the fixture's finite publication interval.
|
||||||
|
token = bind_temporal_data_context(TemporalDataContext(evaluated_at=NOW))
|
||||||
|
self.addCleanup(reset_temporal_data_context, token)
|
||||||
|
|
||||||
def test_service_to_formal_outcome_retains_governed_context(self) -> None:
|
def test_service_to_formal_outcome_retains_governed_context(self) -> None:
|
||||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
for table in (
|
for table in (
|
||||||
@@ -177,31 +196,31 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
service = ServiceDefinition(
|
service = ServiceDefinition(
|
||||||
reference=_reference(
|
reference=_reference(
|
||||||
"service",
|
"service",
|
||||||
"permit-service",
|
JOURNEY["service"]["object_id"],
|
||||||
owner="services",
|
owner="services",
|
||||||
version="5",
|
version=JOURNEY["service"]["version"],
|
||||||
),
|
),
|
||||||
key="permit.apply",
|
key=JOURNEY["service"]["key"],
|
||||||
temporal=TemporalRevision(
|
temporal=TemporalRevision(
|
||||||
revision="5",
|
revision=JOURNEY["service"]["version"],
|
||||||
valid_from=NOW - timedelta(days=1),
|
valid_from=NOW - timedelta(days=1),
|
||||||
valid_to=NOW + timedelta(days=30),
|
valid_to=NOW + timedelta(days=30),
|
||||||
recorded_at=NOW - timedelta(days=2),
|
recorded_at=NOW - timedelta(days=2),
|
||||||
change_reason="Service published.",
|
change_reason="Service published.",
|
||||||
),
|
),
|
||||||
title="Apply for a permit",
|
title=JOURNEY["title"],
|
||||||
audience=("resident",),
|
audience=("resident",),
|
||||||
legal_bases=(legal_basis,),
|
legal_bases=(legal_basis,),
|
||||||
required_evidence_types=("application", "identity"),
|
required_evidence_types=tuple(JOURNEY["service"]["required_evidence_types"]),
|
||||||
channels=("portal", "postbox"),
|
channels=tuple(JOURNEY["service"]["channels"]) + ("postbox",),
|
||||||
responsible_organization_ref=organization,
|
responsible_organization_ref=organization,
|
||||||
responsible_function_ref=function,
|
responsible_function_ref=function,
|
||||||
mandate_ref=mandate_ref,
|
mandate_ref=mandate_ref,
|
||||||
jurisdiction_refs=(jurisdiction,),
|
jurisdiction_refs=(jurisdiction,),
|
||||||
bindings=(
|
bindings=(
|
||||||
ServiceBinding("case", "permit-application"),
|
ServiceBinding("case", JOURNEY["case"]["type_key"]),
|
||||||
ServiceBinding("workflow", "workflow:permit-review"),
|
ServiceBinding("workflow", "workflow:resident-parking-permit-review"),
|
||||||
ServiceBinding("result", "decision:permit"),
|
ServiceBinding("result", f"decision:{JOURNEY['decision']['type']}"),
|
||||||
),
|
),
|
||||||
remedy_refs=("review:administrative-court",),
|
remedy_refs=("review:administrative-court",),
|
||||||
publication_state="published",
|
publication_state="published",
|
||||||
@@ -213,13 +232,44 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
service_launch_capability("case"): object(),
|
service_launch_capability("case"): object(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
entry = PortalServiceDirectory(service_registry).list_entries(
|
directory = PortalServiceDirectory(service_registry)
|
||||||
|
for outside_interval in (
|
||||||
|
service.temporal.valid_from - timedelta(microseconds=1),
|
||||||
|
service.temporal.valid_to,
|
||||||
|
):
|
||||||
|
with self.subTest(outside_interval=outside_interval):
|
||||||
|
token = bind_temporal_data_context(
|
||||||
|
TemporalDataContext(evaluated_at=outside_interval)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Keep Portal inside the valid interval: the real SQL
|
||||||
|
# provider must still exclude a service outside its own
|
||||||
|
# temporal read context, before Portal can project it.
|
||||||
|
self.assertEqual(
|
||||||
|
(),
|
||||||
|
directory.list_entries(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
effective_at=NOW,
|
||||||
|
audiences=("resident",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
reset_temporal_data_context(token)
|
||||||
|
|
||||||
|
entries = directory.list_entries(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
effective_at=NOW,
|
effective_at=NOW,
|
||||||
audiences=("resident",),
|
audiences=("resident",),
|
||||||
)[0]
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
(service.reference,),
|
||||||
|
tuple(entry.definition.reference for entry in entries),
|
||||||
|
)
|
||||||
|
entry = entries[0]
|
||||||
self.assertTrue(entry.available)
|
self.assertTrue(entry.available)
|
||||||
intake = CaseServiceIntake().plan(
|
intake = CaseServiceIntake().plan(
|
||||||
entry.definition,
|
entry.definition,
|
||||||
@@ -230,37 +280,40 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
upsert_case_status(
|
upsert_case_status(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
status_key="intake",
|
status_key=JOURNEY["case"]["initial_status"],
|
||||||
label="Intake",
|
label="Intake",
|
||||||
)
|
)
|
||||||
upsert_case_status(
|
upsert_case_status(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
status_key="decided",
|
status_key=JOURNEY["case"]["decided_status"],
|
||||||
label="Decided",
|
label="Decided",
|
||||||
category="decided",
|
category="decided",
|
||||||
)
|
)
|
||||||
upsert_case_type(
|
upsert_case_type(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
type_key="permit-application",
|
type_key=JOURNEY["case"]["type_key"],
|
||||||
label="Permit application",
|
label=JOURNEY["title"],
|
||||||
initial_status_key="intake",
|
initial_status_key=JOURNEY["case"]["initial_status"],
|
||||||
allowed_status_keys=("intake", "decided"),
|
allowed_status_keys=(
|
||||||
|
JOURNEY["case"]["initial_status"],
|
||||||
|
JOURNEY["case"]["decided_status"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
case_record = create_case_from_intake(
|
case_record = create_case_from_intake(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
plan=intake,
|
plan=intake,
|
||||||
case_number="PERMIT-2026-0001",
|
case_number=JOURNEY["case"]["number"],
|
||||||
title="Permit application",
|
title=JOURNEY["title"],
|
||||||
status_key=None,
|
status_key=JOURNEY["case"]["initial_status"],
|
||||||
opened_at=NOW,
|
opened_at=NOW,
|
||||||
recorded_at=NOW,
|
recorded_at=NOW,
|
||||||
change_reason="Portal application received.",
|
change_reason="Portal application received.",
|
||||||
idempotency_key="journey-case-create",
|
idempotency_key="journey-case-create",
|
||||||
evidence_refs=(application_evidence,),
|
evidence_refs=(application_evidence,),
|
||||||
deadline_at=NOW + timedelta(days=30),
|
deadline_at=NOW + timedelta(days=JOURNEY["case"]["deadline_days"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
applicant = _reference("party", "applicant", owner="parties")
|
applicant = _reference("party", "applicant", owner="parties")
|
||||||
@@ -333,7 +386,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
change_reason="Permit authority delegated.",
|
change_reason="Permit authority delegated.",
|
||||||
),
|
),
|
||||||
task_types=("committee.formal_decision",),
|
task_types=("committee.formal_decision",),
|
||||||
authority_types=("permit",),
|
authority_types=(JOURNEY["decision"]["type"],),
|
||||||
organization_unit_refs=(organization,),
|
organization_unit_refs=(organization,),
|
||||||
function_refs=(function,),
|
function_refs=(function,),
|
||||||
jurisdiction_refs=(jurisdiction,),
|
jurisdiction_refs=(jurisdiction,),
|
||||||
@@ -380,7 +433,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
object_id="item-1",
|
object_id="item-1",
|
||||||
revision=1,
|
revision=1,
|
||||||
state="deliberating",
|
state="deliberating",
|
||||||
title="Permit application",
|
title=JOURNEY["title"],
|
||||||
parent_id=meeting.object_id,
|
parent_id=meeting.object_id,
|
||||||
recorded_at=NOW,
|
recorded_at=NOW,
|
||||||
change_reason="Agenda item entered deliberation.",
|
change_reason="Agenda item entered deliberation.",
|
||||||
@@ -398,7 +451,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
object_id="vote-1",
|
object_id="vote-1",
|
||||||
revision=1,
|
revision=1,
|
||||||
state="closed",
|
state="closed",
|
||||||
title="Vote on permit application",
|
title=f"Vote on {JOURNEY['title'].lower()}",
|
||||||
parent_id=agenda.object_id,
|
parent_id=agenda.object_id,
|
||||||
recorded_at=NOW,
|
recorded_at=NOW,
|
||||||
change_reason="Vote result accepted.",
|
change_reason="Vote result accepted.",
|
||||||
@@ -445,7 +498,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
effective_at=NOW,
|
effective_at=NOW,
|
||||||
meeting_ref="meeting-1",
|
meeting_ref="meeting-1",
|
||||||
agenda_item_ref="item-1",
|
agenda_item_ref="item-1",
|
||||||
decision_type="permit",
|
decision_type=JOURNEY["decision"]["type"],
|
||||||
subject_refs=(case_record.reference,),
|
subject_refs=(case_record.reference,),
|
||||||
organization_unit_ref=organization,
|
organization_unit_ref=organization,
|
||||||
function_ref=function,
|
function_ref=function,
|
||||||
@@ -461,8 +514,8 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
fact_evidence=(application_evidence, address_evidence),
|
fact_evidence=(application_evidence, address_evidence),
|
||||||
legal_bases=(legal_basis,),
|
legal_bases=(legal_basis,),
|
||||||
operative_result="Permit granted.",
|
operative_result=JOURNEY["decision"]["operative_result"],
|
||||||
reasoning="The application satisfies the effective rule.",
|
reasoning=JOURNEY["decision"]["reasoning"],
|
||||||
case_ref=case_record.reference,
|
case_ref=case_record.reference,
|
||||||
jurisdiction_refs=(jurisdiction,),
|
jurisdiction_refs=(jurisdiction,),
|
||||||
party_refs=(applicant, representative),
|
party_refs=(applicant, representative),
|
||||||
@@ -475,7 +528,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
classification="restricted",
|
classification="restricted",
|
||||||
purposes=("permit-decision", "party-delivery"),
|
purposes=("permit-decision", "party-delivery"),
|
||||||
legal_basis_refs=("permit-law:3@2026-01",),
|
legal_basis_refs=("permit-law:3@2026-01",),
|
||||||
retention_policy_ref="records:permit",
|
retention_policy_ref=JOURNEY["records"]["retention_policy_ref"],
|
||||||
disclosure_state="partly_disclosable",
|
disclosure_state="partly_disclosable",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -568,7 +621,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
get_case(session, principal, case_id="case-1"),
|
get_case(session, principal, case_id="case-1"),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"decided",
|
JOURNEY["case"]["decided_status"],
|
||||||
get_workspace_object(
|
get_workspace_object(
|
||||||
session,
|
session,
|
||||||
principal,
|
principal,
|
||||||
@@ -583,7 +636,7 @@ class InstitutionalGovernanceJourneyTests(unittest.TestCase):
|
|||||||
self.assertEqual("confirmed", reconstruction["observed_effects"][0]["state"])
|
self.assertEqual("confirmed", reconstruction["observed_effects"][0]["state"])
|
||||||
self.assertEqual("audit:delivery-1", reconstruction["observed_effects"][0]["audit_event_refs"][0])
|
self.assertEqual("audit:delivery-1", reconstruction["observed_effects"][0]["audit_event_refs"][0])
|
||||||
self.assertEqual("application-1", reconstruction["fact_evidence"][0]["evidence_id"])
|
self.assertEqual("application-1", reconstruction["fact_evidence"][0]["evidence_id"])
|
||||||
self.assertEqual("The application satisfies the effective rule.", reconstruction["reasoning"])
|
self.assertEqual(JOURNEY["decision"]["reasoning"], reconstruction["reasoning"])
|
||||||
self.assertEqual("review:administrative-court", reconstruction["review_refs"][0])
|
self.assertEqual("review:administrative-court", reconstruction["review_refs"][0])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
@@ -13,6 +16,7 @@ from govoplan_core.core.access import PrincipalRef
|
|||||||
from govoplan_core.core.institutional import (
|
from govoplan_core.core.institutional import (
|
||||||
CAPABILITY_FORM_DEFINITIONS,
|
CAPABILITY_FORM_DEFINITIONS,
|
||||||
CAPABILITY_SERVICE_DEFINITIONS,
|
CAPABILITY_SERVICE_DEFINITIONS,
|
||||||
|
EvidenceReference,
|
||||||
FormDefinition,
|
FormDefinition,
|
||||||
FormFieldDefinition,
|
FormFieldDefinition,
|
||||||
InstitutionalReference,
|
InstitutionalReference,
|
||||||
@@ -21,6 +25,11 @@ from govoplan_core.core.institutional import (
|
|||||||
TemporalRevision,
|
TemporalRevision,
|
||||||
service_launch_capability,
|
service_launch_capability,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
|
||||||
|
from govoplan_core.core.payments import (
|
||||||
|
ManualPaymentReconciliationCommand,
|
||||||
|
PaymentRequestCommand,
|
||||||
|
)
|
||||||
from govoplan_core.core.runtime_coordination import (
|
from govoplan_core.core.runtime_coordination import (
|
||||||
DistributedLease,
|
DistributedLease,
|
||||||
RuntimeIdentity,
|
RuntimeIdentity,
|
||||||
@@ -42,15 +51,30 @@ from govoplan_forms.backend.service import (
|
|||||||
record_form_definition,
|
record_form_definition,
|
||||||
)
|
)
|
||||||
from govoplan_forms_runtime.backend.db.models import (
|
from govoplan_forms_runtime.backend.db.models import (
|
||||||
|
FormAssistedConfirmation,
|
||||||
FormInstanceEvent,
|
FormInstanceEvent,
|
||||||
FormInstanceIdentity,
|
FormInstanceIdentity,
|
||||||
FormInstanceRevision,
|
FormInstanceRevision,
|
||||||
|
FormIntakeProfile,
|
||||||
|
FormIntakeSession,
|
||||||
|
FormStatusAccessGrant,
|
||||||
|
FormStatusAccessPolicy,
|
||||||
|
FormStatusAccessToken,
|
||||||
)
|
)
|
||||||
|
from govoplan_forms_runtime.backend.intake import FormIntakeService
|
||||||
from govoplan_forms_runtime.backend.service import (
|
from govoplan_forms_runtime.backend.service import (
|
||||||
|
FormRuntimeError,
|
||||||
FormRuntimeService,
|
FormRuntimeService,
|
||||||
FormsServiceLauncher,
|
FormsServiceLauncher,
|
||||||
)
|
)
|
||||||
|
from govoplan_forms_runtime.backend.status_access import FormStatusAccessService
|
||||||
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
from govoplan_portal.backend.service_directory import PortalServiceDirectory
|
||||||
|
from govoplan_payments.backend.db.models import (
|
||||||
|
PaymentEvent,
|
||||||
|
PaymentObligation,
|
||||||
|
PaymentReconciliation,
|
||||||
|
)
|
||||||
|
from govoplan_payments.backend.service import SqlPaymentRequestProvider
|
||||||
from govoplan_tasks.backend.aggregation import aggregate_work_items
|
from govoplan_tasks.backend.aggregation import aggregate_work_items
|
||||||
from govoplan_workflow_engine.backend.db.models import (
|
from govoplan_workflow_engine.backend.db.models import (
|
||||||
WorkflowDefinition,
|
WorkflowDefinition,
|
||||||
@@ -82,6 +106,11 @@ from govoplan_workflow_engine.backend.work_items import WorkflowWorkItemProvider
|
|||||||
|
|
||||||
|
|
||||||
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC)
|
||||||
|
JOURNEY = json.loads(
|
||||||
|
(Path(__file__).parent / "fixtures/resident_parking_permit_journey.json").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _service() -> ServiceDefinition:
|
def _service() -> ServiceDefinition:
|
||||||
@@ -89,24 +118,24 @@ def _service() -> ServiceDefinition:
|
|||||||
reference=InstitutionalReference(
|
reference=InstitutionalReference(
|
||||||
kind="service",
|
kind="service",
|
||||||
owner_module="portal",
|
owner_module="portal",
|
||||||
object_id="permit",
|
object_id=JOURNEY["service"]["object_id"],
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
version="5",
|
version=JOURNEY["service"]["version"],
|
||||||
),
|
),
|
||||||
key="permit.apply",
|
key=JOURNEY["service"]["key"],
|
||||||
temporal=TemporalRevision(
|
temporal=TemporalRevision(
|
||||||
revision="5",
|
revision=JOURNEY["service"]["version"],
|
||||||
valid_from=NOW - timedelta(days=1),
|
valid_from=NOW - timedelta(days=1),
|
||||||
valid_to=NOW + timedelta(days=1),
|
valid_to=NOW + timedelta(days=1),
|
||||||
recorded_at=NOW - timedelta(days=2),
|
recorded_at=NOW - timedelta(days=2),
|
||||||
),
|
),
|
||||||
title="Apply for a permit",
|
title=JOURNEY["title"],
|
||||||
audience=("resident",),
|
audience=(JOURNEY["service"]["audience"],),
|
||||||
required_evidence_types=("application",),
|
required_evidence_types=tuple(JOURNEY["service"]["required_evidence_types"]),
|
||||||
bindings=(
|
bindings=(
|
||||||
ServiceBinding("capability", CAPABILITY_CASES_SERVICE_INTAKE),
|
ServiceBinding("capability", CAPABILITY_CASES_SERVICE_INTAKE),
|
||||||
ServiceBinding("case", "permit-application"),
|
ServiceBinding("case", JOURNEY["case"]["type_key"]),
|
||||||
ServiceBinding("workflow", "workflow:permit-review"),
|
ServiceBinding("workflow", "workflow:resident-parking-permit-review"),
|
||||||
),
|
),
|
||||||
publication_state="published",
|
publication_state="published",
|
||||||
)
|
)
|
||||||
@@ -161,9 +190,29 @@ class _FormRegistry(_Registry):
|
|||||||
self.capabilities[service_launch_capability("form")] = FormsServiceLauncher(
|
self.capabilities[service_launch_capability("form")] = FormsServiceLauncher(
|
||||||
self
|
self
|
||||||
)
|
)
|
||||||
|
self.notifications = _NotificationProvider()
|
||||||
|
self.capabilities[CAPABILITY_NOTIFICATIONS_DISPATCH] = self.notifications
|
||||||
|
|
||||||
def has(self, module_id: str) -> bool:
|
def has(self, module_id: str) -> bool:
|
||||||
return module_id in {"portal", "forms", "forms_runtime"}
|
return module_id in {"portal", "forms", "forms_runtime", "notifications"}
|
||||||
|
|
||||||
|
|
||||||
|
class _NotificationProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.requests: list[object] = []
|
||||||
|
|
||||||
|
def tenant_id_for_notification(self, session, *, notification_id):
|
||||||
|
return "tenant-1"
|
||||||
|
|
||||||
|
def enqueue_notification(self, session, request, *, enqueue_delivery=True):
|
||||||
|
self.requests.append(request)
|
||||||
|
return {"id": f"notification-{len(self.requests)}"}
|
||||||
|
|
||||||
|
def deliver_notification(self, session, *, notification_id):
|
||||||
|
return {"id": notification_id}
|
||||||
|
|
||||||
|
def deliver_pending(self, session, *, tenant_id=None, limit=50):
|
||||||
|
return {"delivered": 0}
|
||||||
|
|
||||||
|
|
||||||
class _WorkflowTaskRegistry:
|
class _WorkflowTaskRegistry:
|
||||||
@@ -230,8 +279,19 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
self.assertTrue(entries[0].available)
|
self.assertTrue(entries[0].available)
|
||||||
self.assertIs(definition, entries[0].definition)
|
self.assertIs(definition, entries[0].definition)
|
||||||
self.assertEqual(definition.reference, plan.service_ref)
|
self.assertEqual(definition.reference, plan.service_ref)
|
||||||
self.assertEqual("5", plan.context.service_ref.version)
|
self.assertEqual(JOURNEY["service"]["version"], plan.context.service_ref.version)
|
||||||
self.assertEqual("workflow:permit-review", plan.workflow_refs[0])
|
self.assertEqual("workflow:resident-parking-permit-review", plan.workflow_refs[0])
|
||||||
|
|
||||||
|
def test_reference_fixture_names_remaining_manual_target_evidence(self) -> None:
|
||||||
|
self.assertEqual("Anwohnerparkausweis", JOURNEY["title_de"])
|
||||||
|
self.assertEqual("de-DE", JOURNEY["locale"])
|
||||||
|
self.assertEqual("email_link", JOURNEY["status_access"]["mode"])
|
||||||
|
self.assertEqual("manual", JOURNEY["payment"]["mode"])
|
||||||
|
automated = JOURNEY["acceptance"]["automated"]
|
||||||
|
self.assertEqual(10, len(automated))
|
||||||
|
self.assertTrue(any("desktop and mobile" in item for item in automated))
|
||||||
|
self.assertTrue(any("independent source" in item for item in automated))
|
||||||
|
self.assertEqual(6, len(JOURNEY["acceptance"]["manual_or_target"]))
|
||||||
|
|
||||||
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
|
def test_portal_launches_exact_form_revision_and_persists_submission(self) -> None:
|
||||||
engine = create_engine("sqlite+pysqlite:///:memory:")
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
@@ -252,19 +312,19 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
reference=InstitutionalReference(
|
reference=InstitutionalReference(
|
||||||
kind="form",
|
kind="form",
|
||||||
owner_module="forms",
|
owner_module="forms",
|
||||||
object_id="permit-application",
|
object_id=JOURNEY["form"]["object_id"],
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
version="3",
|
version="3",
|
||||||
),
|
),
|
||||||
key="permit-application",
|
key=JOURNEY["form"]["object_id"],
|
||||||
temporal=TemporalRevision(
|
temporal=TemporalRevision(
|
||||||
revision="3",
|
revision="3",
|
||||||
valid_from=NOW - timedelta(days=1),
|
valid_from=NOW - timedelta(days=1),
|
||||||
valid_to=NOW + timedelta(days=1),
|
valid_to=NOW + timedelta(days=1),
|
||||||
recorded_at=NOW - timedelta(days=2),
|
recorded_at=NOW - timedelta(days=2),
|
||||||
change_reason="Publish the permit application.",
|
change_reason="Publish the resident parking permit application.",
|
||||||
),
|
),
|
||||||
title="Permit application",
|
title=JOURNEY["title"],
|
||||||
fields=(
|
fields=(
|
||||||
FormFieldDefinition(
|
FormFieldDefinition(
|
||||||
key="applicant_name",
|
key="applicant_name",
|
||||||
@@ -272,6 +332,25 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
required=True,
|
required=True,
|
||||||
constraints={"min_length": 2},
|
constraints={"min_length": 2},
|
||||||
),
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="applicant_email",
|
||||||
|
label="Applicant email",
|
||||||
|
value_type="email",
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 5},
|
||||||
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="residence_address",
|
||||||
|
label="Primary residence address",
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 5},
|
||||||
|
),
|
||||||
|
FormFieldDefinition(
|
||||||
|
key="licence_plate",
|
||||||
|
label="Vehicle licence plate",
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 3},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
publication_state="published",
|
publication_state="published",
|
||||||
allow_drafts=True,
|
allow_drafts=True,
|
||||||
@@ -286,18 +365,18 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
reference=InstitutionalReference(
|
reference=InstitutionalReference(
|
||||||
kind="service",
|
kind="service",
|
||||||
owner_module="services",
|
owner_module="services",
|
||||||
object_id="permit",
|
object_id=JOURNEY["service"]["object_id"],
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
version="6",
|
version=JOURNEY["service"]["version"],
|
||||||
),
|
),
|
||||||
key="permit.apply",
|
key=JOURNEY["service"]["key"],
|
||||||
temporal=TemporalRevision(
|
temporal=TemporalRevision(
|
||||||
revision="6",
|
revision=JOURNEY["service"]["version"],
|
||||||
valid_from=NOW - timedelta(days=1),
|
valid_from=NOW - timedelta(days=1),
|
||||||
valid_to=NOW + timedelta(days=1),
|
valid_to=NOW + timedelta(days=1),
|
||||||
recorded_at=NOW - timedelta(days=2),
|
recorded_at=NOW - timedelta(days=2),
|
||||||
),
|
),
|
||||||
title="Apply for a permit",
|
title=JOURNEY["title"],
|
||||||
audience=("public",),
|
audience=("public",),
|
||||||
bindings=(binding,),
|
bindings=(binding,),
|
||||||
publication_state="published",
|
publication_state="published",
|
||||||
@@ -311,7 +390,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
reference=service.reference,
|
reference=service.reference,
|
||||||
requested_at=NOW,
|
requested_at=NOW,
|
||||||
idempotency_key="portal-form-launch-1",
|
idempotency_key="portal-form-launch-1",
|
||||||
parameters={"applicant_name": "Ada Lovelace"},
|
parameters=JOURNEY["form"]["fields"],
|
||||||
)
|
)
|
||||||
replay = directory.launch_service(
|
replay = directory.launch_service(
|
||||||
session,
|
session,
|
||||||
@@ -319,7 +398,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
reference=service.reference,
|
reference=service.reference,
|
||||||
requested_at=NOW,
|
requested_at=NOW,
|
||||||
idempotency_key="portal-form-launch-1",
|
idempotency_key="portal-form-launch-1",
|
||||||
parameters={"applicant_name": "Ada Lovelace"},
|
parameters=JOURNEY["form"]["fields"],
|
||||||
)
|
)
|
||||||
instance = FormRuntimeService(registry).get_instance(
|
instance = FormRuntimeService(registry).get_instance(
|
||||||
session,
|
session,
|
||||||
@@ -348,11 +427,231 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(NOW, instance.definition_ref.valid_at)
|
self.assertEqual(NOW, instance.definition_ref.valid_at)
|
||||||
self.assertEqual(service.reference, instance.service_ref)
|
self.assertEqual(service.reference, instance.service_ref)
|
||||||
self.assertEqual("Ada Lovelace", instance.values["applicant_name"])
|
self.assertEqual(JOURNEY["form"]["fields"], instance.values)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_assisted_intake_reuses_exact_form_and_persists_readback_provenance(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
FormDefinitionRevision.__table__,
|
||||||
|
FormInstanceIdentity.__table__,
|
||||||
|
FormInstanceRevision.__table__,
|
||||||
|
FormInstanceEvent.__table__,
|
||||||
|
FormIntakeProfile.__table__,
|
||||||
|
FormIntakeSession.__table__,
|
||||||
|
FormAssistedConfirmation.__table__,
|
||||||
|
FormStatusAccessPolicy.__table__,
|
||||||
|
FormStatusAccessGrant.__table__,
|
||||||
|
FormStatusAccessToken.__table__,
|
||||||
|
):
|
||||||
|
table.create(engine)
|
||||||
|
sessions = sessionmaker(bind=engine)
|
||||||
|
principal = _Principal()
|
||||||
|
assisted = JOURNEY["assisted_intake"]
|
||||||
|
try:
|
||||||
|
with sessions() as session:
|
||||||
|
form = record_form_definition(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition=FormDefinition(
|
||||||
|
reference=InstitutionalReference(
|
||||||
|
kind="form",
|
||||||
|
owner_module="forms",
|
||||||
|
object_id=JOURNEY["form"]["object_id"],
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version=JOURNEY["form"]["version"],
|
||||||
|
),
|
||||||
|
key=JOURNEY["form"]["object_id"],
|
||||||
|
temporal=TemporalRevision(
|
||||||
|
revision=JOURNEY["form"]["version"],
|
||||||
|
recorded_at=NOW - timedelta(days=2),
|
||||||
|
change_reason="Publish the resident parking permit application.",
|
||||||
|
),
|
||||||
|
title=JOURNEY["title"],
|
||||||
|
fields=tuple(
|
||||||
|
FormFieldDefinition(
|
||||||
|
key=key,
|
||||||
|
label=key.replace("_", " ").title(),
|
||||||
|
value_type=(
|
||||||
|
"email" if key == "applicant_email" else "text"
|
||||||
|
),
|
||||||
|
required=True,
|
||||||
|
constraints={"min_length": 2},
|
||||||
|
)
|
||||||
|
for key in JOURNEY["form"]["fields"]
|
||||||
|
),
|
||||||
|
publication_state="published",
|
||||||
|
allow_drafts=True,
|
||||||
|
handoff_kinds=("case",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
registry = _FormRegistry(_service())
|
||||||
|
status_access = JOURNEY["status_access"]
|
||||||
|
FormStatusAccessService(registry).upsert_policy(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=form.reference,
|
||||||
|
mode=status_access["mode"],
|
||||||
|
enabled=True,
|
||||||
|
email_field_key=status_access["email_field_key"],
|
||||||
|
token_ttl_seconds=status_access["token_ttl_seconds"],
|
||||||
|
request_limit_per_hour=status_access[
|
||||||
|
"request_limit_per_hour"
|
||||||
|
],
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
intake = FormIntakeService(registry)
|
||||||
|
profile = intake.create_profile(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
definition_ref=form.reference,
|
||||||
|
mode="assisted",
|
||||||
|
custodian_ref=assisted["responsible_function_ref"],
|
||||||
|
recorded_at=NOW,
|
||||||
|
)
|
||||||
|
started = intake.start_assisted(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
profile_id=profile.profile_id,
|
||||||
|
values=JOURNEY["form"]["fields"],
|
||||||
|
channel=assisted["channel"],
|
||||||
|
affected_party_ref=assisted["affected_party_ref"],
|
||||||
|
represented_party_ref=assisted["represented_party_ref"],
|
||||||
|
authority_basis=assisted["authority_basis"],
|
||||||
|
purpose=assisted["purpose"],
|
||||||
|
legal_basis_ref=assisted["legal_basis_ref"],
|
||||||
|
consent_basis=assisted["consent_basis"],
|
||||||
|
notice_given=assisted["notice_given"],
|
||||||
|
responsible_function_ref=assisted["responsible_function_ref"],
|
||||||
|
language=assisted["language"],
|
||||||
|
accessibility_needs=assisted["accessibility_needs"],
|
||||||
|
field_sources={
|
||||||
|
key: {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": assisted["affected_party_ref"],
|
||||||
|
}
|
||||||
|
for key in JOURNEY["form"]["fields"]
|
||||||
|
},
|
||||||
|
idempotency_key="resident-permit-assisted-start",
|
||||||
|
recorded_at=NOW + timedelta(minutes=1),
|
||||||
|
)
|
||||||
|
self.assertEqual(form.reference, started.instance.definition_ref)
|
||||||
|
self.assertEqual(JOURNEY["form"]["fields"], started.instance.values)
|
||||||
|
self.assertEqual(
|
||||||
|
assisted["purpose"], started.instance.metadata["intake"]["purpose"]
|
||||||
|
)
|
||||||
|
instance_id = started.instance.instance_id
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with sessions() as resumed:
|
||||||
|
runtime = FormRuntimeService(registry)
|
||||||
|
current = runtime.get_instance(
|
||||||
|
resumed,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(current)
|
||||||
|
with self.assertRaisesRegex(FormRuntimeError, "read-back confirmation"):
|
||||||
|
runtime.submit_instance(
|
||||||
|
resumed,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
values=current.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="resident-permit-assisted-unconfirmed",
|
||||||
|
recorded_at=NOW + timedelta(minutes=2),
|
||||||
|
)
|
||||||
|
confirmation = FormIntakeService(registry).record_assisted_confirmation(
|
||||||
|
resumed,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
values=current.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
outcome=assisted["confirmation_outcome"],
|
||||||
|
method=assisted["confirmation_method"],
|
||||||
|
confirmed_by_ref=assisted["affected_party_ref"],
|
||||||
|
confirmed_at=NOW + timedelta(minutes=3),
|
||||||
|
idempotency_key="resident-permit-assisted-readback",
|
||||||
|
field_sources={
|
||||||
|
key: {
|
||||||
|
"source": "person_statement",
|
||||||
|
"confidence": "stated",
|
||||||
|
"declared_by_ref": assisted["affected_party_ref"],
|
||||||
|
}
|
||||||
|
for key in JOURNEY["form"]["fields"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
submitted = runtime.submit_instance(
|
||||||
|
resumed,
|
||||||
|
principal,
|
||||||
|
instance_id=instance_id,
|
||||||
|
expected_revision=current.revision,
|
||||||
|
values=current.values,
|
||||||
|
attachment_refs=(),
|
||||||
|
signature_refs=(),
|
||||||
|
idempotency_key="resident-permit-assisted-submit",
|
||||||
|
recorded_at=NOW + timedelta(minutes=4),
|
||||||
|
)
|
||||||
|
resumed.commit()
|
||||||
|
self.assertEqual("submitted", submitted.status)
|
||||||
|
self.assertEqual(current.revision, confirmation.instance_revision)
|
||||||
|
self.assertEqual(assisted["affected_party_ref"], confirmation.confirmed_by_ref)
|
||||||
|
status_service = FormStatusAccessService(registry)
|
||||||
|
access = status_service.access_summary_for_instance(
|
||||||
|
resumed,
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
instance_id=instance_id,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(access)
|
||||||
|
tracking_id = str(access["tracking_id"])
|
||||||
|
challenge = status_service.public_access_challenge(
|
||||||
|
resumed,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
)
|
||||||
|
self.assertEqual("email_link", challenge["mode"])
|
||||||
|
self.assertFalse(
|
||||||
|
status_service.request_email_link(
|
||||||
|
resumed,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
email="wrong@example.test",
|
||||||
|
requested_at=NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
status_service.request_email_link(
|
||||||
|
resumed,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
email=JOURNEY["form"]["fields"]["applicant_email"],
|
||||||
|
requested_at=NOW + timedelta(minutes=6),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
notification = registry.notifications.requests[-1]
|
||||||
|
query = parse_qs(urlparse(notification.action_url).query)
|
||||||
|
projection = status_service.get_public_projection(
|
||||||
|
resumed,
|
||||||
|
tracking_id=tracking_id,
|
||||||
|
token=query["token"][0],
|
||||||
|
observed_at=NOW + timedelta(minutes=7),
|
||||||
|
)
|
||||||
|
self.assertEqual("submitted", projection["status"])
|
||||||
|
self.assertEqual(JOURNEY["title"], projection["title"])
|
||||||
|
self.assertEqual(
|
||||||
|
["submitted"],
|
||||||
|
[item["status"] for item in projection["timeline"]],
|
||||||
|
)
|
||||||
|
self.assertNotIn("values", projection)
|
||||||
|
finally:
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
def test_workflow_handoff_survives_session_reopen_and_projects_into_tasks(
|
def test_workflow_handoff_survives_session_reopen_and_projects_into_tasks(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -392,7 +691,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
actor_id="account-1",
|
actor_id="account-1",
|
||||||
payload=WorkflowDefinitionCreateRequest(
|
payload=WorkflowDefinitionCreateRequest(
|
||||||
name="Permit decision",
|
name=JOURNEY["workflow"]["definition_name"],
|
||||||
graph=WorkflowGraph(
|
graph=WorkflowGraph(
|
||||||
nodes=[
|
nodes=[
|
||||||
WorkflowNode(
|
WorkflowNode(
|
||||||
@@ -403,8 +702,8 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
id="review",
|
id="review",
|
||||||
type="workflow.activity",
|
type="workflow.activity",
|
||||||
config={
|
config={
|
||||||
"title": "Decide the permit application",
|
"title": JOURNEY["workflow"]["work_item_title"],
|
||||||
"instructions": "Review the filed evidence and record the decision.",
|
"instructions": JOURNEY["workflow"]["instructions"],
|
||||||
"assignee": "account:account-1",
|
"assignee": "account:account-1",
|
||||||
"due_after": "2d",
|
"due_after": "2d",
|
||||||
},
|
},
|
||||||
@@ -464,7 +763,7 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
self.assertEqual(1, work.total)
|
self.assertEqual(1, work.total)
|
||||||
self.assertEqual(step_id, work.items[0].id)
|
self.assertEqual(step_id, work.items[0].id)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"Decide the permit application",
|
JOURNEY["workflow"]["work_item_title"],
|
||||||
work.items[0].title,
|
work.items[0].title,
|
||||||
)
|
)
|
||||||
self.assertTrue(work.items[0].action_url.startswith("/workflow?"))
|
self.assertTrue(work.items[0].action_url.startswith("/workflow?"))
|
||||||
@@ -496,6 +795,76 @@ class InstitutionalServiceJourneyTests(unittest.TestCase):
|
|||||||
bind_process_runtime_identity(None)
|
bind_process_runtime_identity(None)
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
def test_case_bound_payment_handoff_is_replay_safe_and_evidence_bound(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
for table in (
|
||||||
|
PaymentObligation.__table__,
|
||||||
|
PaymentReconciliation.__table__,
|
||||||
|
PaymentEvent.__table__,
|
||||||
|
):
|
||||||
|
table.create(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
provider = SqlPaymentRequestProvider()
|
||||||
|
payment = JOURNEY["payment"]
|
||||||
|
try:
|
||||||
|
command = PaymentRequestCommand(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
source_module="cases",
|
||||||
|
source_resource_type="case",
|
||||||
|
source_resource_id="case-1",
|
||||||
|
amount_minor=payment["amount_minor"],
|
||||||
|
currency=payment["currency"],
|
||||||
|
subject=payment["subject"],
|
||||||
|
idempotency_key="resident-permit-case-1-fee",
|
||||||
|
requested_at=NOW + timedelta(days=1),
|
||||||
|
requested_by_ref="workflow:resident-parking-permit-review",
|
||||||
|
due_at=NOW + timedelta(days=1 + payment["due_days"]),
|
||||||
|
context_refs={
|
||||||
|
"case": "case-1",
|
||||||
|
"workflow": "workflow:resident-parking-permit-review",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
requested = provider.request_payment(session, command)
|
||||||
|
replay = provider.request_payment(session, command)
|
||||||
|
self.assertEqual(requested["payment_id"], replay["payment_id"])
|
||||||
|
self.assertTrue(replay["replayed"])
|
||||||
|
self.assertEqual("case-1", requested["source"]["resource_id"])
|
||||||
|
|
||||||
|
paid = provider.reconcile_manual_payment(
|
||||||
|
session,
|
||||||
|
ManualPaymentReconciliationCommand(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
payment_id=str(requested["payment_id"]),
|
||||||
|
amount_minor=payment["amount_minor"],
|
||||||
|
currency=payment["currency"],
|
||||||
|
transaction_reference="BANK-RPP-2026-0001",
|
||||||
|
evidence_ref=EvidenceReference(
|
||||||
|
kind="document",
|
||||||
|
owner_module=payment["evidence_owner"],
|
||||||
|
evidence_id="file-payment-rpp-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
version="1",
|
||||||
|
checksum="b" * 64,
|
||||||
|
),
|
||||||
|
idempotency_key="resident-permit-bank-receipt-1",
|
||||||
|
received_at=NOW + timedelta(days=2),
|
||||||
|
recorded_at=NOW + timedelta(days=2, minutes=5),
|
||||||
|
recorded_by_ref="account:payment-officer-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
self.assertEqual("paid", paid["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
"BANK-RPP-2026-0001",
|
||||||
|
paid["reconciliation"]["transaction_reference"],
|
||||||
|
)
|
||||||
|
self.assertEqual(2, len(paid["events"]))
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Local mixed-owner admission/recovery evidence, not production capacity certification.
|
||||||
|
|
||||||
|
All inputs are synthetic and held in memory. The spawn observer temporarily
|
||||||
|
holds the admitted parent's handshake so the other owners encounter the same
|
||||||
|
occupied slot deterministically. Children perform real XLSX, template and
|
||||||
|
Dataflow work; there is no mocked process execution, database, or live service.
|
||||||
|
The non-queuing gate promises retryable rejection, not scheduler fairness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from io import BytesIO
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
try:
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from govoplan_connectors.backend.tabular_adapters import (
|
||||||
|
parse_managed_tabular_content,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.templates import TemplateRenderRequest
|
||||||
|
from govoplan_core.security import bounded_process
|
||||||
|
from govoplan_core.security.bounded_process import ProcessBudgetError
|
||||||
|
from govoplan_core.settings import settings
|
||||||
|
from govoplan_dataflow.backend.backends import execute_typed_graph
|
||||||
|
from govoplan_dataflow.backend.schemas import (
|
||||||
|
GraphEdge,
|
||||||
|
GraphNode,
|
||||||
|
GraphPosition,
|
||||||
|
PipelineGraph,
|
||||||
|
)
|
||||||
|
from govoplan_templates.backend.rendering import _render_payload
|
||||||
|
except ImportError as exc:
|
||||||
|
raise unittest.SkipTest(
|
||||||
|
"Mixed-owner isolation requires the optional module test environment."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
class IsolatedWorkCompositionTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
workbook = Workbook()
|
||||||
|
workbook.active.append(["name"])
|
||||||
|
workbook.active.append(["Ada"])
|
||||||
|
stream = BytesIO()
|
||||||
|
workbook.save(stream)
|
||||||
|
workbook.close()
|
||||||
|
self.workbook = stream.getvalue()
|
||||||
|
self.graph = PipelineGraph(
|
||||||
|
nodes=[
|
||||||
|
GraphNode(
|
||||||
|
id="source",
|
||||||
|
type="source.inline",
|
||||||
|
label="Source",
|
||||||
|
position=GraphPosition(x=0, y=0),
|
||||||
|
config={"source_name": "records", "rows": [{"name": "Ada"}]},
|
||||||
|
),
|
||||||
|
GraphNode(
|
||||||
|
id="output",
|
||||||
|
type="output",
|
||||||
|
label="Output",
|
||||||
|
position=GraphPosition(x=100, y=0),
|
||||||
|
config={},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
edges=[GraphEdge(id="edge", source="source", target="output")],
|
||||||
|
)
|
||||||
|
|
||||||
|
def xlsx(self):
|
||||||
|
rows, sheet = parse_managed_tabular_content(
|
||||||
|
self.workbook,
|
||||||
|
filename="synthetic.xlsx",
|
||||||
|
content_type=None,
|
||||||
|
delimiter=",",
|
||||||
|
sheet_name=None,
|
||||||
|
)
|
||||||
|
self.assertEqual(rows, ({"name": "Ada"},))
|
||||||
|
self.assertEqual(sheet, "Sheet")
|
||||||
|
return "xlsx"
|
||||||
|
|
||||||
|
def templates(self):
|
||||||
|
payload, content_type, _pages = _render_payload(
|
||||||
|
SimpleNamespace(name="Synthetic template"),
|
||||||
|
SimpleNamespace(
|
||||||
|
content_text="Hello {{item.name}}",
|
||||||
|
content_html=None,
|
||||||
|
template_type="letter",
|
||||||
|
layout={},
|
||||||
|
output_profiles=[],
|
||||||
|
),
|
||||||
|
request=TemplateRenderRequest(
|
||||||
|
template_id="synthetic", output_format="text"
|
||||||
|
),
|
||||||
|
items=({"name": "Ada"},),
|
||||||
|
)
|
||||||
|
self.assertEqual(payload, b"Hello Ada")
|
||||||
|
self.assertEqual(content_type, "text/plain; charset=utf-8")
|
||||||
|
return "templates"
|
||||||
|
|
||||||
|
def dataflow(self):
|
||||||
|
result = execute_typed_graph(self.graph, backend="reference")
|
||||||
|
self.assertEqual(result.rows, [{"name": "Ada"}])
|
||||||
|
return "dataflow"
|
||||||
|
|
||||||
|
def test_one_shared_slot_rejects_other_owners_and_all_retries_recover(self):
|
||||||
|
owners = {
|
||||||
|
"xlsx": self.xlsx,
|
||||||
|
"templates": self.templates,
|
||||||
|
"dataflow": self.dataflow,
|
||||||
|
}
|
||||||
|
processes = []
|
||||||
|
modules = []
|
||||||
|
hold_next = False
|
||||||
|
entered = threading.Event()
|
||||||
|
release = threading.Event()
|
||||||
|
observer_lock = threading.Lock()
|
||||||
|
maximum_unreaped = 0
|
||||||
|
observer_timeouts = 0
|
||||||
|
original_popen = bounded_process.subprocess.Popen
|
||||||
|
|
||||||
|
def observe_spawn(*args, **kwargs):
|
||||||
|
nonlocal hold_next, maximum_unreaped, observer_timeouts
|
||||||
|
process = original_popen(*args, **kwargs)
|
||||||
|
with observer_lock:
|
||||||
|
processes.append(process)
|
||||||
|
modules.append(args[0][5])
|
||||||
|
maximum_unreaped = max(
|
||||||
|
maximum_unreaped, sum(item.returncode is None for item in processes)
|
||||||
|
)
|
||||||
|
should_hold = hold_next
|
||||||
|
hold_next = False
|
||||||
|
if should_hold:
|
||||||
|
entered.set()
|
||||||
|
if not release.wait(8):
|
||||||
|
# Return control so the real runner's normal timeout and
|
||||||
|
# process-group cleanup still own this child on test error.
|
||||||
|
observer_timeouts += 1
|
||||||
|
return process
|
||||||
|
|
||||||
|
def rejected(operation):
|
||||||
|
try:
|
||||||
|
operation()
|
||||||
|
except Exception as exc:
|
||||||
|
cause = exc
|
||||||
|
while cause is not None and not isinstance(cause, ProcessBudgetError):
|
||||||
|
cause = cause.__cause__
|
||||||
|
self.assertIsInstance(cause, ProcessBudgetError)
|
||||||
|
self.assertEqual(cause.code, "busy")
|
||||||
|
return "busy"
|
||||||
|
self.fail(
|
||||||
|
"A different module admitted work while the shared slot was occupied."
|
||||||
|
)
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
busy_count = 0
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch.object(settings, "isolated_process_concurrency", 1),
|
||||||
|
patch.object(bounded_process.subprocess, "Popen", observe_spawn),
|
||||||
|
ThreadPoolExecutor(max_workers=3) as executor,
|
||||||
|
):
|
||||||
|
for owner, operation in owners.items():
|
||||||
|
with self.subTest(admitted_owner=owner):
|
||||||
|
entered.clear()
|
||||||
|
release.clear()
|
||||||
|
hold_next = True
|
||||||
|
holder = executor.submit(operation)
|
||||||
|
try:
|
||||||
|
self.assertTrue(
|
||||||
|
entered.wait(5),
|
||||||
|
"The admitted operation never spawned its real child.",
|
||||||
|
)
|
||||||
|
children_before = len(processes)
|
||||||
|
others = [
|
||||||
|
work for label, work in owners.items() if label != owner
|
||||||
|
]
|
||||||
|
denied = [
|
||||||
|
executor.submit(rejected, work) for work in others
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[future.result(timeout=5) for future in denied],
|
||||||
|
["busy", "busy"],
|
||||||
|
)
|
||||||
|
busy_count += len(denied)
|
||||||
|
self.assertEqual(len(processes), children_before)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
self.assertEqual(holder.result(timeout=15), owner)
|
||||||
|
self.assertEqual(bounded_process._active, 0)
|
||||||
|
# Every rejected owner is retried through its real API.
|
||||||
|
# Each must complete after the previous holder releases.
|
||||||
|
for other in others:
|
||||||
|
other()
|
||||||
|
self.assertEqual(bounded_process._active, 0)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
for process in processes:
|
||||||
|
self.assertIsNotNone(process.returncode, "Worker was not reaped.")
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
stream.closed
|
||||||
|
for stream in (process.stdin, process.stdout, process.stderr)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(ChildProcessError):
|
||||||
|
os.waitpid(process.pid, os.WNOHANG)
|
||||||
|
self.assertEqual(maximum_unreaped, 1)
|
||||||
|
self.assertEqual(observer_timeouts, 0)
|
||||||
|
self.assertEqual(len(processes), 9)
|
||||||
|
self.assertEqual(busy_count, 6)
|
||||||
|
self.assertEqual(
|
||||||
|
set(modules),
|
||||||
|
{
|
||||||
|
"govoplan_connectors.backend.tabular_adapters",
|
||||||
|
"govoplan_templates.backend.rendering",
|
||||||
|
"govoplan_dataflow.backend.backends.reference",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"local_composition": {
|
||||||
|
"successful_children": len(processes),
|
||||||
|
"busy_rejections": busy_count,
|
||||||
|
"maximum_unreaped_children": maximum_unreaped,
|
||||||
|
"all_children_reaped": True,
|
||||||
|
"seconds": round(time.monotonic() - started, 3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -57,22 +57,42 @@ class PackageRegistryReleaseTests(unittest.TestCase):
|
|||||||
self.assertEqual(ARTIFACTS._canonical_sha256(unsigned), digest)
|
self.assertEqual(ARTIFACTS._canonical_sha256(unsigned), digest)
|
||||||
|
|
||||||
def test_full_profile_is_derived_from_the_developer_meta_package(self) -> None:
|
def test_full_profile_is_derived_from_the_developer_meta_package(self) -> None:
|
||||||
|
core_version = tomllib.loads(
|
||||||
|
(ROOT.parent / "govoplan-core/pyproject.toml").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
)["project"]["version"]
|
||||||
|
meta_package = ROOT / "packages/govoplan-meta/pyproject.toml"
|
||||||
|
meta_project = tomllib.loads(
|
||||||
|
meta_package.read_text(encoding="utf-8")
|
||||||
|
)["project"]
|
||||||
|
expected_tasks_pin = next(
|
||||||
|
requirement
|
||||||
|
for requirement in (
|
||||||
|
*meta_project["dependencies"],
|
||||||
|
*meta_project["optional-dependencies"]["full"],
|
||||||
|
)
|
||||||
|
if requirement.startswith("govoplan-tasks==")
|
||||||
|
)
|
||||||
selected = PACKAGE_SET.parse_meta_package(
|
selected = PACKAGE_SET.parse_meta_package(
|
||||||
ROOT / "packages/govoplan-meta/pyproject.toml",
|
meta_package,
|
||||||
core_version="0.1.18",
|
core_version=core_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
by_name = {item["name"]: item for item in selected}
|
by_name = {item["name"]: item for item in selected}
|
||||||
self.assertIn("govoplan-core", by_name)
|
self.assertIn("govoplan-core", by_name)
|
||||||
self.assertIn("govoplan-records", by_name)
|
self.assertIn("govoplan-records", by_name)
|
||||||
self.assertEqual("0.1.19", by_name["govoplan-tasks"]["version"])
|
self.assertEqual(
|
||||||
|
expected_tasks_pin,
|
||||||
|
f"govoplan-tasks=={by_name['govoplan-tasks']['version']}",
|
||||||
|
)
|
||||||
|
|
||||||
payload = PACKAGE_SET.generate_package_set(
|
payload = PACKAGE_SET.generate_package_set(
|
||||||
core_version="0.1.18",
|
core_version=core_version,
|
||||||
requirements=ROOT / "requirements-release.txt",
|
requirements=ROOT / "requirements-release.txt",
|
||||||
workspace=ROOT.parent,
|
workspace=ROOT.parent,
|
||||||
profile="full",
|
profile="full",
|
||||||
meta_package=ROOT / "packages/govoplan-meta/pyproject.toml",
|
meta_package=meta_package,
|
||||||
)
|
)
|
||||||
self.assertEqual("full", payload["profile"])
|
self.assertEqual("full", payload["profile"])
|
||||||
self.assertEqual(len(selected), len(payload["python"]))
|
self.assertEqual(len(selected), len(payload["python"]))
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ class PackageSetDispatchTests(unittest.TestCase):
|
|||||||
def test_meta_package_resolves_to_exact_tagged_repository_targets(self) -> None:
|
def test_meta_package_resolves_to_exact_tagged_repository_targets(self) -> None:
|
||||||
targets = MODULE.package_targets()
|
targets = MODULE.package_targets()
|
||||||
|
|
||||||
self.assertEqual(66, len(targets))
|
self.assertEqual(73, len(targets))
|
||||||
self.assertEqual(66, len({target.distribution for target in targets}))
|
self.assertEqual(73, len({target.distribution for target in targets}))
|
||||||
by_name = {target.distribution: target for target in targets}
|
by_name = {target.distribution: target for target in targets}
|
||||||
self.assertEqual("v0.1.14", by_name["govoplan-core"].tag)
|
self.assertEqual("v0.1.38", by_name["govoplan-core"].tag)
|
||||||
self.assertEqual("v0.1.8", by_name["govoplan-access"].tag)
|
self.assertEqual("v0.1.22", by_name["govoplan-access"].tag)
|
||||||
|
self.assertEqual("v0.1.20", by_name["govoplan-dms"].tag)
|
||||||
|
self.assertEqual("v0.1.20", by_name["govoplan-erp"].tag)
|
||||||
|
self.assertEqual("v0.1.20", by_name["govoplan-fit-connect"].tag)
|
||||||
|
self.assertEqual("v0.1.23", by_name["govoplan-idm"].tag)
|
||||||
|
self.assertEqual("v0.1.21", by_name["govoplan-xrechnung"].tag)
|
||||||
self.assertTrue(by_name["govoplan-core"].tag_exists)
|
self.assertTrue(by_name["govoplan-core"].tag_exists)
|
||||||
self.assertTrue(by_name["govoplan-access"].has_webui)
|
self.assertTrue(by_name["govoplan-access"].has_webui)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "tracking_issue"):
|
with self.assertRaisesRegex(ValueError, "tracking_issue"):
|
||||||
inventory._load_endpoint_declarations(path)
|
inventory._load_endpoint_declarations(path)
|
||||||
|
|
||||||
|
def test_bounded_workflow_read_apis_have_explicit_headless_declarations(self) -> None:
|
||||||
|
declarations = inventory._load_endpoint_declarations(inventory.DEFAULT_ENDPOINT_DECLARATIONS)
|
||||||
|
for path in (
|
||||||
|
"/workflow/instances/summaries", "/workflow/instances/{}/summary",
|
||||||
|
"/workflow/instances/{}/steps", "/workflow/instances/{}/events",
|
||||||
|
):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
entry = declarations[("govoplan-workflow-engine", "GET", path)]
|
||||||
|
self.assertEqual("intentionally_headless", entry["category"])
|
||||||
|
self.assertIn("current-authorized", entry["rationale"])
|
||||||
|
self.assertIn("workflow.instance-history", entry["rationale"])
|
||||||
|
|
||||||
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
|
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -169,6 +181,69 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_high_risk_help_baseline_is_validated(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "help-baseline.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"maximum_missing_exact_help": 3,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
3,
|
||||||
|
inventory._load_high_risk_help_baseline(path)[
|
||||||
|
"maximum_missing_exact_help"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"maximum_missing_exact_help": -1,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "non-negative integer"):
|
||||||
|
inventory._load_high_risk_help_baseline(path)
|
||||||
|
|
||||||
|
def test_declaration_strict_mode_rejects_high_risk_help_regression(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
result = {
|
||||||
|
"translation_health": {"missing_catalog_entries": []},
|
||||||
|
"api": {
|
||||||
|
"unclassified_endpoints": [],
|
||||||
|
"stale_endpoint_declarations": [],
|
||||||
|
},
|
||||||
|
"declaration_health": {},
|
||||||
|
"help_health": {
|
||||||
|
"invalid_risk_annotations": [],
|
||||||
|
"unresolved_exact_high_risk_help": [],
|
||||||
|
"high_risk_help_without_german": [],
|
||||||
|
"missing_exact_high_risk_help": [{"id": "example.delete"}],
|
||||||
|
"baseline_maximum_missing": 0,
|
||||||
|
"baseline_regression": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"1 high-risk controls lack exact F1 help; baseline permits at most 0"
|
||||||
|
],
|
||||||
|
inventory._strict_failures(
|
||||||
|
result,
|
||||||
|
check_translations=False,
|
||||||
|
check_endpoints=False,
|
||||||
|
check_declarations=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
||||||
tree = ast.parse(
|
tree = ast.parse(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import tomllib
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.modules import ModuleManifest
|
from govoplan_core.core.modules import ModuleManifest, PermissionDefinition
|
||||||
from govoplan_core.core.provider_governance import (
|
from govoplan_core.core.provider_governance import (
|
||||||
ExternalProviderDeclaration,
|
ExternalProviderDeclaration,
|
||||||
ModuleArchitectureDeclaration,
|
ModuleArchitectureDeclaration,
|
||||||
@@ -73,7 +73,7 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
ModuleMaturityEvidence(
|
ModuleMaturityEvidence(
|
||||||
kind="documentation",
|
kind="documentation",
|
||||||
reference="docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
reference="docs/architecture/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md",
|
||||||
summary="Defines the provider declaration contract.",
|
summary="Defines the provider declaration contract.",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -88,6 +88,18 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
id="example",
|
id="example",
|
||||||
name="Example",
|
name="Example",
|
||||||
version="1.2.3",
|
version="1.2.3",
|
||||||
|
permissions=(
|
||||||
|
PermissionDefinition(
|
||||||
|
scope="example:records:read",
|
||||||
|
label="Read records",
|
||||||
|
description="Read example records.",
|
||||||
|
category="Records",
|
||||||
|
level="tenant",
|
||||||
|
module_id="example",
|
||||||
|
resource="records",
|
||||||
|
action="read",
|
||||||
|
),
|
||||||
|
),
|
||||||
architecture=architecture,
|
architecture=architecture,
|
||||||
external_providers=(provider,),
|
external_providers=(provider,),
|
||||||
),
|
),
|
||||||
@@ -112,6 +124,19 @@ class ReleaseCatalogEntrySynthesisTests(unittest.TestCase):
|
|||||||
"default_authority_mode"
|
"default_authority_mode"
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"scope": "example:records:read",
|
||||||
|
"label": "Read records",
|
||||||
|
"description": "Read example records.",
|
||||||
|
"category": "Records",
|
||||||
|
"level": "tenant",
|
||||||
|
"resource": "records",
|
||||||
|
"action": "read",
|
||||||
|
"deprecated": False,
|
||||||
|
},
|
||||||
|
entry["permissions"][0],
|
||||||
|
)
|
||||||
|
|
||||||
def test_selective_update_synthesizes_initial_entries_from_package_manifests(self) -> None:
|
def test_selective_update_synthesizes_initial_entries_from_package_manifests(self) -> None:
|
||||||
payload: dict[str, object] = {
|
payload: dict[str, object] = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
@@ -17,6 +18,80 @@ from govoplan_release import git_state # noqa: E402
|
|||||||
|
|
||||||
|
|
||||||
class ReleaseGitStateTests(unittest.TestCase):
|
class ReleaseGitStateTests(unittest.TestCase):
|
||||||
|
def test_unset_ssh_address_family_preserves_original_command_and_operator_config(self) -> None:
|
||||||
|
environment = git_state.sanitized_git_environment({})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
|
||||||
|
],
|
||||||
|
shlex.split(environment["GIT_SSH_COMMAND"]),
|
||||||
|
)
|
||||||
|
self.assertNotIn("GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY", environment)
|
||||||
|
self.assertEqual(environment, git_state.sanitized_git_environment(environment))
|
||||||
|
|
||||||
|
def test_ssh_address_family_accepts_only_fixed_choices_and_survives_resanitizing(self) -> None:
|
||||||
|
for family in ("any", "inet", "inet6"):
|
||||||
|
with self.subTest(family=family):
|
||||||
|
environment = git_state.sanitized_git_environment({
|
||||||
|
"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": family,
|
||||||
|
"GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no",
|
||||||
|
"GIT_SSH": "/attacker/ssh",
|
||||||
|
"PATH": "/attacker/bin",
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
"/usr/bin/ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8",
|
||||||
|
"-o", f"AddressFamily={family}",
|
||||||
|
],
|
||||||
|
shlex.split(environment["GIT_SSH_COMMAND"]),
|
||||||
|
)
|
||||||
|
self.assertNotIn("GIT_SSH", environment)
|
||||||
|
self.assertEqual("/usr/bin:/bin", environment["PATH"])
|
||||||
|
self.assertEqual(environment, git_state.sanitized_git_environment(environment))
|
||||||
|
|
||||||
|
def test_invalid_ssh_address_family_is_rejected_before_git_runs(self) -> None:
|
||||||
|
for invalid in (
|
||||||
|
"", "INET", "ipv4", " inet", "inet ", "inet\n",
|
||||||
|
"inet; touch /not-executed", "inet -o StrictHostKeyChecking=no",
|
||||||
|
"$(not-executed)",
|
||||||
|
):
|
||||||
|
with (
|
||||||
|
self.subTest(value=invalid),
|
||||||
|
patch.dict("os.environ", {"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": invalid}),
|
||||||
|
patch.object(git_state.subprocess, "run") as run,
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError, "GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY must be any, inet, or inet6",
|
||||||
|
):
|
||||||
|
git_state.git(Path("/workspace/govoplan-core"), "status", "--porcelain")
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_source_provenance_readback_keeps_family_but_discards_ssh_command_override(self) -> None:
|
||||||
|
from govoplan_release.source_provenance import inspect_remote_tag
|
||||||
|
|
||||||
|
completed = subprocess.CompletedProcess(
|
||||||
|
[], 0, f"{'a' * 40}\trefs/tags/v1.2.3\n{'b' * 40}\trefs/tags/v1.2.3^{{}}\n", "",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {
|
||||||
|
"GOVOPLAN_RELEASE_SSH_ADDRESS_FAMILY": "inet",
|
||||||
|
"GIT_SSH_COMMAND": "/attacker/ssh -o StrictHostKeyChecking=no",
|
||||||
|
}),
|
||||||
|
patch("govoplan_release.repository_tag.subprocess.run", return_value=completed) as run,
|
||||||
|
):
|
||||||
|
result = inspect_remote_tag(
|
||||||
|
path=Path("/workspace/govoplan-core"), remote="origin",
|
||||||
|
remote_url="git@git.add-ideas.de:GovOPlaN/govoplan-core.git", tag="v1.2.3",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("b" * 40, result.commit)
|
||||||
|
self.assertEqual(
|
||||||
|
"/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8 -o AddressFamily=inet",
|
||||||
|
run.call_args.kwargs["env"]["GIT_SSH_COMMAND"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_manifest_version_does_not_confuse_interface_versions(self) -> None:
|
def test_manifest_version_does_not_confuse_interface_versions(self) -> None:
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||||
|
|
||||||
|
from govoplan_release import meta_preparation # noqa: E402
|
||||||
|
from govoplan_release.git_state import collect_repository_snapshot # noqa: E402
|
||||||
|
from govoplan_release.meta_preparation import ( # noqa: E402
|
||||||
|
MetaPreparationError,
|
||||||
|
prepare_developer_meta_package,
|
||||||
|
)
|
||||||
|
from govoplan_release.model import RepositorySpec # noqa: E402
|
||||||
|
from govoplan_release.selective_planner import build_selective_release_plan # noqa: E402
|
||||||
|
from govoplan_release.version_metadata import ( # noqa: E402
|
||||||
|
VersionMetadataError,
|
||||||
|
apply_version_metadata_mutations,
|
||||||
|
version_metadata_mutations,
|
||||||
|
)
|
||||||
|
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||||
|
from test_release_plan_guidance import dashboard # noqa: E402
|
||||||
|
from test_release_repository_tag import create_release_repo, git, git_text # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class MetaPreparationTests(unittest.TestCase):
|
||||||
|
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
meta_fixture.MetaSourceTagTests.setUp(self)
|
||||||
|
self.operator = self.root / "operator"
|
||||||
|
self.generator = (
|
||||||
|
self.operator / "tools/release/generate-developer-meta-package.py"
|
||||||
|
)
|
||||||
|
self.generator.parent.mkdir(parents=True)
|
||||||
|
shutil.copyfile(
|
||||||
|
ROOT / "tools/release/generate-developer-meta-package.py", self.generator
|
||||||
|
)
|
||||||
|
self.enterContext(patch.object(meta_preparation, "META_ROOT", self.operator))
|
||||||
|
|
||||||
|
def prepare_core(self):
|
||||||
|
apply_version_metadata_mutations(self.core, target_version="0.1.11")
|
||||||
|
git(self.core, "add", ".")
|
||||||
|
git(self.core, "commit", "-m", "Prepared synthetic Core target")
|
||||||
|
|
||||||
|
def preview(self, **kwargs):
|
||||||
|
return prepare_developer_meta_package(
|
||||||
|
repo_path=self.meta, target_version="0.1.11", **kwargs
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(self, preview):
|
||||||
|
return self.preview(
|
||||||
|
apply=True, expected_receipt=preview["receipt"], confirm_out_of_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_canonical_preview_apply_and_shared_mutation_discovery(self):
|
||||||
|
extra, remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-workflow-engine",
|
||||||
|
version="0.2.3",
|
||||||
|
)
|
||||||
|
self.specs.append(
|
||||||
|
{
|
||||||
|
"name": extra.name,
|
||||||
|
"path": extra.name,
|
||||||
|
"category": "module",
|
||||||
|
"subtype": "",
|
||||||
|
"remote": str(remote),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||||
|
self.prepare_core()
|
||||||
|
before = self.package.read_bytes()
|
||||||
|
preview = self.preview()
|
||||||
|
self.assertEqual("planned", preview["status"])
|
||||||
|
self.assertEqual(before, self.package.read_bytes())
|
||||||
|
mutations = version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||||
|
self.assertEqual([meta_preparation.PACKAGE], [item.path for item in mutations])
|
||||||
|
self.assertIn(b"govoplan-workflow-engine==0.2.3", mutations[0].after)
|
||||||
|
self.assertIn(b"govoplan-core==0.1.11", mutations[0].after)
|
||||||
|
result = self.apply(preview)
|
||||||
|
self.assertEqual("prepared", result["status"])
|
||||||
|
self.assertEqual(mutations[0].after, self.package.read_bytes())
|
||||||
|
self.assertEqual(
|
||||||
|
self.render(workspace=self.workspace, requirements=self.requirements),
|
||||||
|
self.package.read_text(),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
f"M {meta_preparation.PACKAGE}",
|
||||||
|
git_text(self.meta, "status", "--porcelain"),
|
||||||
|
)
|
||||||
|
self.assertFalse(git_text(self.meta, "tag", "--list"))
|
||||||
|
|
||||||
|
def test_core_target_must_already_be_prepared(self):
|
||||||
|
before = self.package.read_bytes()
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "Prepare and commit Core"):
|
||||||
|
self.preview()
|
||||||
|
self.assertEqual(before, self.package.read_bytes())
|
||||||
|
|
||||||
|
def test_changed_requirements_receipt_blocks_before_any_output(self):
|
||||||
|
self.prepare_core()
|
||||||
|
preview = self.preview()
|
||||||
|
before = self.package.read_bytes()
|
||||||
|
self.requirements.write_text(
|
||||||
|
self.requirements.read_text() + "# reviewed different inputs\n"
|
||||||
|
)
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Changed synthetic requirements")
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||||
|
self.apply(preview)
|
||||||
|
self.assertEqual(before, self.package.read_bytes())
|
||||||
|
|
||||||
|
def test_source_change_immediately_before_effect_is_rechecked(self):
|
||||||
|
self.prepare_core()
|
||||||
|
preview = self.preview()
|
||||||
|
before = self.package.read_bytes()
|
||||||
|
original = meta_preparation.preview_meta_mutation
|
||||||
|
|
||||||
|
def changed(**kwargs):
|
||||||
|
result = original(**kwargs)
|
||||||
|
self.requirements.write_text(
|
||||||
|
self.requirements.read_text() + "# concurrent change\n"
|
||||||
|
)
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Concurrent synthetic change")
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
meta_preparation, "preview_meta_mutation", side_effect=changed
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "changed before"):
|
||||||
|
self.apply(preview)
|
||||||
|
self.assertEqual(before, self.package.read_bytes())
|
||||||
|
|
||||||
|
def test_core_full_package_and_operator_generator_are_receipt_bound(self):
|
||||||
|
self.prepare_core()
|
||||||
|
for path, repository in (
|
||||||
|
(self.core / "pyproject.toml", self.core),
|
||||||
|
(self.access / "pyproject.toml", self.access),
|
||||||
|
(self.generator, None),
|
||||||
|
):
|
||||||
|
with self.subTest(input=path.name, repo=str(repository)):
|
||||||
|
preview = self.preview()
|
||||||
|
before = self.package.read_bytes()
|
||||||
|
path.write_text(path.read_text() + "\n# changed frozen input\n")
|
||||||
|
if repository is not None:
|
||||||
|
git(repository, "add", ".")
|
||||||
|
git(
|
||||||
|
repository,
|
||||||
|
"commit",
|
||||||
|
"-m",
|
||||||
|
"Changed synthetic composition input",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "changed since"):
|
||||||
|
self.apply(preview)
|
||||||
|
self.assertEqual(before, self.package.read_bytes())
|
||||||
|
|
||||||
|
def test_post_write_source_change_is_reported_without_retry_or_rollback(self):
|
||||||
|
from govoplan_release import version_metadata
|
||||||
|
|
||||||
|
self.prepare_core()
|
||||||
|
preview = self.preview()
|
||||||
|
original = version_metadata._atomic_write
|
||||||
|
|
||||||
|
def changed(path, payload):
|
||||||
|
original(path, payload)
|
||||||
|
self.requirements.write_text(
|
||||||
|
self.requirements.read_text() + "# concurrent after write\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
version_metadata, "_atomic_write", side_effect=changed
|
||||||
|
) as writer:
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
meta_preparation.MetaPreparationAmbiguous, "write/post-check failed"
|
||||||
|
):
|
||||||
|
self.apply(preview)
|
||||||
|
self.assertEqual(1, writer.call_count)
|
||||||
|
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||||
|
self.assertIn("# concurrent after write", self.requirements.read_text())
|
||||||
|
|
||||||
|
def test_write_failure_after_replace_requires_reconciliation(self):
|
||||||
|
from govoplan_release import version_metadata
|
||||||
|
|
||||||
|
self.prepare_core()
|
||||||
|
preview = self.preview()
|
||||||
|
original = version_metadata._atomic_write
|
||||||
|
|
||||||
|
def partial(path, payload):
|
||||||
|
original(path, payload)
|
||||||
|
raise OSError("Synthetic directory fsync failure after replacement")
|
||||||
|
|
||||||
|
with patch.object(version_metadata, "_atomic_write", side_effect=partial) as writer:
|
||||||
|
with self.assertRaisesRegex(meta_preparation.MetaPreparationAmbiguous, "may have been written"):
|
||||||
|
self.apply(preview)
|
||||||
|
self.assertEqual(1, writer.call_count)
|
||||||
|
self.assertIn('version = "0.1.11"', self.package.read_text())
|
||||||
|
|
||||||
|
def test_cli_requires_reviewed_receipt_and_explicit_out_of_run_confirmation(self):
|
||||||
|
self.prepare_core()
|
||||||
|
main = runpy.run_path(
|
||||||
|
str(ROOT / "tools/release/prepare-developer-meta-package.py")
|
||||||
|
)["main"]
|
||||||
|
arguments = [
|
||||||
|
"prepare-developer-meta-package.py",
|
||||||
|
"--workspace",
|
||||||
|
str(self.workspace),
|
||||||
|
"--target-version",
|
||||||
|
"0.1.11",
|
||||||
|
]
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(sys, "argv", arguments), redirect_stdout(output):
|
||||||
|
self.assertEqual(0, main())
|
||||||
|
preview = self.root / "meta-preview.json"
|
||||||
|
preview.write_text(output.getvalue())
|
||||||
|
with (
|
||||||
|
patch.object(sys, "argv", [*arguments, "--apply"]),
|
||||||
|
redirect_stdout(io.StringIO()),
|
||||||
|
):
|
||||||
|
self.assertEqual(1, main())
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
*arguments,
|
||||||
|
"--apply",
|
||||||
|
"--receipt",
|
||||||
|
str(preview),
|
||||||
|
"--confirm-out-of-run",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
redirect_stdout(io.StringIO()),
|
||||||
|
):
|
||||||
|
self.assertEqual(0, main())
|
||||||
|
|
||||||
|
def test_unknown_full_input_and_unsafe_operator_tooling_fail_closed(self):
|
||||||
|
self.prepare_core()
|
||||||
|
unknown = self.workspace / "govoplan-unknown/pyproject.toml"
|
||||||
|
unknown.parent.mkdir()
|
||||||
|
unknown.write_text('[project]\nname="govoplan-unknown"\nversion="1.0.0"\n')
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "unregistered"):
|
||||||
|
self.preview()
|
||||||
|
unknown.unlink()
|
||||||
|
self.generator.chmod(0o666)
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "owned, bounded regular"):
|
||||||
|
self.preview()
|
||||||
|
|
||||||
|
def test_wrong_nested_identity_and_existing_immutable_tag_fail_closed(self):
|
||||||
|
self.prepare_core()
|
||||||
|
original = self.package.read_text()
|
||||||
|
self.package.write_text(
|
||||||
|
original.replace('name = "govoplan"', 'name = "not-govoplan"')
|
||||||
|
)
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Wrong synthetic package identity")
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "identity"):
|
||||||
|
self.preview()
|
||||||
|
self.package.write_text(original)
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Restore synthetic package identity")
|
||||||
|
git(self.meta, "tag", "-a", "v0.1.11", "-m", "Immutable target")
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "target Meta tag"):
|
||||||
|
self.preview()
|
||||||
|
|
||||||
|
def test_no_generic_durable_self_mutation_or_running_tooling_target(self):
|
||||||
|
self.prepare_core()
|
||||||
|
preview = self.preview()
|
||||||
|
with self.assertRaisesRegex(VersionMetadataError, "outside durable runs"):
|
||||||
|
apply_version_metadata_mutations(self.meta, target_version="0.1.11")
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "confirm"):
|
||||||
|
self.preview(apply=True, expected_receipt=preview["receipt"])
|
||||||
|
with patch.object(meta_preparation, "META_ROOT", self.meta):
|
||||||
|
with self.assertRaisesRegex(MetaPreparationError, "running operator"):
|
||||||
|
self.preview()
|
||||||
|
|
||||||
|
def test_next_version_plan_is_actionable_core_first_without_meta_executor(self):
|
||||||
|
snapshots = tuple(
|
||||||
|
collect_repository_snapshot(
|
||||||
|
RepositorySpec(**spec),
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
target_tag="v0.1.11",
|
||||||
|
)
|
||||||
|
for spec in self.specs[:2]
|
||||||
|
)
|
||||||
|
source = replace(
|
||||||
|
dashboard(workspace=self.workspace, version=self.version),
|
||||||
|
repositories=snapshots,
|
||||||
|
)
|
||||||
|
plan = build_selective_release_plan(
|
||||||
|
source,
|
||||||
|
selected_repos=("govoplan", "govoplan-core"),
|
||||||
|
target_version="0.1.11",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
["govoplan-core", "govoplan"], [unit.repo for unit in plan.units]
|
||||||
|
)
|
||||||
|
findings = [
|
||||||
|
finding for finding in plan.gate_findings if finding.repo == "govoplan"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
["developer_meta_core_preparation_required"],
|
||||||
|
[finding.code for finding in findings],
|
||||||
|
)
|
||||||
|
self.assertIn("prepare-developer-meta-package.py", findings[0].remediation)
|
||||||
|
meta_steps = [step for step in plan.dry_run_steps if step.repo == "govoplan"]
|
||||||
|
self.assertEqual(
|
||||||
|
["govoplan:prepare-support", "govoplan:publish-support"],
|
||||||
|
[step.id for step in meta_steps],
|
||||||
|
)
|
||||||
|
self.assertTrue(all(step.status == "needs-executor" for step in meta_steps))
|
||||||
|
self.prepare_core()
|
||||||
|
prepared = build_selective_release_plan(
|
||||||
|
source, selected_repos=("govoplan",), target_version="0.1.11"
|
||||||
|
)
|
||||||
|
self.assertEqual("developer_meta_out_of_run", prepared.gate_findings[0].code)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||||
|
|
||||||
|
from govoplan_release import source_tag_batch as meta_source_tag, workspace # noqa: E402
|
||||||
|
from govoplan_release.git_state import collect_versions # noqa: E402
|
||||||
|
from govoplan_release.model import RepositorySnapshot, RepositorySpec, VersionSnapshot # noqa: E402
|
||||||
|
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||||
|
from govoplan_release.selective_planner import build_unit # noqa: E402
|
||||||
|
from govoplan_release.version_alignment import repository_version_issues # noqa: E402
|
||||||
|
from test_release_repository_tag import ( # noqa: E402
|
||||||
|
add_scoped_workflow_manifest,
|
||||||
|
create_release_repo,
|
||||||
|
git,
|
||||||
|
git_text,
|
||||||
|
ref_exists,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MetaSourceTagTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temporary = self.enterContext(
|
||||||
|
tempfile.TemporaryDirectory(prefix="meta-release-tests-")
|
||||||
|
)
|
||||||
|
self.root = Path(self.temporary)
|
||||||
|
self.workspace = self.root / "workspace"
|
||||||
|
self.workspace.mkdir()
|
||||||
|
self.version = "0.1.10"
|
||||||
|
self.core, self.core_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-core",
|
||||||
|
version=self.version,
|
||||||
|
)
|
||||||
|
self.access, self.access_remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-access",
|
||||||
|
version=self.version,
|
||||||
|
)
|
||||||
|
add_scoped_workflow_manifest(self.access)
|
||||||
|
self.meta = self.workspace / "govoplan"
|
||||||
|
self.meta_remote = self.root / "govoplan.git"
|
||||||
|
git(self.root, "init", "--bare", str(self.meta_remote))
|
||||||
|
git(self.workspace, "init", "-b", "main", str(self.meta))
|
||||||
|
git(self.meta, "config", "user.name", "Meta Release Fixture")
|
||||||
|
git(self.meta, "config", "user.email", "release@example.invalid")
|
||||||
|
self.package = self.meta / "packages/govoplan-meta/pyproject.toml"
|
||||||
|
self.package.parent.mkdir(parents=True)
|
||||||
|
self.requirements = self.meta / "requirements-release.txt"
|
||||||
|
self.requirements.write_text(
|
||||||
|
"../govoplan-core\ngovoplan-access @ git+ssh://git@example.invalid/GovOPlaN/govoplan-access.git@v0.1.10\n"
|
||||||
|
)
|
||||||
|
self.render = runpy.run_path(
|
||||||
|
str(ROOT / "tools/release/generate-developer-meta-package.py")
|
||||||
|
)["render"]
|
||||||
|
self.synchronize()
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Nested developer package")
|
||||||
|
git(self.meta, "remote", "add", "origin", str(self.meta_remote))
|
||||||
|
git(self.meta, "push", "-u", "origin", "main")
|
||||||
|
self.specs = [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"category": "system" if subtype else "module",
|
||||||
|
"subtype": subtype,
|
||||||
|
"path": name,
|
||||||
|
"remote": str(remote),
|
||||||
|
}
|
||||||
|
for name, subtype, remote in (
|
||||||
|
("govoplan", "meta", self.meta_remote),
|
||||||
|
("govoplan-core", "kernel", self.core_remote),
|
||||||
|
("govoplan-access", "", self.access_remote),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.registry = self.root / "repositories.json"
|
||||||
|
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||||
|
self.enterContext(patch.object(workspace, "REPOSITORIES_FILE", self.registry))
|
||||||
|
|
||||||
|
def synchronize(self):
|
||||||
|
self.package.write_text(
|
||||||
|
self.render(workspace=self.workspace, requirements=self.requirements)
|
||||||
|
)
|
||||||
|
|
||||||
|
def commit_meta(self):
|
||||||
|
git(self.meta, "add", ".")
|
||||||
|
git(self.meta, "commit", "-m", "Changed synthetic metadata")
|
||||||
|
|
||||||
|
def tag(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
repos=("govoplan", "govoplan-core"),
|
||||||
|
apply=False,
|
||||||
|
push=False,
|
||||||
|
**overrides,
|
||||||
|
):
|
||||||
|
return tag_repositories(
|
||||||
|
repos=repos,
|
||||||
|
repo_versions={repo: self.version for repo in repos},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=apply,
|
||||||
|
push=push,
|
||||||
|
**overrides,
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_no_tags(self):
|
||||||
|
for repo in (
|
||||||
|
self.meta,
|
||||||
|
self.meta_remote,
|
||||||
|
self.core,
|
||||||
|
self.core_remote,
|
||||||
|
self.access,
|
||||||
|
self.access_remote,
|
||||||
|
):
|
||||||
|
self.assertFalse(ref_exists(repo, "refs/tags/v0.1.10"), str(repo))
|
||||||
|
|
||||||
|
def test_explicit_nested_version_collection_and_alignment_without_root_package(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
versions = collect_versions(self.meta)
|
||||||
|
self.assertIsNone(versions.pyproject)
|
||||||
|
self.assertEqual(self.version, versions.developer_meta)
|
||||||
|
self.assertEqual(self.version, versions.primary)
|
||||||
|
self.assertFalse((self.meta / "pyproject.toml").exists())
|
||||||
|
self.assertEqual(
|
||||||
|
(), repository_version_issues(self.meta, expected_version=self.version)
|
||||||
|
)
|
||||||
|
mismatch = repository_version_issues(self.meta, expected_version="0.1.11")
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
issue.source == "packages/govoplan-meta/pyproject.toml"
|
||||||
|
for issue in mismatch
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_planner_and_console_display_the_explicit_nested_version(self):
|
||||||
|
snapshot = RepositorySnapshot(
|
||||||
|
spec=RepositorySpec(**self.specs[0]),
|
||||||
|
absolute_path=str(self.meta),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
branch="main",
|
||||||
|
versions=VersionSnapshot(developer_meta=self.version),
|
||||||
|
)
|
||||||
|
unit = build_unit(snapshot, target_version=None, contracts=None)
|
||||||
|
self.assertEqual(self.version, unit.current_version)
|
||||||
|
self.assertEqual(self.version, unit.target_version)
|
||||||
|
html = (ROOT / "tools/release/webui/index.html").read_text()
|
||||||
|
self.assertIn(
|
||||||
|
"if (versions.developer_meta) return versions.developer_meta;", html
|
||||||
|
)
|
||||||
|
drift = RepositorySnapshot(
|
||||||
|
spec=snapshot.spec,
|
||||||
|
absolute_path=str(self.meta),
|
||||||
|
exists=True,
|
||||||
|
is_git=True,
|
||||||
|
has_head=True,
|
||||||
|
branch="main",
|
||||||
|
versions=VersionSnapshot(pyproject="0.1.9", developer_meta=self.version),
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
"version metadata is not aligned" in item
|
||||||
|
for item in build_unit(
|
||||||
|
drift, target_version=self.version, contracts=None
|
||||||
|
).blockers
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unknown_nested_package_and_missing_or_wrong_meta_identity_fail_closed(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
unknown = self.workspace / "unknown"
|
||||||
|
nested = unknown / "packages/govoplan-meta/pyproject.toml"
|
||||||
|
nested.parent.mkdir(parents=True)
|
||||||
|
nested.write_text(self.package.read_text())
|
||||||
|
self.assertIsNone(collect_versions(unknown).primary)
|
||||||
|
self.assertIn(
|
||||||
|
"no version metadata",
|
||||||
|
repository_version_issues(unknown, expected_version=self.version)[
|
||||||
|
0
|
||||||
|
].message,
|
||||||
|
)
|
||||||
|
for value in ("", '[project]\nname="not-govoplan"\nversion="0.1.10"\n'):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
self.package.write_text(value)
|
||||||
|
self.assertTrue(
|
||||||
|
repository_version_issues(self.meta, expected_version=self.version)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_preview_local_tag_and_publish_share_complete_nested_contract(self):
|
||||||
|
preview = self.tag(push=True)
|
||||||
|
self.assertEqual("planned", preview["status"], preview)
|
||||||
|
self.assertEqual(
|
||||||
|
["govoplan-core", "govoplan"],
|
||||||
|
[row["repo"] for row in preview["repositories"]],
|
||||||
|
)
|
||||||
|
self.assertEqual("registered-meta-batch-v1", preview["source_contract"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
local = self.tag(apply=True)
|
||||||
|
self.assertEqual("tagged", local["status"], local)
|
||||||
|
for repo in (self.core, self.meta):
|
||||||
|
self.assertEqual(
|
||||||
|
"tag", git_text(repo, "cat-file", "-t", "refs/tags/v0.1.10")
|
||||||
|
)
|
||||||
|
self.assertFalse(ref_exists(self.meta_remote, "refs/tags/v0.1.10"))
|
||||||
|
published = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("published", published["status"], published)
|
||||||
|
for repo, remote in (
|
||||||
|
(self.core, self.core_remote),
|
||||||
|
(self.meta, self.meta_remote),
|
||||||
|
):
|
||||||
|
self.assertEqual(
|
||||||
|
git_text(repo, "rev-parse", "HEAD"),
|
||||||
|
git_text(remote, "rev-parse", "refs/heads/main"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
git_text(repo, "rev-parse", "refs/tags/v0.1.10"),
|
||||||
|
git_text(remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||||
|
)
|
||||||
|
again = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("published", again["status"], again)
|
||||||
|
|
||||||
|
def test_stale_composition_blocks_whole_batch_before_local_tag_or_push(self):
|
||||||
|
self.package.write_text(
|
||||||
|
self.package.read_text().replace(
|
||||||
|
"govoplan-access==0.1.10", "govoplan-access==0.1.9"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.commit_meta()
|
||||||
|
for apply, push in ((False, False), (True, False), (True, True)):
|
||||||
|
result = self.tag(
|
||||||
|
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||||
|
apply=apply,
|
||||||
|
push=push,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_core_outside_batch_requires_matching_existing_and_published_tag(self):
|
||||||
|
self.assertEqual("blocked", self.tag(repos=("govoplan",))["status"])
|
||||||
|
git(self.core, "tag", "-a", "v0.1.10", "-m", "Core release")
|
||||||
|
self.assertEqual("planned", self.tag(repos=("govoplan",))["status"])
|
||||||
|
self.assertEqual("blocked", self.tag(repos=("govoplan",), push=True)["status"])
|
||||||
|
git(self.core, "push", "origin", "refs/tags/v0.1.10")
|
||||||
|
self.assertEqual("planned", self.tag(repos=("govoplan",), push=True)["status"])
|
||||||
|
|
||||||
|
def test_changed_core_version_and_explicit_selected_version_mismatch_block(self):
|
||||||
|
mismatch = tag_repositories(
|
||||||
|
repos=("govoplan", "govoplan-core"),
|
||||||
|
repo_versions={"govoplan": self.version, "govoplan-core": "0.1.11"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", mismatch["status"], mismatch)
|
||||||
|
(self.core / "pyproject.toml").write_text(
|
||||||
|
'[project]\nname="govoplan-core"\nversion="0.1.11"\n'
|
||||||
|
)
|
||||||
|
git(self.core, "add", ".")
|
||||||
|
git(self.core, "commit", "-m", "Core new version")
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_unsafe_origin_on_any_selected_repo_blocks_every_effect(self):
|
||||||
|
for repo in (self.meta, self.core, self.access):
|
||||||
|
with self.subTest(repo=repo.name):
|
||||||
|
git(
|
||||||
|
repo,
|
||||||
|
"config",
|
||||||
|
"remote.origin.pushurl",
|
||||||
|
str(self.root / "unregistered.git"),
|
||||||
|
)
|
||||||
|
result = self.tag(
|
||||||
|
repos=("govoplan-core", "govoplan-access", "govoplan"),
|
||||||
|
apply=True,
|
||||||
|
push=True,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("registered origin", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
git(repo, "config", "--unset", "remote.origin.pushurl")
|
||||||
|
|
||||||
|
def test_world_writable_nonsticky_parent_blocks_without_changing_permissions(self):
|
||||||
|
original = self.root.stat().st_mode & 0o7777
|
||||||
|
self.root.chmod(0o777)
|
||||||
|
try:
|
||||||
|
for apply in (False, True):
|
||||||
|
result = self.tag(apply=apply, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn(
|
||||||
|
"group/world writable", result["repositories"][0]["detail"]
|
||||||
|
)
|
||||||
|
self.assertEqual(0o777, self.root.stat().st_mode & 0o7777)
|
||||||
|
self.assert_no_tags()
|
||||||
|
finally:
|
||||||
|
self.root.chmod(original)
|
||||||
|
|
||||||
|
def test_wrong_metadata_owner_blocks_before_remote_lookup(self):
|
||||||
|
config = self.meta / ".git/config"
|
||||||
|
original = Path.lstat
|
||||||
|
|
||||||
|
def wrong_owner(path, *args, **kwargs):
|
||||||
|
observed = original(path, *args, **kwargs)
|
||||||
|
if path == config:
|
||||||
|
fields = list(observed)
|
||||||
|
fields[4] = os.geteuid() + 1
|
||||||
|
return os.stat_result(fields)
|
||||||
|
return observed
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(Path, "lstat", new=wrong_owner),
|
||||||
|
patch.object(
|
||||||
|
meta_source_tag,
|
||||||
|
"registered_source_origin_issues",
|
||||||
|
side_effect=AssertionError("must validate ownership before Git"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("current operator", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_hidden_index_flags_cannot_disguise_modified_release_metadata(self):
|
||||||
|
for flag, undo in (
|
||||||
|
("--assume-unchanged", "--no-assume-unchanged"),
|
||||||
|
("--skip-worktree", "--no-skip-worktree"),
|
||||||
|
):
|
||||||
|
for repo, relative in (
|
||||||
|
(self.meta, "packages/govoplan-meta/pyproject.toml"),
|
||||||
|
(self.core, "pyproject.toml"),
|
||||||
|
):
|
||||||
|
with self.subTest(flag=flag, repo=repo.name):
|
||||||
|
target = repo / relative
|
||||||
|
original = target.read_text()
|
||||||
|
git(repo, "update-index", flag, relative)
|
||||||
|
target.write_text(
|
||||||
|
original
|
||||||
|
+ "\n# Hidden working-tree input differs from frozen HEAD\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self.assertEqual("", git_text(repo, "status", "--porcelain"))
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn(
|
||||||
|
"index entries", result["repositories"][0]["detail"]
|
||||||
|
)
|
||||||
|
self.assert_no_tags()
|
||||||
|
finally:
|
||||||
|
target.write_text(original)
|
||||||
|
git(repo, "update-index", undo, relative)
|
||||||
|
|
||||||
|
def test_read_only_git_target_is_not_repaired_or_tagged(self):
|
||||||
|
metadata = self.meta / ".git"
|
||||||
|
original = metadata.stat().st_mode & 0o7777
|
||||||
|
metadata.chmod(0o500)
|
||||||
|
try:
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertEqual(0o500, metadata.stat().st_mode & 0o7777)
|
||||||
|
self.assert_no_tags()
|
||||||
|
finally:
|
||||||
|
metadata.chmod(original)
|
||||||
|
|
||||||
|
def test_git_object_alternates_are_rejected_before_remote_lookup(self):
|
||||||
|
(self.meta / ".git/objects/info/alternates").write_text(
|
||||||
|
str(self.root / "outside-objects") + "\n"
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
meta_source_tag,
|
||||||
|
"registered_source_origin_issues",
|
||||||
|
side_effect=AssertionError("must reject alternates before Git"),
|
||||||
|
):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("alternates", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_owned_worktree_metadata_inside_private_workspace_is_supported(self):
|
||||||
|
main_checkout = self.workspace / "meta-main-storage"
|
||||||
|
self.meta.rename(main_checkout)
|
||||||
|
git(main_checkout, "worktree", "add", "--force", str(self.meta), "main")
|
||||||
|
self.assertTrue((self.meta / ".git").is_file())
|
||||||
|
result = self.tag(apply=True)
|
||||||
|
self.assertEqual("tagged", result["status"], result)
|
||||||
|
filesystem = result["source_receipts"]["govoplan"]["filesystem"]
|
||||||
|
self.assertEqual(
|
||||||
|
str(main_checkout / ".git"), filesystem["git_common_directory"][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_git_directory_replacement_with_same_head_changes_frozen_receipt(self):
|
||||||
|
preview = meta_source_tag._preview_repositories
|
||||||
|
|
||||||
|
def swapped_git_directory(**kwargs):
|
||||||
|
result = preview(**kwargs)
|
||||||
|
original = self.meta / ".git"
|
||||||
|
backup = self.root / "original-meta-git"
|
||||||
|
original.rename(backup)
|
||||||
|
shutil.copytree(backup, original)
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
meta_source_tag,
|
||||||
|
"_preview_repositories",
|
||||||
|
side_effect=swapped_git_directory,
|
||||||
|
):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_non_main_and_divergent_live_main_fail_even_with_stale_tracking(self):
|
||||||
|
git(self.meta, "switch", "-c", "feature")
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
git(self.meta, "switch", "main")
|
||||||
|
clone = self.root / "other-writer"
|
||||||
|
git(self.root, "clone", "--branch", "main", str(self.meta_remote), str(clone))
|
||||||
|
git(clone, "config", "user.name", "Other synthetic writer")
|
||||||
|
git(clone, "config", "user.email", "other@example.invalid")
|
||||||
|
(clone / "other.txt").write_text("remote divergence\n")
|
||||||
|
git(clone, "add", ".")
|
||||||
|
git(clone, "commit", "-m", "Remote main advanced")
|
||||||
|
git(clone, "push", "origin", "main")
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_symlink_checkout_is_not_a_registered_source(self):
|
||||||
|
original = self.workspace / "moved-meta"
|
||||||
|
self.meta.rename(original)
|
||||||
|
self.meta.symlink_to(original, target_is_directory=True)
|
||||||
|
result = self.tag(apply=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("symlink", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_lightweight_and_conflicting_annotated_tags_block(self):
|
||||||
|
git(self.meta, "tag", "v0.1.10")
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
git(self.meta, "tag", "-d", "v0.1.10")
|
||||||
|
git(self.meta, "tag", "-a", "v0.1.10", "-m", "First annotation")
|
||||||
|
git(self.meta, "push", "origin", "refs/tags/v0.1.10")
|
||||||
|
git(self.meta, "tag", "-d", "v0.1.10")
|
||||||
|
git(self.meta, "tag", "-a", "v0.1.10", "-m", "Different annotation")
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||||
|
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_meta_source_receipt_changed_after_preflight_blocks_before_first_effect(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
preview = meta_source_tag._preview_repositories
|
||||||
|
|
||||||
|
def changed_after_preflight(**kwargs):
|
||||||
|
result = preview(**kwargs)
|
||||||
|
(self.meta / "new-review.txt").write_text("changed after preflight\n")
|
||||||
|
self.commit_meta()
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
meta_source_tag,
|
||||||
|
"_preview_repositories",
|
||||||
|
side_effect=changed_after_preflight,
|
||||||
|
):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_fabricated_push_success_without_remote_receipt_fails_and_stops_batch(self):
|
||||||
|
original = meta_source_tag.run
|
||||||
|
|
||||||
|
def run(command, **kwargs):
|
||||||
|
if command[:3] == ("git", "push", "--atomic"):
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
return original(command, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(meta_source_tag, "run", side_effect=run):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("partial", result["status"], result)
|
||||||
|
self.assertEqual("failed", result["repositories"][0]["status"])
|
||||||
|
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||||
|
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_remote_tag_without_expected_main_receipt_is_not_success(self):
|
||||||
|
(self.core / "reviewed-change.txt").write_text("release source changes\n")
|
||||||
|
git(self.core, "add", ".")
|
||||||
|
git(self.core, "commit", "-m", "Advance reviewed Core source")
|
||||||
|
original = meta_source_tag.run
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def tag_only(command, **kwargs):
|
||||||
|
if command[:3] == ("git", "push", "--atomic"):
|
||||||
|
pushes.append(command)
|
||||||
|
# Simulate a defective transport that claims atomic success,
|
||||||
|
# while publishing only the exact expected annotation object.
|
||||||
|
return original(("git", "push", "origin", command[-1]), **kwargs)
|
||||||
|
return original(command, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(meta_source_tag, "run", side_effect=tag_only):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("partial", result["status"], result)
|
||||||
|
self.assertEqual(1, len(pushes))
|
||||||
|
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||||
|
self.assertEqual(
|
||||||
|
git_text(self.core, "rev-parse", "refs/tags/v0.1.10"),
|
||||||
|
git_text(self.core_remote, "rev-parse", "refs/tags/v0.1.10"),
|
||||||
|
)
|
||||||
|
self.assertNotEqual(
|
||||||
|
git_text(self.core, "rev-parse", "HEAD"),
|
||||||
|
git_text(self.core_remote, "rev-parse", "refs/heads/main"),
|
||||||
|
)
|
||||||
|
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_meta_receipt_is_rechecked_after_an_earlier_successful_publication(self):
|
||||||
|
original = meta_source_tag.run
|
||||||
|
|
||||||
|
def changed_after_core(command, **kwargs):
|
||||||
|
result = original(command, **kwargs)
|
||||||
|
if (
|
||||||
|
command[:3] == ("git", "push", "--atomic")
|
||||||
|
and kwargs["cwd"] == self.core
|
||||||
|
):
|
||||||
|
(self.meta / "changed-review.txt").write_text(
|
||||||
|
"new Meta source after Core publication\n"
|
||||||
|
)
|
||||||
|
self.commit_meta()
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(meta_source_tag, "run", side_effect=changed_after_core):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("partial", result["status"], result)
|
||||||
|
self.assertTrue(ref_exists(self.core_remote, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(self.meta, "refs/tags/v0.1.10"))
|
||||||
|
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||||
|
|
||||||
|
def test_unknown_selected_repository_cannot_use_meta_support_exception(self):
|
||||||
|
result = self.tag(repos=("govoplan", "unknown"), apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("not registered", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_selected_checkout_generator_is_never_executed(self):
|
||||||
|
malicious = self.meta / "tools/release/generate-developer-meta-package.py"
|
||||||
|
malicious.parent.mkdir(parents=True)
|
||||||
|
malicious.write_text(
|
||||||
|
'raise RuntimeError("selected checkout must not execute")\n'
|
||||||
|
)
|
||||||
|
self.commit_meta()
|
||||||
|
self.assertEqual("planned", self.tag()["status"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -65,6 +65,8 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
)
|
)
|
||||||
wrapper = development / release.name
|
wrapper = development / release.name
|
||||||
wrapper.write_text(
|
wrapper.write_text(
|
||||||
|
"from importlib import import_module\n"
|
||||||
|
'_migration = import_module("govoplan_core.backend.migrations.versions.1234_example")\n'
|
||||||
"revision = _migration.revision\n"
|
"revision = _migration.revision\n"
|
||||||
"down_revision = _migration.down_revision\n"
|
"down_revision = _migration.down_revision\n"
|
||||||
"depends_on = _migration.depends_on\n"
|
"depends_on = _migration.depends_on\n"
|
||||||
@@ -79,6 +81,150 @@ branch_labels: Union[str, Sequence[str], None] = None
|
|||||||
self.assertEqual(("base",), migration.down_revisions)
|
self.assertEqual(("base",), migration.down_revisions)
|
||||||
self.assertEqual(("core",), migration.depends_on)
|
self.assertEqual(("core",), migration.depends_on)
|
||||||
|
|
||||||
|
def test_literal_wrapper_alias_and_different_filename_are_resolved_without_execution(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "versions").mkdir()
|
||||||
|
(root / "dev_versions").mkdir()
|
||||||
|
(root / "versions/1234_v019_example.py").write_text(
|
||||||
|
'revision = "1234"\ndown_revision = "base"\ndepends_on = "core"\nbranch_labels = None\n'
|
||||||
|
'raise AssertionError("Migration implementation must not execute")\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
wrapper = root / "dev_versions/1234_example.py"
|
||||||
|
for alias in ("_migration", "edit_revision", "message_actions"):
|
||||||
|
with self.subTest(alias=alias):
|
||||||
|
wrapper.write_text(
|
||||||
|
"from importlib import import_module as load_migration\n"
|
||||||
|
f'{alias} = load_migration("govoplan_campaign.backend.migrations.versions." "1234_v019_example")\n'
|
||||||
|
f"revision = {alias}.revision\ndown_revision = {alias}.down_revision\n"
|
||||||
|
f"depends_on = {alias}.depends_on\nbranch_labels = {alias}.branch_labels\n"
|
||||||
|
'raise AssertionError("Wrapper must not execute")\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
migration = audit.parse_migration_file("govoplan-campaign", wrapper)
|
||||||
|
self.assertEqual(migration.revision, "1234")
|
||||||
|
self.assertEqual(migration.down_revisions, ("base",))
|
||||||
|
self.assertEqual(migration.depends_on, ("core",))
|
||||||
|
|
||||||
|
def test_core_literal_sibling_file_wrapper_is_resolved_without_execution(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "versions").mkdir()
|
||||||
|
(root / "dev_versions").mkdir()
|
||||||
|
(root / "versions/1234_example.py").write_text(
|
||||||
|
'revision = "1234"\ndown_revision = None\ndepends_on = None\nbranch_labels = None\n'
|
||||||
|
'raise AssertionError("Migration implementation must not execute")\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
wrapper = root / "dev_versions/1234_example.py"
|
||||||
|
wrapper.write_text(
|
||||||
|
"from importlib.util import module_from_spec, spec_from_file_location\n"
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
'_path = Path(__file__).resolve().parents[1] / "versions" / "1234_example.py"\n'
|
||||||
|
'_spec = spec_from_file_location("synthetic_migration", _path)\n'
|
||||||
|
"_module = module_from_spec(_spec)\n_spec.loader.exec_module(_module)\n"
|
||||||
|
"revision = _module.revision\ndown_revision = _module.down_revision\n"
|
||||||
|
"depends_on = _module.depends_on\nbranch_labels = _module.branch_labels\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
migration = audit.parse_migration_file("govoplan-core", wrapper)
|
||||||
|
self.assertEqual(migration.revision, "1234")
|
||||||
|
self.assertEqual(migration.down_revisions, ())
|
||||||
|
|
||||||
|
def test_wrapper_rejects_dynamic_foreign_missing_or_rebound_targets(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "versions").mkdir()
|
||||||
|
(root / "dev_versions").mkdir()
|
||||||
|
(root / "versions/1234_example.py").write_text('revision = "1234"\n', encoding="utf-8")
|
||||||
|
wrapper = root / "dev_versions/1234_example.py"
|
||||||
|
valid = '_migration = import_module("govoplan_campaign.backend.migrations.versions.1234_example")\n'
|
||||||
|
definitions = (
|
||||||
|
'_migration = import_module(module_name)\n',
|
||||||
|
'_migration = import_module("govoplan_mail.backend.migrations.versions.1234_example")\n',
|
||||||
|
'_migration = import_module("govoplan_campaign.backend.migrations.versions...other.1234_example")\n',
|
||||||
|
'_migration = import_module("govoplan_campaign.backend.migrations.versions.missing")\n',
|
||||||
|
valid + "_migration = another_module\n",
|
||||||
|
"import_module = another_loader\n" + valid,
|
||||||
|
"def import_module(value):\n return another_module\n" + valid,
|
||||||
|
"import another_loader as import_module\n" + valid,
|
||||||
|
valid + "class _migration:\n revision = 'another'\n",
|
||||||
|
"if condition:\n _migration = another_module\n" + valid,
|
||||||
|
valid + "del _migration\n",
|
||||||
|
)
|
||||||
|
for definition in definitions:
|
||||||
|
with self.subTest(definition=definition):
|
||||||
|
wrapper.write_text(
|
||||||
|
"from importlib import import_module\n" + definition + "revision = _migration.revision\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(ValueError, "unsupported or ambiguous"):
|
||||||
|
audit.parse_migration_file("govoplan-campaign", wrapper)
|
||||||
|
|
||||||
|
def test_wrapper_rejects_symlink_and_mixed_release_metadata(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "versions").mkdir()
|
||||||
|
(root / "dev_versions").mkdir()
|
||||||
|
(root / "versions/1234_example.py").write_text('revision = "1234"\ndown_revision = None\n', encoding="utf-8")
|
||||||
|
(root / "versions/5678_example.py").write_text('revision = "5678"\ndown_revision = None\n', encoding="utf-8")
|
||||||
|
(root / "versions/linked.py").symlink_to(root / "versions/1234_example.py")
|
||||||
|
wrapper = root / "dev_versions/1234_example.py"
|
||||||
|
definitions = (
|
||||||
|
'_migration = import_module("govoplan_campaign.backend.migrations.versions.linked")\nrevision = _migration.revision\n',
|
||||||
|
'_migration = import_module("govoplan_campaign.backend.migrations.versions.1234_example")\n'
|
||||||
|
'_other = import_module("govoplan_campaign.backend.migrations.versions.5678_example")\n'
|
||||||
|
'revision = _migration.revision\ndown_revision = _other.down_revision\n',
|
||||||
|
)
|
||||||
|
for definition in definitions:
|
||||||
|
with self.subTest(definition=definition):
|
||||||
|
wrapper.write_text("from importlib import import_module\n" + definition, encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "unsupported or ambiguous"):
|
||||||
|
audit.parse_migration_file("govoplan-campaign", wrapper)
|
||||||
|
|
||||||
|
def test_unresolved_revision_expression_is_not_silently_omitted(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
path = Path(directory) / "1234_example.py"
|
||||||
|
path.write_text("revision = calculate_revision()\n", encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unsupported or ambiguous migration metadata"):
|
||||||
|
audit.parse_migration_file("govoplan-core", path)
|
||||||
|
|
||||||
|
def test_explicit_metadata_reexport_is_resolved_without_execution(self) -> None:
|
||||||
|
audit = load_audit_module()
|
||||||
|
with tempfile.TemporaryDirectory(prefix="migration-audit-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "versions").mkdir()
|
||||||
|
(root / "dev_versions").mkdir()
|
||||||
|
(root / "versions/a234_example.py").write_text(
|
||||||
|
'revision = "1234"\ndown_revision = "base"\ndepends_on = None\nbranch_labels = None\n'
|
||||||
|
'raise AssertionError("Migration implementation must not execute")\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
wrapper = root / "dev_versions/1234_example.py"
|
||||||
|
source = "govoplan_organizations.backend.migrations.versions.a234_example"
|
||||||
|
wrapper.write_text(f"from {source} import revision, down_revision, depends_on, branch_labels\n", encoding="utf-8")
|
||||||
|
migration = audit.parse_migration_file("govoplan-organizations", wrapper)
|
||||||
|
self.assertEqual(migration.revision, "1234")
|
||||||
|
self.assertEqual(migration.down_revisions, ("base",))
|
||||||
|
for declaration in (
|
||||||
|
f"from {source} import *\n",
|
||||||
|
f"from {source} import revision as down_revision\n",
|
||||||
|
f"from {source} import revision\nrevision = 'different'\n",
|
||||||
|
f"from {source} import revision\nimport another as revision\n",
|
||||||
|
f"from {source} import revision\ndef revision():\n pass\n",
|
||||||
|
f"from {source.replace('govoplan_organizations', 'govoplan_mail')} import revision\n",
|
||||||
|
):
|
||||||
|
with self.subTest(declaration=declaration):
|
||||||
|
wrapper.write_text(declaration, encoding="utf-8")
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
audit.parse_migration_file("govoplan-organizations", wrapper)
|
||||||
|
|
||||||
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
def test_release_baseline_matches_current_heads_in_strict_report(self) -> None:
|
||||||
audit = load_audit_module()
|
audit = load_audit_module()
|
||||||
migrations = [
|
migrations = [
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import runpy
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -16,6 +18,7 @@ if str(RELEASE_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(RELEASE_ROOT))
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||||
|
from govoplan_release import workspace as release_workspace # noqa: E402
|
||||||
from server.app import create_app # noqa: E402
|
from server.app import create_app # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +41,14 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
)
|
)
|
||||||
add_scoped_workflow_manifest(self.manifest_repo)
|
add_scoped_workflow_manifest(self.manifest_repo)
|
||||||
|
# The operator's test catalog explicitly registers known synthetic
|
||||||
|
# endpoints; production trust checks are not patched or bypassed.
|
||||||
|
self.registry = self.root / "registered-test-repositories.json"
|
||||||
|
self.registered = json.loads((META_ROOT / "repositories.json").read_text())
|
||||||
|
for spec in self.registered["repositories"]:
|
||||||
|
spec["remote"] = str(self.root / f"{spec['name']}.git")
|
||||||
|
self.registry.write_text(json.dumps(self.registered))
|
||||||
|
self.enterContext(patch.object(release_workspace, "REPOSITORIES_FILE", self.registry))
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
@@ -127,6 +138,10 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
name="govoplan-core",
|
name="govoplan-core",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
)
|
)
|
||||||
|
for spec in self.registered["repositories"]:
|
||||||
|
if spec["name"] == "govoplan-core":
|
||||||
|
spec["remote"] = str(remote_root / "govoplan-core.git")
|
||||||
|
self.registry.write_text(json.dumps(self.registered))
|
||||||
|
|
||||||
result = tag_repositories(
|
result = tag_repositories(
|
||||||
repos=("govoplan-core",),
|
repos=("govoplan-core",),
|
||||||
@@ -214,21 +229,23 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
git(self.repo, "commit", "-m", "Add release WebUI composition")
|
git(self.repo, "commit", "-m", "Add release WebUI composition")
|
||||||
git(self.repo, "push", "origin", "main")
|
git(self.repo, "push", "origin", "main")
|
||||||
|
|
||||||
result = tag_repositories(
|
for push in (False, True):
|
||||||
repos=("govoplan-core", "govoplan-campaign"),
|
with self.subTest(push=push):
|
||||||
repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
|
result = tag_repositories(
|
||||||
workspace_root=self.workspace,
|
repos=("govoplan-core", "govoplan-campaign"),
|
||||||
apply=True,
|
repo_versions={"govoplan-core": "0.1.10", "govoplan-campaign": "0.1.10"},
|
||||||
push=True,
|
workspace_root=self.workspace,
|
||||||
)
|
apply=True,
|
||||||
|
push=push,
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual("blocked", result["status"])
|
self.assertEqual("blocked", result["status"])
|
||||||
self.assertIn("no selected repository was mutated", result["detail"])
|
self.assertIn("no selected repository was mutated", result["detail"])
|
||||||
self.assertEqual("skipped", result["repositories"][0]["status"])
|
self.assertEqual("skipped", result["repositories"][0]["status"])
|
||||||
self.assertEqual("blocked", result["repositories"][1]["status"])
|
self.assertEqual("blocked", result["repositories"][1]["status"])
|
||||||
self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
|
self.assertIn("release WebUI composition gate failed", result["repositories"][1]["detail"])
|
||||||
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||||
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
|
def test_api_keeps_legacy_source_release_preview_only(self) -> None:
|
||||||
with TestClient(
|
with TestClient(
|
||||||
@@ -300,6 +317,183 @@ class ReleaseRepositoryTagTests(unittest.TestCase):
|
|||||||
self.assertIn("Signed Website Catalog", ui.text)
|
self.assertIn("Signed Website Catalog", ui.text)
|
||||||
self.assertIn("Apply + Website Tag", ui.text)
|
self.assertIn("Apply + Website Tag", ui.text)
|
||||||
|
|
||||||
|
def test_local_module_candidate_precedes_core_lock_but_publication_does_not(self) -> None:
|
||||||
|
campaign, campaign_remote = self._staged_campaign_bundle()
|
||||||
|
remotes = (self.remote, self.manifest_remote, campaign_remote)
|
||||||
|
remote_refs = {path: git_text(path, "show-ref") for path in remotes}
|
||||||
|
arguments = {
|
||||||
|
"repos": ("govoplan-campaign",),
|
||||||
|
"repo_versions": {"govoplan-campaign": "0.1.10"},
|
||||||
|
"workspace_root": self.workspace,
|
||||||
|
}
|
||||||
|
|
||||||
|
preview = tag_repositories(**arguments, apply=False, push=False)
|
||||||
|
self.assertEqual("planned", preview["status"], preview)
|
||||||
|
self.assertFalse(ref_exists(campaign, "refs/tags/v0.1.10"))
|
||||||
|
candidate = tag_repositories(**arguments, apply=True, push=False)
|
||||||
|
self.assertEqual("tagged", candidate["status"], candidate)
|
||||||
|
self.assertEqual("tag", git_text(campaign, "cat-file", "-t", "v0.1.10"))
|
||||||
|
tag_object = git_text(campaign, "rev-parse", "v0.1.10")
|
||||||
|
head = git_text(campaign, "rev-parse", "HEAD")
|
||||||
|
self.assertEqual(head, git_text(campaign, "rev-parse", "v0.1.10^{commit}"))
|
||||||
|
|
||||||
|
for apply in (False, True):
|
||||||
|
with self.subTest(publish_apply=apply):
|
||||||
|
blocked = tag_repositories(**arguments, apply=apply, push=True)
|
||||||
|
self.assertEqual("blocked", blocked["status"], blocked)
|
||||||
|
self.assertIn(
|
||||||
|
"release WebUI composition gate failed",
|
||||||
|
blocked["repositories"][0]["detail"],
|
||||||
|
)
|
||||||
|
self.assertEqual(tag_object, git_text(campaign, "rev-parse", "v0.1.10"))
|
||||||
|
self.assertEqual(remote_refs, {path: git_text(path, "show-ref") for path in remotes})
|
||||||
|
|
||||||
|
lock_path = self.repo / "webui" / "package-lock.release.json"
|
||||||
|
lock = json.loads(lock_path.read_text(encoding="utf-8"))
|
||||||
|
locked_campaign = lock["packages"]["node_modules/@govoplan/campaign-webui"]
|
||||||
|
locked_campaign["version"] = "0.1.10"
|
||||||
|
locked_campaign["resolved"] = f"git+ssh://git@example.test/acme/govoplan-campaign.git#{head}"
|
||||||
|
lock_path.write_text(json.dumps(lock) + "\n", encoding="utf-8")
|
||||||
|
git(self.repo, "add", "webui/package-lock.release.json")
|
||||||
|
git(self.repo, "commit", "-m", "Resolve reviewed local Campaign candidate")
|
||||||
|
|
||||||
|
core_candidate = tag_repositories(
|
||||||
|
repos=("govoplan-core",),
|
||||||
|
repo_versions={"govoplan-core": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=False,
|
||||||
|
)
|
||||||
|
self.assertEqual("tagged", core_candidate["status"], core_candidate)
|
||||||
|
self.assertEqual(remote_refs, {path: git_text(path, "show-ref") for path in remotes})
|
||||||
|
published = tag_repositories(**arguments, apply=True, push=True)
|
||||||
|
self.assertEqual("published", published["status"], published)
|
||||||
|
self.assertEqual(tag_object, git_text(campaign_remote, "rev-parse", "v0.1.10"))
|
||||||
|
self.assertEqual(head, git_text(campaign_remote, "rev-parse", "refs/heads/main"))
|
||||||
|
self.assertEqual(remote_refs[self.remote], git_text(self.remote, "show-ref"))
|
||||||
|
|
||||||
|
def test_local_core_candidate_still_requires_resolved_module_tags(self) -> None:
|
||||||
|
campaign, campaign_remote = self._staged_campaign_bundle()
|
||||||
|
for selected in (("govoplan-core",), ("govoplan-campaign", "govoplan-core")):
|
||||||
|
with self.subTest(selected=selected):
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=selected,
|
||||||
|
repo_versions={repo: "0.1.10" for repo in selected},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=False,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
core_row = next(row for row in result["repositories"] if row["repo"] == "govoplan-core")
|
||||||
|
self.assertIn("version alignment gate failed", core_row["detail"])
|
||||||
|
self.assertIn("@govoplan/campaign-webui:resolved", core_row["detail"])
|
||||||
|
self.assertIn("expected 'local tag v0.1.10'", core_row["detail"])
|
||||||
|
for repository in (self.repo, self.remote, campaign, campaign_remote):
|
||||||
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_local_module_candidate_preserves_version_and_worktree_gates(self) -> None:
|
||||||
|
campaign, campaign_remote = self._staged_campaign_bundle()
|
||||||
|
arguments = {
|
||||||
|
"repos": ("govoplan-campaign",),
|
||||||
|
"workspace_root": self.workspace,
|
||||||
|
"apply": True,
|
||||||
|
"push": False,
|
||||||
|
}
|
||||||
|
mismatch = tag_repositories(**arguments, repo_versions={"govoplan-campaign": "0.1.11"})
|
||||||
|
self.assertEqual("blocked", mismatch["status"], mismatch)
|
||||||
|
self.assertIn("version alignment gate failed", mismatch["repositories"][0]["detail"])
|
||||||
|
|
||||||
|
(campaign / "unreviewed.txt").write_text("operator work\n", encoding="utf-8")
|
||||||
|
dirty = tag_repositories(**arguments, repo_versions={"govoplan-campaign": "0.1.10"})
|
||||||
|
self.assertEqual("blocked", dirty["status"], dirty)
|
||||||
|
self.assertIn("worktree is not clean", dirty["repositories"][0]["detail"])
|
||||||
|
for repository in (campaign, campaign_remote):
|
||||||
|
for tag in ("v0.1.10", "v0.1.11"):
|
||||||
|
self.assertFalse(ref_exists(repository, f"refs/tags/{tag}"))
|
||||||
|
|
||||||
|
def test_local_module_candidate_preserves_manifest_gate(self) -> None:
|
||||||
|
campaign, campaign_remote = self._staged_campaign_bundle()
|
||||||
|
replace_with_unscoped_workflow_manifest(self.manifest_repo)
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-campaign",),
|
||||||
|
repo_versions={"govoplan-campaign": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=False,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("scope-conditioned alternative", result["repositories"][0]["detail"])
|
||||||
|
for repository in (campaign, campaign_remote):
|
||||||
|
self.assertFalse(ref_exists(repository, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_local_module_candidate_preserves_remote_tag_immutability(self) -> None:
|
||||||
|
campaign, campaign_remote = self._staged_campaign_bundle()
|
||||||
|
git(campaign, "tag", "-a", "v0.1.10", "-m", "Existing immutable tag", "v0.1.9^{commit}")
|
||||||
|
git(campaign, "push", "origin", "refs/tags/v0.1.10")
|
||||||
|
remote_refs = git_text(campaign_remote, "show-ref")
|
||||||
|
for local_exists in (True, False):
|
||||||
|
with self.subTest(local_exists=local_exists):
|
||||||
|
if not local_exists:
|
||||||
|
git(campaign, "tag", "-d", "v0.1.10")
|
||||||
|
result = tag_repositories(
|
||||||
|
repos=("govoplan-campaign",),
|
||||||
|
repo_versions={"govoplan-campaign": "0.1.10"},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=True,
|
||||||
|
push=False,
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("immutable tag", result["repositories"][0]["detail"])
|
||||||
|
self.assertIn("not HEAD", result["repositories"][0]["detail"])
|
||||||
|
self.assertEqual(remote_refs, git_text(campaign_remote, "show-ref"))
|
||||||
|
self.assertEqual(local_exists, ref_exists(campaign, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def _staged_campaign_bundle(self) -> tuple[Path, Path]:
|
||||||
|
campaign, remote = create_release_repo(
|
||||||
|
root=self.root,
|
||||||
|
workspace=self.workspace,
|
||||||
|
name="govoplan-campaign",
|
||||||
|
version="0.1.9",
|
||||||
|
)
|
||||||
|
campaign_webui = campaign / "webui"
|
||||||
|
campaign_webui.mkdir()
|
||||||
|
package_path = campaign_webui / "package.json"
|
||||||
|
package_path.write_text(
|
||||||
|
'{"name":"@govoplan/campaign-webui","version":"0.1.9"}\n', encoding="utf-8"
|
||||||
|
)
|
||||||
|
git(campaign, "add", "webui/package.json")
|
||||||
|
git(campaign, "commit", "-m", "Prior Campaign WebUI package")
|
||||||
|
git(campaign, "tag", "-a", "v0.1.9", "-m", "Prior Campaign release")
|
||||||
|
git(campaign, "push", "origin", "main", "refs/tags/v0.1.9")
|
||||||
|
prior_commit = git_text(campaign, "rev-parse", "HEAD")
|
||||||
|
for path in (campaign / "pyproject.toml", package_path):
|
||||||
|
path.write_text(path.read_text(encoding="utf-8").replace("0.1.9", "0.1.10"), encoding="utf-8")
|
||||||
|
git(campaign, "add", "pyproject.toml", "webui/package.json")
|
||||||
|
git(campaign, "commit", "-m", "Reviewed Campaign candidate")
|
||||||
|
|
||||||
|
core_webui = self.repo / "webui"
|
||||||
|
core_webui.mkdir()
|
||||||
|
dependency_ref = "git+ssh://git@example.test/acme/govoplan-campaign.git#v0.1.10"
|
||||||
|
package = {
|
||||||
|
"name": "@govoplan/core-webui",
|
||||||
|
"version": "0.1.10",
|
||||||
|
"dependencies": {"@govoplan/campaign-webui": dependency_ref},
|
||||||
|
}
|
||||||
|
(core_webui / "package.release.json").write_text(json.dumps(package) + "\n", encoding="utf-8")
|
||||||
|
(core_webui / "package-lock.release.json").write_text(
|
||||||
|
json.dumps({"packages": {
|
||||||
|
"": package,
|
||||||
|
"node_modules/@govoplan/campaign-webui": {
|
||||||
|
"version": "0.1.9",
|
||||||
|
"resolved": f"git+ssh://git@example.test/acme/govoplan-campaign.git#{prior_commit}",
|
||||||
|
},
|
||||||
|
}}) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
git(self.repo, "add", "webui/package.release.json", "webui/package-lock.release.json")
|
||||||
|
git(self.repo, "commit", "-m", "Stage Core input before candidate lock resolution")
|
||||||
|
return campaign, remote
|
||||||
|
|
||||||
|
|
||||||
def git(cwd: Path, *args: str) -> None:
|
def git(cwd: Path, *args: str) -> None:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -338,9 +532,50 @@ def add_scoped_workflow_manifest(repo: Path) -> None:
|
|||||||
backend.mkdir(parents=True)
|
backend.mkdir(parents=True)
|
||||||
(package / "__init__.py").write_text("", encoding="utf-8")
|
(package / "__init__.py").write_text("", encoding="utf-8")
|
||||||
(backend / "__init__.py").write_text("", encoding="utf-8")
|
(backend / "__init__.py").write_text("", encoding="utf-8")
|
||||||
|
# This small workspace still has to satisfy the real presentation contract.
|
||||||
|
# Keep that prerequisite shared by both valid and intentionally unscoped
|
||||||
|
# documentation fixtures, so each test reaches its intended release gate.
|
||||||
|
canonical_areas = runpy.run_path(
|
||||||
|
str(META_ROOT / "tools" / "checks" / "check-manifest-shapes.py")
|
||||||
|
)["CANONICAL_PRODUCT_AREAS"]
|
||||||
|
(backend / "release_fixture.py").write_text(
|
||||||
|
"""from govoplan_core.core.modules import FrontendModule, ProductAreaContribution
|
||||||
|
from govoplan_core.core.views import ViewSurface
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_frontend():
|
||||||
|
return FrontendModule(
|
||||||
|
module_id="access",
|
||||||
|
view_surfaces=(
|
||||||
|
ViewSurface(
|
||||||
|
id="access.section.release-fixture",
|
||||||
|
module_id="access",
|
||||||
|
kind="section",
|
||||||
|
label="Release fixture",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
product_areas=tuple(
|
||||||
|
ProductAreaContribution(
|
||||||
|
id=area_id,
|
||||||
|
module_id="access",
|
||||||
|
label=label,
|
||||||
|
icon=icon,
|
||||||
|
description=description,
|
||||||
|
order=order,
|
||||||
|
surface_ids=("access.section.release-fixture",),
|
||||||
|
)
|
||||||
|
for area_id, (label, icon, description, order) in CANONICAL_AREAS.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CANONICAL_AREAS = """ + repr(canonical_areas) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
(backend / "manifest.py").write_text(
|
(backend / "manifest.py").write_text(
|
||||||
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
"""from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, ModuleManifest, PermissionDefinition
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from .release_fixture import fixture_frontend
|
||||||
|
|
||||||
|
|
||||||
def get_manifest():
|
def get_manifest():
|
||||||
@@ -348,6 +583,7 @@ def get_manifest():
|
|||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
|
frontend=fixture_frontend(),
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
scope="access:item:read",
|
scope="access:item:read",
|
||||||
@@ -403,6 +639,7 @@ def replace_with_unscoped_workflow_manifest(repo: Path) -> None:
|
|||||||
manifest.write_text(
|
manifest.write_text(
|
||||||
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
"""from govoplan_core.core.modules import DocumentationTopic, ModuleManifest
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
|
from .release_fixture import fixture_frontend
|
||||||
|
|
||||||
|
|
||||||
def get_manifest():
|
def get_manifest():
|
||||||
@@ -410,6 +647,7 @@ def get_manifest():
|
|||||||
id="access",
|
id="access",
|
||||||
name="Access",
|
name="Access",
|
||||||
version="0.1.10",
|
version="0.1.10",
|
||||||
|
frontend=fixture_frontend(),
|
||||||
documentation=(
|
documentation=(
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="access.workflow.unscoped",
|
id="access.workflow.unscoped",
|
||||||
|
|||||||
@@ -1891,7 +1891,7 @@ class ReleaseRunApiTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_ui_and_runbook_state_tracking_boundary_are_explicit(self) -> None:
|
def test_ui_and_runbook_state_tracking_boundary_are_explicit(self) -> None:
|
||||||
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
webui = (RELEASE_ROOT / "webui" / "index.html").read_text(encoding="utf-8")
|
||||||
runbook = (META_ROOT / "docs" / "RELEASE_CONSOLE.md").read_text(
|
runbook = (META_ROOT / "docs" / "operations" / "RELEASE_CONSOLE.md").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools/release"))
|
||||||
|
|
||||||
|
from govoplan_release import source_tag_batch # noqa: E402
|
||||||
|
from govoplan_release.repository_tag import tag_repositories # noqa: E402
|
||||||
|
import test_release_meta_source_tag as meta_fixture # noqa: E402
|
||||||
|
import test_release_repository_tag as release_fixture # noqa: E402
|
||||||
|
from test_release_repository_tag import git, git_text, ref_exists # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class RegisteredSourceTagBatchTests(unittest.TestCase):
|
||||||
|
setUp = meta_fixture.MetaSourceTagTests.setUp
|
||||||
|
synchronize = meta_fixture.MetaSourceTagTests.synchronize
|
||||||
|
assert_no_tags = meta_fixture.MetaSourceTagTests.assert_no_tags
|
||||||
|
|
||||||
|
def tag(self, *, repos=("govoplan-access",), apply=False, push=False):
|
||||||
|
return tag_repositories(
|
||||||
|
repos=repos,
|
||||||
|
repo_versions={repo: self.version for repo in repos},
|
||||||
|
workspace_root=self.workspace,
|
||||||
|
apply=apply,
|
||||||
|
push=push,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_python_only_module_publishes_without_meta_or_core_checkout(self):
|
||||||
|
self.meta.rename(self.root / "unused-meta")
|
||||||
|
self.core.rename(self.root / "unused-core")
|
||||||
|
preview = self.tag(push=True)
|
||||||
|
self.assertEqual("planned", preview["status"], preview)
|
||||||
|
self.assertEqual("registered-source-batch-v1", preview["source_contract"])
|
||||||
|
self.assertEqual({}, preview["bundle_input_receipts"])
|
||||||
|
self.assertFalse(ref_exists(self.access, "refs/tags/v0.1.10"))
|
||||||
|
published = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("published", published["status"], published)
|
||||||
|
self.assertEqual(["govoplan-access"], list(published["source_receipts"]))
|
||||||
|
self.assertEqual(
|
||||||
|
git_text(self.access, "rev-parse", "HEAD"),
|
||||||
|
git_text(self.access_remote, "rev-parse", "refs/heads/main"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_core_only_batch_has_no_meta_composition_requirement(self):
|
||||||
|
self.meta.rename(self.root / "unused-meta")
|
||||||
|
result = self.tag(repos=("govoplan-core",), apply=True, push=True)
|
||||||
|
self.assertEqual("published", result["status"], result)
|
||||||
|
self.assertEqual(["govoplan-core"], list(result["source_receipts"]))
|
||||||
|
|
||||||
|
def test_backend_only_publication_never_reads_irrelevant_unsafe_core_json(self):
|
||||||
|
webui = self.core / "webui"
|
||||||
|
webui.mkdir()
|
||||||
|
(webui / "package.release.json").write_text("invalid unselected Core JSON")
|
||||||
|
(webui / "package-lock.release.json").symlink_to(
|
||||||
|
self.root / "not-a-core-release-lock"
|
||||||
|
)
|
||||||
|
from govoplan_release import version_alignment
|
||||||
|
|
||||||
|
original = version_alignment._json_object
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
if path.is_relative_to(webui):
|
||||||
|
raise AssertionError(
|
||||||
|
"backend-only publication must not read unrelated Core JSON"
|
||||||
|
)
|
||||||
|
return original(path)
|
||||||
|
|
||||||
|
with patch.object(version_alignment, "_json_object", side_effect=read):
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("published", result["status"], result)
|
||||||
|
self.assertEqual({}, result["bundle_input_receipts"])
|
||||||
|
|
||||||
|
def test_source_origin_parent_and_read_only_target_guards_apply_without_meta(self):
|
||||||
|
for problem in ("origin", "parent", "read_only"):
|
||||||
|
with self.subTest(problem=problem):
|
||||||
|
parent_mode = self.root.stat().st_mode & 0o7777
|
||||||
|
git_directory = self.access / ".git"
|
||||||
|
git_mode = git_directory.stat().st_mode & 0o7777
|
||||||
|
if problem == "origin":
|
||||||
|
git(
|
||||||
|
self.access,
|
||||||
|
"config",
|
||||||
|
"remote.origin.pushurl",
|
||||||
|
str(self.root / "unknown.git"),
|
||||||
|
)
|
||||||
|
elif problem == "parent":
|
||||||
|
self.root.chmod(0o777)
|
||||||
|
else:
|
||||||
|
git_directory.chmod(0o500)
|
||||||
|
try:
|
||||||
|
for apply in (False, True):
|
||||||
|
result = self.tag(apply=apply, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assert_no_tags()
|
||||||
|
finally:
|
||||||
|
self.root.chmod(parent_mode)
|
||||||
|
git_directory.chmod(git_mode)
|
||||||
|
if problem == "origin":
|
||||||
|
git(self.access, "config", "--unset", "remote.origin.pushurl")
|
||||||
|
|
||||||
|
def test_wrong_owner_symlink_and_hidden_index_are_rejected_without_meta(self):
|
||||||
|
config = self.access / ".git/config"
|
||||||
|
original_stat = Path.lstat
|
||||||
|
|
||||||
|
def wrong_owner(path, *args, **kwargs):
|
||||||
|
observed = original_stat(path, *args, **kwargs)
|
||||||
|
if path == config:
|
||||||
|
fields = list(observed)
|
||||||
|
fields[4] = os.geteuid() + 1
|
||||||
|
return os.stat_result(fields)
|
||||||
|
return observed
|
||||||
|
|
||||||
|
with patch.object(Path, "lstat", new=wrong_owner):
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
moved = self.root / "moved-access"
|
||||||
|
self.access.rename(moved)
|
||||||
|
self.access.symlink_to(moved, target_is_directory=True)
|
||||||
|
try:
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
finally:
|
||||||
|
self.access.unlink()
|
||||||
|
moved.rename(self.access)
|
||||||
|
for flag, undo in (
|
||||||
|
("--assume-unchanged", "--no-assume-unchanged"),
|
||||||
|
("--skip-worktree", "--no-skip-worktree"),
|
||||||
|
):
|
||||||
|
with self.subTest(flag=flag):
|
||||||
|
git(self.access, "update-index", flag, "pyproject.toml")
|
||||||
|
try:
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
finally:
|
||||||
|
git(self.access, "update-index", undo, "pyproject.toml")
|
||||||
|
|
||||||
|
def test_live_remote_divergence_blocks_without_cached_tracking_update(self):
|
||||||
|
clone = self.root / "other-access-writer"
|
||||||
|
git(self.root, "clone", "--branch", "main", str(self.access_remote), str(clone))
|
||||||
|
git(clone, "config", "user.name", "Synthetic writer")
|
||||||
|
git(clone, "config", "user.email", "writer@example.invalid")
|
||||||
|
(clone / "advance.txt").write_text("new remote main\n")
|
||||||
|
git(clone, "add", ".")
|
||||||
|
git(clone, "commit", "-m", "Advance remote without updating original tracking")
|
||||||
|
git(clone, "push", "origin", "main")
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("live origin/main", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_ignored_selected_version_metadata_cannot_supply_an_untagged_artifact(self):
|
||||||
|
project = self.access / "pyproject.toml"
|
||||||
|
original = project.read_text()
|
||||||
|
git(self.access, "rm", "pyproject.toml")
|
||||||
|
(self.access / ".gitignore").write_text("/pyproject.toml\n")
|
||||||
|
git(self.access, "add", ".gitignore")
|
||||||
|
git(self.access, "commit", "-m", "Ignored metadata absent from frozen tree")
|
||||||
|
project.write_text(original)
|
||||||
|
self.assertEqual("", git_text(self.access, "status", "--porcelain"))
|
||||||
|
result = self.tag(apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("must be tracked", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_lightweight_and_different_annotation_objects_remain_immutable(self):
|
||||||
|
git(self.access, "tag", "v0.1.10")
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True)["status"])
|
||||||
|
git(self.access, "tag", "-d", "v0.1.10")
|
||||||
|
git(self.access, "tag", "-a", "v0.1.10", "-m", "Original immutable annotation")
|
||||||
|
git(self.access, "push", "origin", "refs/tags/v0.1.10")
|
||||||
|
original = git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||||
|
git(self.access, "tag", "-d", "v0.1.10")
|
||||||
|
git(
|
||||||
|
self.access,
|
||||||
|
"tag",
|
||||||
|
"-a",
|
||||||
|
"v0.1.10",
|
||||||
|
"-m",
|
||||||
|
"Conflicting immutable annotation",
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", self.tag(apply=True, push=True)["status"])
|
||||||
|
self.assertEqual(
|
||||||
|
original, git_text(self.access_remote, "rev-parse", "refs/tags/v0.1.10")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_changed_source_after_preflight_stops_before_first_batch_effect(self):
|
||||||
|
original = source_tag_batch._preview_repositories
|
||||||
|
|
||||||
|
def changed(**kwargs):
|
||||||
|
result = original(**kwargs)
|
||||||
|
(self.access / "changed-source.txt").write_text(
|
||||||
|
"new source after preflight\n"
|
||||||
|
)
|
||||||
|
git(self.access, "add", ".")
|
||||||
|
git(self.access, "commit", "-m", "Source changed")
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||||
|
):
|
||||||
|
result = self.tag(
|
||||||
|
repos=("govoplan-core", "govoplan-access"), apply=True, push=True
|
||||||
|
)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn("receipt changed", result["repositories"][0]["detail"])
|
||||||
|
self.assert_no_tags()
|
||||||
|
|
||||||
|
def test_false_push_success_is_not_a_receipt_and_never_retries(self):
|
||||||
|
original = source_tag_batch.run
|
||||||
|
pushes = []
|
||||||
|
|
||||||
|
def run(command, **kwargs):
|
||||||
|
if command[:3] == ("git", "push", "--atomic"):
|
||||||
|
pushes.append(command)
|
||||||
|
return subprocess.CompletedProcess(command, 0, "", "")
|
||||||
|
return original(command, **kwargs)
|
||||||
|
|
||||||
|
with patch.object(source_tag_batch, "run", side_effect=run):
|
||||||
|
result = self.tag(
|
||||||
|
repos=("govoplan-access", "govoplan-core"), apply=True, push=True
|
||||||
|
)
|
||||||
|
self.assertEqual("partial", result["status"], result)
|
||||||
|
self.assertEqual(1, len(pushes))
|
||||||
|
self.assertEqual("skipped", result["repositories"][1]["status"])
|
||||||
|
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(self.access_remote, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def _ready_bundle(self):
|
||||||
|
self.repo = self.core # Existing fixture's Core name.
|
||||||
|
campaign, remote = (
|
||||||
|
release_fixture.ReleaseRepositoryTagTests._staged_campaign_bundle(self)
|
||||||
|
)
|
||||||
|
self.specs.append(
|
||||||
|
{
|
||||||
|
"name": "govoplan-campaign",
|
||||||
|
"category": "module",
|
||||||
|
"subtype": "domain",
|
||||||
|
"path": "govoplan-campaign",
|
||||||
|
"remote": str(remote),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.registry.write_text(json.dumps({"repositories": self.specs}))
|
||||||
|
self.assertEqual(
|
||||||
|
"tagged", self.tag(repos=("govoplan-campaign",), apply=True)["status"]
|
||||||
|
)
|
||||||
|
lock_path = self.core / "webui/package-lock.release.json"
|
||||||
|
payload = json.loads(lock_path.read_text())
|
||||||
|
package = payload["packages"]["node_modules/@govoplan/campaign-webui"]
|
||||||
|
package["version"] = self.version
|
||||||
|
package["resolved"] = (
|
||||||
|
"git+ssh://git@example.test/acme/govoplan-campaign.git#"
|
||||||
|
+ git_text(campaign, "rev-parse", "HEAD")
|
||||||
|
)
|
||||||
|
lock_path.write_text(json.dumps(payload))
|
||||||
|
# Core is a reviewed input only here: do not require an unrelated tag
|
||||||
|
# or silently impose a new clean-Core prerequisite for module release.
|
||||||
|
return campaign, remote, lock_path
|
||||||
|
|
||||||
|
def test_module_publication_freezes_core_inputs_without_requiring_core_tag(self):
|
||||||
|
_campaign, _remote, _lock = self._ready_bundle()
|
||||||
|
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||||
|
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||||
|
self.assertEqual("published", result["status"], result)
|
||||||
|
self.assertEqual(["govoplan-campaign"], list(result["source_receipts"]))
|
||||||
|
self.assertEqual(
|
||||||
|
{"webui/package.release.json", "webui/package-lock.release.json"},
|
||||||
|
set(result["bundle_input_receipts"]),
|
||||||
|
)
|
||||||
|
self.assertFalse(ref_exists(self.core, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_changed_or_group_writable_core_bundle_inputs_block_module_publication(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
campaign, remote, lock = self._ready_bundle()
|
||||||
|
original = source_tag_batch._preview_repositories
|
||||||
|
|
||||||
|
def changed(**kwargs):
|
||||||
|
result = original(**kwargs)
|
||||||
|
lock.write_text(lock.read_text() + "\n")
|
||||||
|
return result
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
source_tag_batch, "_preview_repositories", side_effect=changed
|
||||||
|
):
|
||||||
|
result = self.tag(repos=("govoplan-campaign",), apply=True, push=True)
|
||||||
|
self.assertEqual("blocked", result["status"], result)
|
||||||
|
self.assertIn(
|
||||||
|
"bundle input receipt changed", result["repositories"][0]["detail"]
|
||||||
|
)
|
||||||
|
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||||
|
lock.chmod(0o666)
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
"blocked",
|
||||||
|
self.tag(repos=("govoplan-campaign",), apply=True, push=True)["status"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
lock.chmod(0o644)
|
||||||
|
self.assertTrue(ref_exists(campaign, "refs/tags/v0.1.10"))
|
||||||
|
self.assertFalse(ref_exists(remote, "refs/tags/v0.1.10"))
|
||||||
|
|
||||||
|
def test_read_only_shared_preflight_has_no_apply_or_mutating_legacy_entry(self):
|
||||||
|
import inspect
|
||||||
|
from govoplan_release import repository_tag
|
||||||
|
|
||||||
|
self.assertNotIn(
|
||||||
|
"apply", inspect.signature(repository_tag._preview_repositories).parameters
|
||||||
|
)
|
||||||
|
self.assertFalse(hasattr(repository_tag, "_tag_repositories_legacy"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -13,12 +13,47 @@ if str(RELEASE_ROOT) not in sys.path:
|
|||||||
sys.path.insert(0, str(RELEASE_ROOT))
|
sys.path.insert(0, str(RELEASE_ROOT))
|
||||||
|
|
||||||
from govoplan_release.version_metadata import ( # noqa: E402
|
from govoplan_release.version_metadata import ( # noqa: E402
|
||||||
|
VersionMetadataError,
|
||||||
apply_version_metadata_mutations,
|
apply_version_metadata_mutations,
|
||||||
version_metadata_mutations,
|
version_metadata_mutations,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReleaseVersionMetadataTests(unittest.TestCase):
|
class ReleaseVersionMetadataTests(unittest.TestCase):
|
||||||
|
def test_updates_shared_module_version_without_rewriting_independent_interfaces(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
backend = root / "src" / "govoplan_example" / "backend"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
manifest = backend / "manifest.py"
|
||||||
|
manifest.write_text(
|
||||||
|
'MODULE_VERSION: str = "1.2.3"\n'
|
||||||
|
'manifest = ModuleManifest(id="example", version=MODULE_VERSION,\n'
|
||||||
|
' provides_interfaces=(ModuleInterfaceProvider(name="api", version="2.0"),))\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
changed = apply_version_metadata_mutations(root, target_version="1.2.4")
|
||||||
|
self.assertEqual(("src/govoplan_example/backend/manifest.py",), changed)
|
||||||
|
self.assertIn('MODULE_VERSION: str = "1.2.4"', manifest.read_text())
|
||||||
|
self.assertIn('version="2.0"', manifest.read_text())
|
||||||
|
self.assertEqual((), version_metadata_mutations(root, target_version="1.2.4"))
|
||||||
|
|
||||||
|
def test_dynamic_module_version_fails_before_any_metadata_is_written(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
root = Path(temp_dir)
|
||||||
|
backend = root / "src" / "govoplan_example" / "backend"
|
||||||
|
backend.mkdir(parents=True)
|
||||||
|
project = root / "pyproject.toml"
|
||||||
|
project.write_text('[project]\nname="govoplan-example"\nversion="1.2.3"\n')
|
||||||
|
before = project.read_bytes()
|
||||||
|
(backend / "manifest.py").write_text(
|
||||||
|
'MODULE_VERSION = compute_version()\n'
|
||||||
|
'manifest = ModuleManifest(id="example", version=MODULE_VERSION)\n',
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(VersionMetadataError, "no literal MODULE_VERSION"):
|
||||||
|
apply_version_metadata_mutations(root, target_version="1.2.4")
|
||||||
|
self.assertEqual(before, project.read_bytes())
|
||||||
|
|
||||||
def test_updates_recognized_metadata_without_changing_interface_versions(
|
def test_updates_recognized_metadata_without_changing_interface_versions(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -98,6 +98,10 @@ class SecurityAuditWrapperTests(unittest.TestCase):
|
|||||||
if [[ "${1:-}" == 'git' && "${2:-}" == '--help' ]]; then
|
if [[ "${1:-}" == 'git' && "${2:-}" == '--help' ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
if [[ " $* " != *" --redact=100 "* ]]; then
|
||||||
|
echo 'secret scans must redact reports and logs' >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
output=''
|
output=''
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
if [[ "$1" == '--report-path' ]]; then
|
if [[ "$1" == '--report-path' ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import runpy
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
CHECK = runpy.run_path(str(META_ROOT / "tools/checks/check-webui-package-facades.py"))
|
||||||
|
|
||||||
|
|
||||||
|
class WebuiPackageFacadeTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temporary = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temporary.cleanup)
|
||||||
|
self.repository = Path(self.temporary.name) / "govoplan-example"
|
||||||
|
source = self.repository / "webui/src"
|
||||||
|
source.mkdir(parents=True)
|
||||||
|
(source / "index.ts").write_text("export {};\n", encoding="utf-8")
|
||||||
|
(source / "styles.css").write_text(":root {}\n", encoding="utf-8")
|
||||||
|
self.webui = {
|
||||||
|
"name": "@govoplan/example-webui", "version": "0.1.2", "type": "module",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"exports": {".": {"import": "./src/index.ts"}, "./styles.css": "./src/styles.css"},
|
||||||
|
"peerDependencies": {"@govoplan/core-webui": "^0.1.45"},
|
||||||
|
"peerDependenciesMeta": {"@govoplan/core-webui": {"optional": True}},
|
||||||
|
}
|
||||||
|
self.root = {**self.webui, **{
|
||||||
|
field: CHECK["prefixed_entries"](self.webui[field])
|
||||||
|
for field in CHECK["ENTRY_FIELDS"] if field in self.webui
|
||||||
|
}}
|
||||||
|
self.write_manifests()
|
||||||
|
|
||||||
|
def write_manifests(self) -> None:
|
||||||
|
(self.repository / "webui/package.json").write_text(json.dumps(self.webui), encoding="utf-8")
|
||||||
|
(self.repository / "package.json").write_text(json.dumps(self.root), encoding="utf-8")
|
||||||
|
|
||||||
|
def issues(self) -> list[str]:
|
||||||
|
return CHECK["facade_issues"](self.repository, package_name="@govoplan/example-webui")
|
||||||
|
|
||||||
|
def test_matching_conditional_and_css_entries_are_accepted(self) -> None:
|
||||||
|
self.assertEqual([], self.issues())
|
||||||
|
|
||||||
|
def test_missing_root_or_generic_package_is_rejected(self) -> None:
|
||||||
|
(self.repository / "package.json").unlink()
|
||||||
|
self.assertIn("cannot read", " ".join(self.issues()))
|
||||||
|
self.root = {"name": "@govoplan/example", "version": "0.1.2"}
|
||||||
|
self.write_manifests()
|
||||||
|
self.assertIn("root name differs", " ".join(self.issues()))
|
||||||
|
self.assertIn("no WebUI entry point", " ".join(self.issues()))
|
||||||
|
|
||||||
|
def test_peer_drift_or_missing_entry_is_rejected(self) -> None:
|
||||||
|
self.root["peerDependencies"] = {"@govoplan/core-webui": "^9.0.0"}
|
||||||
|
self.write_manifests()
|
||||||
|
self.assertIn("peerDependencies differs", " ".join(self.issues()))
|
||||||
|
(self.repository / "webui/src/styles.css").unlink()
|
||||||
|
self.assertIn("missing exports entry", " ".join(self.issues()))
|
||||||
|
|
||||||
|
def test_entry_cannot_escape_webui_even_if_it_exists(self) -> None:
|
||||||
|
(self.repository / "outside.ts").write_text("export {};\n", encoding="utf-8")
|
||||||
|
self.root["main"] = "webui/../outside.ts"
|
||||||
|
self.write_manifests()
|
||||||
|
self.assertIn("escapes webui/", " ".join(self.issues()))
|
||||||
|
|
||||||
|
def test_release_composition_checks_only_declared_module_sources(self) -> None:
|
||||||
|
core = self.repository.parent / "govoplan-core/webui"
|
||||||
|
core.mkdir(parents=True)
|
||||||
|
(core / "package.release.json").write_text(json.dumps({"dependencies": {
|
||||||
|
"react": "19.2.7",
|
||||||
|
"@govoplan/example-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-example.git#v0.1.2",
|
||||||
|
}}), encoding="utf-8")
|
||||||
|
self.assertEqual((1, []), CHECK["check_composition"](self.repository.parent))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
INSTALLER = META_ROOT / "tools" / "release" / "install-webui-release-dependencies.sh"
|
||||||
|
STAGES = ("base", "clone", "modules")
|
||||||
|
|
||||||
|
|
||||||
|
class WebUIReleaseDependencyRetryTests(unittest.TestCase):
|
||||||
|
def _run_installer(
|
||||||
|
self, stage: str, statuses: tuple[int, ...]
|
||||||
|
) -> tuple[subprocess.CompletedProcess[str], list[str]]:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="govoplan-installer-retry-test-") as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
stub_bin = root / "bin"
|
||||||
|
stub_bin.mkdir()
|
||||||
|
core_root = root / "core"
|
||||||
|
webui = core_root / "web ui"
|
||||||
|
webui.mkdir(parents=True)
|
||||||
|
work_root = root / "work"
|
||||||
|
work_root.mkdir()
|
||||||
|
log = root / "commands.log"
|
||||||
|
stub = "#!/usr/bin/env bash\nset -euo pipefail\n" + textwrap.dedent(
|
||||||
|
r"""
|
||||||
|
case "${0##*/}" in
|
||||||
|
node)
|
||||||
|
# Supply the shell's dependency list without requiring Node.
|
||||||
|
printf '%s\t%s\n' '@govoplan/example-webui' \
|
||||||
|
'git+https://example.invalid/module.git#v1.0.0' > "$GOVOPLAN_DEPS"
|
||||||
|
printf 'node\n' >> "$RETRY_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
sleep)
|
||||||
|
printf 'sleep %s\n' "$*" >> "$RETRY_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
npm)
|
||||||
|
case "${1:-}" in
|
||||||
|
cache)
|
||||||
|
printf 'cache\n' >> "$RETRY_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
install)
|
||||||
|
stage=base
|
||||||
|
for argument in "$@"; do
|
||||||
|
if [[ "$argument" == --no-save ]]; then
|
||||||
|
stage=modules
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
;;
|
||||||
|
*) exit 98 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
git)
|
||||||
|
[[ "${1:-}" == clone ]] || exit 98
|
||||||
|
stage=clone
|
||||||
|
;;
|
||||||
|
*) exit 98 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
status=0
|
||||||
|
if [[ "$stage" == "$RETRY_TEST_STAGE" ]]; then
|
||||||
|
attempt=0
|
||||||
|
counter="$RETRY_TEST_ROOT/$stage.count"
|
||||||
|
if [[ -f "$counter" ]]; then
|
||||||
|
read -r attempt < "$counter"
|
||||||
|
fi
|
||||||
|
read -r -a statuses <<< "$RETRY_TEST_STATUSES"
|
||||||
|
status="${statuses[$attempt]:-99}"
|
||||||
|
printf '%s\n' "$((attempt + 1))" > "$counter"
|
||||||
|
fi
|
||||||
|
printf '%s %s\n' "$stage" "$status" >> "$RETRY_TEST_LOG"
|
||||||
|
exit "$status"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for name in ("node", "npm", "git", "sleep"):
|
||||||
|
executable = stub_bin / name
|
||||||
|
executable.write_text(stub, encoding="utf-8")
|
||||||
|
executable.chmod(0o755)
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update(
|
||||||
|
{
|
||||||
|
"PATH": f"{stub_bin}:{os.defpath}",
|
||||||
|
"TMPDIR": str(work_root),
|
||||||
|
"GOVOPLAN_CORE_ROOT": str(core_root),
|
||||||
|
"GOVOPLAN_WEBUI_PACKAGE_LOCK": "",
|
||||||
|
"GOVOPLAN_WEBUI_PACKAGE_DIR": "",
|
||||||
|
"RETRY_TEST_ROOT": str(root),
|
||||||
|
"RETRY_TEST_LOG": str(log),
|
||||||
|
"RETRY_TEST_STAGE": stage,
|
||||||
|
"RETRY_TEST_STATUSES": " ".join(map(str, statuses)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
'set -euo pipefail; bash "$1" "$2"; '
|
||||||
|
'printf "caller-continued\\n" >> "$RETRY_TEST_LOG"',
|
||||||
|
"retry-test-caller",
|
||||||
|
str(INSTALLER),
|
||||||
|
str(webui),
|
||||||
|
],
|
||||||
|
cwd=root,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=10,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(list(work_root.iterdir()), [], result.stderr)
|
||||||
|
return result, log.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
def _assert_attempts(self, statuses: tuple[int, ...]) -> None:
|
||||||
|
for retried_stage in STAGES:
|
||||||
|
with self.subTest(stage=retried_stage, statuses=statuses):
|
||||||
|
result, commands = self._run_installer(retried_stage, statuses)
|
||||||
|
expected = ["node", "cache"]
|
||||||
|
for stage in STAGES:
|
||||||
|
attempts = statuses if stage == retried_stage else (0,)
|
||||||
|
for index, status in enumerate(attempts):
|
||||||
|
expected.append(f"{stage} {status}")
|
||||||
|
if status and index < 2:
|
||||||
|
expected.append(f"sleep {(index + 1) * 10}")
|
||||||
|
if attempts[-1]:
|
||||||
|
break
|
||||||
|
if statuses[-1] == 0:
|
||||||
|
expected.append("caller-continued")
|
||||||
|
self.assertEqual(result.returncode, statuses[-1], result.stderr)
|
||||||
|
self.assertEqual(commands, expected, result.stderr)
|
||||||
|
|
||||||
|
def test_success_on_first_attempt(self) -> None:
|
||||||
|
self._assert_attempts((0,))
|
||||||
|
|
||||||
|
def test_success_on_second_attempt(self) -> None:
|
||||||
|
self._assert_attempts((17, 0))
|
||||||
|
|
||||||
|
def test_success_on_third_attempt(self) -> None:
|
||||||
|
self._assert_attempts((17, 23, 0))
|
||||||
|
|
||||||
|
def test_exhaustion_preserves_final_status_and_stops_callers(self) -> None:
|
||||||
|
self._assert_attempts((17, 23, 47))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the human capability-fit report from its machine-readable input."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments"
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT):
|
||||||
|
if str(tools_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(tools_root))
|
||||||
|
|
||||||
|
from govoplan_assessment.report_generator import ( # noqa: E402
|
||||||
|
AssessmentGenerationError,
|
||||||
|
load_bounded_json,
|
||||||
|
render_assessment_markdown,
|
||||||
|
validate_report_input,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ASSESSMENT = META_ROOT / "docs" / "capability-fit-current.json"
|
||||||
|
DEFAULT_SCHEMA = META_ROOT / "docs" / "capability-fit.schema.json"
|
||||||
|
DEFAULT_OUTPUT = (
|
||||||
|
META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "evidence"
|
||||||
|
/ "snapshots"
|
||||||
|
/ "CAPABILITY_AND_INFRASTRUCTURE_FIT.generated.md"
|
||||||
|
)
|
||||||
|
MAX_OUTPUT_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Render a deterministic human report from one capability-fit JSON input."
|
||||||
|
)
|
||||||
|
parser.add_argument("--assessment", type=Path, default=DEFAULT_ASSESSMENT)
|
||||||
|
parser.add_argument("--schema", type=Path, default=DEFAULT_SCHEMA)
|
||||||
|
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check",
|
||||||
|
action="store_true",
|
||||||
|
help="Fail when the output is missing or differs instead of writing it.",
|
||||||
|
)
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
try:
|
||||||
|
assessment = load_bounded_json(args.assessment, label="assessment")
|
||||||
|
schema = load_bounded_json(args.schema, label="assessment schema")
|
||||||
|
validate_report_input(assessment=assessment, schema=schema)
|
||||||
|
rendered = render_assessment_markdown(assessment)
|
||||||
|
encoded = rendered.encode("utf-8")
|
||||||
|
if len(encoded) > MAX_OUTPUT_BYTES:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Generated report exceeds the {MAX_OUTPUT_BYTES}-byte output limit"
|
||||||
|
)
|
||||||
|
if args.check:
|
||||||
|
try:
|
||||||
|
current = args.output.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
current = None
|
||||||
|
if current != encoded:
|
||||||
|
print(
|
||||||
|
f"Capability-fit report is stale: {args.output}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
print(f"Capability-fit report is current: {args.output}")
|
||||||
|
return 0
|
||||||
|
_atomic_write(args.output, encoded)
|
||||||
|
print(f"Generated capability-fit report: {args.output}")
|
||||||
|
return 0
|
||||||
|
except AssessmentGenerationError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _atomic_write(path: Path, content: bytes) -> None:
|
||||||
|
if not path.parent.is_dir():
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Output parent directory does not exist: {path.parent}"
|
||||||
|
)
|
||||||
|
if path.is_symlink():
|
||||||
|
raise AssessmentGenerationError("Output path must not be a symbolic link")
|
||||||
|
descriptor = -1
|
||||||
|
temporary_name = ""
|
||||||
|
try:
|
||||||
|
descriptor, temporary_name = tempfile.mkstemp(
|
||||||
|
prefix=".govoplan-fit-report-",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=path.parent,
|
||||||
|
)
|
||||||
|
os.fchmod(descriptor, 0o644)
|
||||||
|
with os.fdopen(descriptor, "wb", closefd=True) as handle:
|
||||||
|
descriptor = -1
|
||||||
|
handle.write(content)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temporary_name, path)
|
||||||
|
temporary_name = ""
|
||||||
|
except OSError as exc:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Could not write generated report atomically: {exc}"
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
if descriptor >= 0:
|
||||||
|
os.close(descriptor)
|
||||||
|
if temporary_name:
|
||||||
|
try:
|
||||||
|
os.unlink(temporary_name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
"""Deterministically render one validated capability-fit assessment as Markdown."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
from jsonschema.exceptions import SchemaError
|
||||||
|
|
||||||
|
|
||||||
|
MAX_ASSESSMENT_BYTES = 16 * 1024 * 1024
|
||||||
|
STATUS_DEFINITIONS = (
|
||||||
|
(
|
||||||
|
"verified",
|
||||||
|
"Implemented and directly exercised by evidence appropriate to the stated scope.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"available_unconfigured",
|
||||||
|
"Implemented with supporting evidence, but not configured and exercised in the target.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"partial",
|
||||||
|
"A useful subset exists, but a material part of the requirement is missing or unproved.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"scaffold",
|
||||||
|
"Contracts or structure exist, but the end-to-end capability is not usable.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"external_system",
|
||||||
|
"The deployment or another system must supply the capability.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"planned",
|
||||||
|
"Only a concept, backlog item, or design direction exists.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"not_fit",
|
||||||
|
"Evidence shows that the assessed composition cannot meet the requirement.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"not_assessed",
|
||||||
|
"The requirement or target environment is not sufficiently known.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AssessmentGenerationError(ValueError):
|
||||||
|
"""The assessment cannot be safely validated or rendered."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_bounded_json(path: Path, *, label: str) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
size = path.stat().st_size
|
||||||
|
except OSError as exc:
|
||||||
|
raise AssessmentGenerationError(f"Could not inspect {label}: {exc}") from exc
|
||||||
|
if size > MAX_ASSESSMENT_BYTES:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"{label} exceeds the {MAX_ASSESSMENT_BYTES}-byte input limit"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = json.loads(
|
||||||
|
path.read_text(encoding="utf-8"),
|
||||||
|
object_pairs_hook=_unique_object,
|
||||||
|
)
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise AssessmentGenerationError(f"Could not read {label}: {exc}") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AssessmentGenerationError(f"{label} must contain one JSON object")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def validate_report_input(
|
||||||
|
*,
|
||||||
|
assessment: Mapping[str, Any],
|
||||||
|
schema: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
except SchemaError as exc:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Assessment schema is invalid: {exc.message}"
|
||||||
|
) from exc
|
||||||
|
errors = sorted(
|
||||||
|
Draft202012Validator(
|
||||||
|
schema,
|
||||||
|
format_checker=FormatChecker(),
|
||||||
|
).iter_errors(assessment),
|
||||||
|
key=lambda item: tuple(str(part) for part in item.absolute_path),
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
details = "; ".join(
|
||||||
|
f"{_json_path(error.absolute_path)}: {error.message}"
|
||||||
|
for error in errors[:20]
|
||||||
|
)
|
||||||
|
raise AssessmentGenerationError(f"Assessment does not match schema: {details}")
|
||||||
|
_validate_references(assessment)
|
||||||
|
_reject_sensitive_keys(assessment)
|
||||||
|
|
||||||
|
|
||||||
|
def render_assessment_markdown(assessment: Mapping[str, Any]) -> str:
|
||||||
|
"""Return stable Markdown derived only from a validated assessment object."""
|
||||||
|
|
||||||
|
assessment_hash = hashlib.sha256(
|
||||||
|
json.dumps(
|
||||||
|
assessment,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
ensure_ascii=True,
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
scope = _mapping(assessment["scope"])
|
||||||
|
release = _mapping(assessment["release"])
|
||||||
|
profile = _mapping(assessment["deployment_profile"])
|
||||||
|
lines = [
|
||||||
|
"# GovOPlaN Capability and IT-Infrastructure Fit Assessment",
|
||||||
|
"",
|
||||||
|
"> Generated from [`capability-fit-current.json`](../../capability-fit-current.json).",
|
||||||
|
"> Edit and validate the machine-readable assessment, then regenerate this file;",
|
||||||
|
"> do not maintain conclusions independently in Markdown.",
|
||||||
|
"",
|
||||||
|
"This is an evidence-based fit assessment, not a production approval or",
|
||||||
|
"security certification. Repository or manifest existence alone never counts",
|
||||||
|
"as an implemented capability. Unknown target requirements remain explicitly",
|
||||||
|
"`not_assessed`.",
|
||||||
|
"",
|
||||||
|
"## Assessment record",
|
||||||
|
"",
|
||||||
|
"| Field | Value |",
|
||||||
|
"| --- | --- |",
|
||||||
|
f"| Assessment ID | `{_cell(assessment['assessment_id'])}` |",
|
||||||
|
f"| Schema version | `govoplan.fit-assessment/{_cell(assessment['schema_version'])}` |",
|
||||||
|
f"| Assessed on | {_cell(assessment['assessed_at'])} |",
|
||||||
|
f"| Scope | {_cell(scope['title'])} |",
|
||||||
|
f"| Release | `{_cell(release['ref'])}` ({_cell(release['kind'])}) |",
|
||||||
|
f"| Meta commit | `{_cell(release['meta_commit'])}` |",
|
||||||
|
f"| Deployment profile | `{_cell(profile['id'])}` · `{_cell(profile['status'])}` |",
|
||||||
|
f"| Configuration packages | {_inline_list(release['configuration_packages'], code=True)} |",
|
||||||
|
f"| Canonical input SHA-256 | `{assessment_hash}` |",
|
||||||
|
"",
|
||||||
|
"## Controlled status vocabulary",
|
||||||
|
"",
|
||||||
|
"| Status | Meaning |",
|
||||||
|
"| --- | --- |",
|
||||||
|
]
|
||||||
|
lines.extend(
|
||||||
|
f"| `{status}` | {_cell(description)} |"
|
||||||
|
for status, description in STATUS_DEFINITIONS
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Scope and reference journeys",
|
||||||
|
"",
|
||||||
|
"Reference journeys:",
|
||||||
|
"",
|
||||||
|
*_bullets(scope["reference_journeys"]),
|
||||||
|
"",
|
||||||
|
"Explicitly postponed:",
|
||||||
|
"",
|
||||||
|
*_bullets(scope["postponed"]),
|
||||||
|
"",
|
||||||
|
"## Facts",
|
||||||
|
"",
|
||||||
|
*_bullets(assessment["facts"]),
|
||||||
|
"",
|
||||||
|
"## Decisions",
|
||||||
|
"",
|
||||||
|
*_bullets(assessment["decisions"]),
|
||||||
|
"",
|
||||||
|
"## Assumptions",
|
||||||
|
"",
|
||||||
|
*_bullets(assessment["assumptions"]),
|
||||||
|
"",
|
||||||
|
"## Unresolved decisions",
|
||||||
|
"",
|
||||||
|
*_bullets(assessment["open_questions"]),
|
||||||
|
"",
|
||||||
|
"## Pinned release and composition",
|
||||||
|
"",
|
||||||
|
f"Release reproducible: **{'yes' if release['reproducible'] else 'no'}**.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(_notes(release.get("notes", [])))
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"| Module | Repository and commit | Manifest version | Enabled | Role |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for module_value in assessment["composition"]:
|
||||||
|
module = _mapping(module_value)
|
||||||
|
lines.append(
|
||||||
|
"| `{}` | `{}` @ `{}` | `{}` | {} | {} |".format(
|
||||||
|
_cell(module["module_id"]),
|
||||||
|
_cell(module["repository"]),
|
||||||
|
_cell(module["commit"]),
|
||||||
|
_cell(module["manifest_version"]),
|
||||||
|
"yes" if module["enabled"] else "no",
|
||||||
|
_cell(module["role"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Deployment profile",
|
||||||
|
"",
|
||||||
|
f"Status: `{_cell(profile['status'])}`",
|
||||||
|
"",
|
||||||
|
_text(profile["description"]),
|
||||||
|
"",
|
||||||
|
"Evidence:",
|
||||||
|
"",
|
||||||
|
*_bullets(_evidence_labels(profile["evidence"])),
|
||||||
|
"",
|
||||||
|
"## Recommended scenarios",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for scenario_value in assessment["scenarios"]:
|
||||||
|
scenario = _mapping(scenario_value)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
f"### {_text(scenario['label'])}",
|
||||||
|
"",
|
||||||
|
f"Status: `{_cell(scenario['status'])}`",
|
||||||
|
"",
|
||||||
|
_text(scenario["recommendation"]),
|
||||||
|
"",
|
||||||
|
f"Composition: {_inline_list(scenario['composition'], code=True)}.",
|
||||||
|
"",
|
||||||
|
"Topology:",
|
||||||
|
"",
|
||||||
|
*_bullets(scenario["topology"]),
|
||||||
|
"",
|
||||||
|
"Conditions:",
|
||||||
|
"",
|
||||||
|
*_bullets(scenario["conditions"]),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
functional_context = _mapping(assessment["functional_context"])
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## Functional matrix context",
|
||||||
|
"",
|
||||||
|
"### Required modules",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["required_modules"]),
|
||||||
|
"",
|
||||||
|
"### Optional modules",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["optional_modules"]),
|
||||||
|
"",
|
||||||
|
"### External systems and connectors",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["external_systems"]),
|
||||||
|
"",
|
||||||
|
"### Missing contracts",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["missing_contracts"]),
|
||||||
|
"",
|
||||||
|
"### Policy decisions",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["policy_decisions"]),
|
||||||
|
"",
|
||||||
|
"### Manual workarounds",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["manual_workarounds"]),
|
||||||
|
"",
|
||||||
|
"### Blockers",
|
||||||
|
"",
|
||||||
|
*_bullets(functional_context["blockers"]),
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## Assessment questionnaire",
|
||||||
|
"",
|
||||||
|
"Every required area remains visible even when its target answer is unknown.",
|
||||||
|
"",
|
||||||
|
"| Area | Question | State | Answer | Evidence |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
questionnaire = _mapping(assessment["questionnaire"])
|
||||||
|
for area, answers in questionnaire.items():
|
||||||
|
for answer_value in _sequence(answers):
|
||||||
|
answer = _mapping(answer_value)
|
||||||
|
raw_answer = answer["answer"]
|
||||||
|
answer_text = (
|
||||||
|
_inline_list(raw_answer)
|
||||||
|
if isinstance(raw_answer, list)
|
||||||
|
else _text(raw_answer) if raw_answer is not None else "—"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
"| {} | {} | `{}` | {} | {} |".format(
|
||||||
|
_cell(area.replace("_", " ").title()),
|
||||||
|
_cell(answer["question"]),
|
||||||
|
_cell(answer["state"]),
|
||||||
|
_cell(answer_text),
|
||||||
|
_cell("; ".join(_evidence_labels(answer["evidence"])) or "—"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.extend(_assessed_matrix("Functional capability matrix", assessment["capabilities"]))
|
||||||
|
lines.extend(_assessed_matrix("Infrastructure matrix", assessment["infrastructure"]))
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"## Data flows and trust boundaries",
|
||||||
|
"",
|
||||||
|
"| Flow | From → to | Data | Trust boundary | Controls |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for flow_value in assessment["data_flows"]:
|
||||||
|
flow = _mapping(flow_value)
|
||||||
|
lines.append(
|
||||||
|
"| `{}` | {} → {} | {} | {} | {} |".format(
|
||||||
|
_cell(flow["id"]),
|
||||||
|
_cell(flow["from"]),
|
||||||
|
_cell(flow["to"]),
|
||||||
|
_cell(_inline_list(flow["data"])),
|
||||||
|
_cell(flow["trust_boundary"]),
|
||||||
|
_cell(_inline_list(flow["controls"])),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Risks and residual risks",
|
||||||
|
"",
|
||||||
|
"| Risk | Impact | Treatment | Owner | Residual risk |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
for risk_value in assessment["risks"]:
|
||||||
|
risk = _mapping(risk_value)
|
||||||
|
lines.append(
|
||||||
|
"| **{}**<br>{} | {} | {} | {} | {} |".format(
|
||||||
|
_cell(risk["id"]),
|
||||||
|
_cell(risk["statement"]),
|
||||||
|
_cell(risk["impact"]),
|
||||||
|
_cell(risk["treatment"]),
|
||||||
|
_cell(risk["owner"] or "unassigned"),
|
||||||
|
_cell(risk["residual_risk"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Recommendations",
|
||||||
|
"",
|
||||||
|
*_bullets(assessment["recommendations"]),
|
||||||
|
"",
|
||||||
|
"## Proof-of-concept and promotion checks",
|
||||||
|
"",
|
||||||
|
*_numbered(assessment["proof_checks"]),
|
||||||
|
"",
|
||||||
|
"## Generation contract",
|
||||||
|
"",
|
||||||
|
"This report is deterministic output from the schema-validated JSON companion.",
|
||||||
|
"The generator rejects duplicate JSON keys, schema drift, secret-bearing field",
|
||||||
|
"names, stale checked-in output, and oversized inputs. A new assessment or",
|
||||||
|
"release changes the canonical input hash and requires review of the affected",
|
||||||
|
"evidence and conclusions through the release-aware reassessment tool.",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _assessed_matrix(title: str, values: object) -> list[str]:
|
||||||
|
lines = [
|
||||||
|
"",
|
||||||
|
f"## {title}",
|
||||||
|
"",
|
||||||
|
"| Requirement | Status | Evidence | Conditions and gaps | Recommendation and proof |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for item_value in _sequence(values):
|
||||||
|
item = _mapping(item_value)
|
||||||
|
conditions = [f"Condition: {value}" for value in item["conditions"]]
|
||||||
|
gaps = [f"Gap: {value}" for value in item["gaps"]]
|
||||||
|
risks = [f"Risk: {value}" for value in item["risks"]]
|
||||||
|
lines.append(
|
||||||
|
"| **{}**<br>{} | `{}` | {} | {} | {}<br>**Proof:** {} |".format(
|
||||||
|
_cell(item["id"]),
|
||||||
|
_cell(item["requirement"]),
|
||||||
|
_cell(item["status"]),
|
||||||
|
_cell("; ".join(_evidence_labels(item["evidence"])) or "Explicit absence of evidence"),
|
||||||
|
_cell("; ".join([*conditions, *gaps, *risks]) or "—"),
|
||||||
|
_cell(item["recommendation"] or "—"),
|
||||||
|
_cell(item["proof_check"] or "—"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _evidence_labels(values: object) -> list[str]:
|
||||||
|
labels: list[str] = []
|
||||||
|
for value in _sequence(values):
|
||||||
|
item = _mapping(value)
|
||||||
|
label = f"{item['kind']}/{item['scope']}: {item['locator']}"
|
||||||
|
if item.get("note"):
|
||||||
|
label += f" ({item['note']})"
|
||||||
|
labels.append(label)
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise AssessmentGenerationError(f"Duplicate JSON key: {key!r}")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_sensitive_keys(value: object, path: tuple[str, ...] = ()) -> None:
|
||||||
|
forbidden = {
|
||||||
|
"access_token",
|
||||||
|
"api_key",
|
||||||
|
"credential_value",
|
||||||
|
"password",
|
||||||
|
"private_key",
|
||||||
|
"refresh_token",
|
||||||
|
"secret",
|
||||||
|
}
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
for key, nested in value.items():
|
||||||
|
normalized = str(key).strip().casefold()
|
||||||
|
if normalized in forbidden:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Assessment contains forbidden sensitive field {_json_path((*path, str(key)))}"
|
||||||
|
)
|
||||||
|
_reject_sensitive_keys(nested, (*path, str(key)))
|
||||||
|
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||||
|
for index, nested in enumerate(value):
|
||||||
|
_reject_sensitive_keys(nested, (*path, str(index)))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_references(assessment: Mapping[str, Any]) -> None:
|
||||||
|
composition = [_mapping(item) for item in _sequence(assessment["composition"])]
|
||||||
|
module_ids = [str(item["module_id"]) for item in composition]
|
||||||
|
if len(module_ids) != len(set(module_ids)):
|
||||||
|
raise AssessmentGenerationError("Composition contains duplicate module IDs")
|
||||||
|
modules = {str(item["module_id"]): item for item in composition}
|
||||||
|
context = _mapping(assessment["functional_context"])
|
||||||
|
required = {str(item) for item in _sequence(context["required_modules"])}
|
||||||
|
optional = {str(item) for item in _sequence(context["optional_modules"])}
|
||||||
|
unknown_context = (required | optional) - set(modules)
|
||||||
|
if unknown_context:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
"Functional context references unknown modules: "
|
||||||
|
+ ", ".join(sorted(unknown_context))
|
||||||
|
)
|
||||||
|
if required & optional:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
"Functional context cannot mark a module both required and optional"
|
||||||
|
)
|
||||||
|
for scenario_value in _sequence(assessment["scenarios"]):
|
||||||
|
scenario = _mapping(scenario_value)
|
||||||
|
referenced = {str(item) for item in _sequence(scenario["composition"])}
|
||||||
|
unknown = referenced - set(modules)
|
||||||
|
if unknown:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Scenario {scenario['id']!r} references unknown modules: "
|
||||||
|
+ ", ".join(sorted(unknown))
|
||||||
|
)
|
||||||
|
disabled = sorted(
|
||||||
|
module_id
|
||||||
|
for module_id in referenced
|
||||||
|
if not bool(modules[module_id]["enabled"])
|
||||||
|
)
|
||||||
|
if disabled:
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Scenario {scenario['id']!r} references disabled modules: "
|
||||||
|
+ ", ".join(disabled)
|
||||||
|
)
|
||||||
|
for collection in ("capabilities", "infrastructure", "data_flows", "risks"):
|
||||||
|
identifiers = [
|
||||||
|
str(_mapping(item)["id"])
|
||||||
|
for item in _sequence(assessment[collection])
|
||||||
|
]
|
||||||
|
if len(identifiers) != len(set(identifiers)):
|
||||||
|
raise AssessmentGenerationError(
|
||||||
|
f"Assessment contains duplicate {collection} IDs"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise AssessmentGenerationError("Validated assessment contains a non-object value")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sequence(value: object) -> Sequence[Any]:
|
||||||
|
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||||
|
raise AssessmentGenerationError("Validated assessment contains a non-list value")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _text(value: object) -> str:
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _cell(value: object) -> str:
|
||||||
|
return _text(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _inline_list(values: object, *, code: bool = False) -> str:
|
||||||
|
items = [_text(item) for item in _sequence(values)]
|
||||||
|
if not items:
|
||||||
|
return "none"
|
||||||
|
if code:
|
||||||
|
return ", ".join(f"`{_cell(item)}`" for item in items)
|
||||||
|
return "; ".join(items)
|
||||||
|
|
||||||
|
|
||||||
|
def _bullets(values: object) -> list[str]:
|
||||||
|
items = [_text(item) for item in _sequence(values)]
|
||||||
|
return [f"- {item}" for item in items] or ["- None recorded."]
|
||||||
|
|
||||||
|
|
||||||
|
def _numbered(values: object) -> list[str]:
|
||||||
|
return [f"{index}. {_text(item)}" for index, item in enumerate(_sequence(values), 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _notes(values: object) -> list[str]:
|
||||||
|
items = _bullets(values)
|
||||||
|
return ["Release notes:", "", *items]
|
||||||
|
|
||||||
|
|
||||||
|
def _json_path(parts: Iterable[object]) -> str:
|
||||||
|
suffix = "".join(f"[{part}]" if str(part).isdigit() else f".{part}" for part in parts)
|
||||||
|
return f"${suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"AssessmentGenerationError",
|
||||||
|
"MAX_ASSESSMENT_BYTES",
|
||||||
|
"load_bounded_json",
|
||||||
|
"render_assessment_markdown",
|
||||||
|
"validate_report_input",
|
||||||
|
)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Require DSAR coverage or a reviewed no-store rationale for every module."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
EXEMPTIONS_PATH = Path(__file__).with_name("dsar-coverage-exemptions.json")
|
||||||
|
REPORT_PATH = (
|
||||||
|
META_ROOT
|
||||||
|
/ "docs"
|
||||||
|
/ "evidence"
|
||||||
|
/ "snapshots"
|
||||||
|
/ "DSAR_PROVIDER_COVERAGE.generated.md"
|
||||||
|
)
|
||||||
|
MODULE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
|
||||||
|
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CoverageRow:
|
||||||
|
module_id: str
|
||||||
|
repository: str
|
||||||
|
migration_owned: bool
|
||||||
|
capability: str | None
|
||||||
|
rationale: str
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"--workspace-root",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Directory containing GovOPlaN repositories.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--render",
|
||||||
|
action="store_true",
|
||||||
|
help="Print the current matrix instead of comparing the checked-in report.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
|
workspace_root = (args.workspace_root or Path(catalog["default_parent"])).resolve()
|
||||||
|
exemptions = _exemptions()
|
||||||
|
manifests, load_errors = _load_manifests(
|
||||||
|
workspace_root=workspace_root,
|
||||||
|
repositories=tuple(catalog["repositories"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
errors = list(load_errors)
|
||||||
|
rows: list[CoverageRow] = []
|
||||||
|
manifest_ids = {manifest.id for _, manifest in manifests}
|
||||||
|
stale_exemptions = sorted(set(exemptions) - manifest_ids)
|
||||||
|
if stale_exemptions:
|
||||||
|
errors.append(
|
||||||
|
"DSAR coverage exemptions reference unknown modules: "
|
||||||
|
+ ", ".join(stale_exemptions)
|
||||||
|
)
|
||||||
|
|
||||||
|
for repository, manifest in manifests:
|
||||||
|
expected = f"privacy.dsar.{manifest.id}"
|
||||||
|
provided = {
|
||||||
|
item.name
|
||||||
|
for item in manifest.provides_interfaces
|
||||||
|
if item.name.startswith("privacy.dsar.")
|
||||||
|
}
|
||||||
|
factories = {
|
||||||
|
name
|
||||||
|
for name in manifest.capability_factories
|
||||||
|
if name.startswith("privacy.dsar.")
|
||||||
|
}
|
||||||
|
migration_owned = manifest.migration_spec is not None
|
||||||
|
rationale = exemptions.get(manifest.id)
|
||||||
|
|
||||||
|
if provided != factories:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: DSAR interface/factory mismatch: "
|
||||||
|
f"interfaces={sorted(provided)!r}, factories={sorted(factories)!r}"
|
||||||
|
)
|
||||||
|
if provided and provided != {expected}:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: expected only {expected!r}, found {sorted(provided)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
capability = (
|
||||||
|
expected if expected in provided and expected in factories else None
|
||||||
|
)
|
||||||
|
if migration_owned and capability is None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: migration-owning module {manifest.id!r} must provide "
|
||||||
|
f"and register {expected!r}"
|
||||||
|
)
|
||||||
|
if migration_owned and rationale is not None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: migration-owning module {manifest.id!r} cannot use a "
|
||||||
|
"no-store DSAR exemption"
|
||||||
|
)
|
||||||
|
if not migration_owned and capability is None and rationale is None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: module {manifest.id!r} needs a DSAR provider or an "
|
||||||
|
"explicit reviewed no-store rationale"
|
||||||
|
)
|
||||||
|
if capability is not None and rationale is not None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: module {manifest.id!r} has both DSAR coverage and a "
|
||||||
|
"stale exemption"
|
||||||
|
)
|
||||||
|
if capability is not None:
|
||||||
|
if capability not in manifest.capability_documentation:
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: {capability!r} lacks capability documentation"
|
||||||
|
)
|
||||||
|
matching_topics = tuple(
|
||||||
|
topic
|
||||||
|
for topic in manifest.documentation
|
||||||
|
if "data-subject-request" in topic.id
|
||||||
|
)
|
||||||
|
if not matching_topics or not any(
|
||||||
|
REQUIRED_DOCUMENTATION_TYPES.issubset(topic.documentation_types)
|
||||||
|
for topic in matching_topics
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{repository}: DSAR coverage needs a static administrator "
|
||||||
|
"data-subject-requests DocumentationTopic"
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
CoverageRow(
|
||||||
|
module_id=manifest.id,
|
||||||
|
repository=repository,
|
||||||
|
migration_owned=migration_owned,
|
||||||
|
capability=capability,
|
||||||
|
rationale=(
|
||||||
|
f"Provider `{capability}` is registered and documented."
|
||||||
|
if capability
|
||||||
|
else rationale or "MISSING"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
report = _report(rows)
|
||||||
|
if args.render:
|
||||||
|
print(report, end="")
|
||||||
|
elif not REPORT_PATH.is_file():
|
||||||
|
errors.append(f"DSAR coverage report is missing: {REPORT_PATH}")
|
||||||
|
elif REPORT_PATH.read_text(encoding="utf-8") != report:
|
||||||
|
errors.append(
|
||||||
|
"DSAR coverage report is stale; review changes and replace it with "
|
||||||
|
"the output of tools/checks/check-dsar-coverage.py --render"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
provider_count = sum(row.capability is not None for row in rows)
|
||||||
|
print(
|
||||||
|
"DSAR coverage check passed: "
|
||||||
|
f"{provider_count} providers, {len(rows) - provider_count} reviewed "
|
||||||
|
f"no-store rationales, {len(rows)} active modules."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _exemptions() -> dict[str, str]:
|
||||||
|
values = json.loads(EXEMPTIONS_PATH.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(values, dict) or any(
|
||||||
|
not isinstance(key, str) or not isinstance(value, str) or not value.strip()
|
||||||
|
for key, value in values.items()
|
||||||
|
):
|
||||||
|
raise ValueError("DSAR coverage exemptions must be non-empty string mappings.")
|
||||||
|
return {key: value.strip() for key, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifests(*, workspace_root: Path, repositories: tuple[dict, ...]):
|
||||||
|
sources: list[Path] = []
|
||||||
|
candidates: list[tuple[str, Path, Path]] = []
|
||||||
|
for repository in repositories:
|
||||||
|
source = workspace_root / repository["path"] / "src"
|
||||||
|
if not source.is_dir():
|
||||||
|
continue
|
||||||
|
sources.append(source)
|
||||||
|
candidates.extend(
|
||||||
|
(repository["name"], source, path)
|
||||||
|
for path in sorted(source.glob("*/backend/manifest.py"))
|
||||||
|
)
|
||||||
|
core_source = workspace_root / "govoplan-core" / "src"
|
||||||
|
sys.path[:0] = [
|
||||||
|
str(core_source),
|
||||||
|
*(str(source) for source in sources if source != core_source),
|
||||||
|
]
|
||||||
|
|
||||||
|
manifests = []
|
||||||
|
errors = []
|
||||||
|
for repository, source, path in candidates:
|
||||||
|
module_name = ".".join(path.relative_to(source).with_suffix("").parts)
|
||||||
|
if MODULE_NAME_PATTERN.fullmatch(module_name) is None:
|
||||||
|
errors.append(f"{repository}: unsafe manifest module name {module_name!r}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
manifests.append((repository, module.get_manifest()))
|
||||||
|
except Exception as exc: # pragma: no cover - emitted as check evidence
|
||||||
|
errors.append(f"{repository}: could not load {module_name}: {exc}")
|
||||||
|
return manifests, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _report(rows: list[CoverageRow]) -> str:
|
||||||
|
ordered = sorted(rows, key=lambda row: row.module_id)
|
||||||
|
providers = sum(row.capability is not None for row in ordered)
|
||||||
|
lines = [
|
||||||
|
"# DSAR Provider Coverage",
|
||||||
|
"",
|
||||||
|
"This generated matrix is enforced by `tools/checks/check-dsar-coverage.py`.",
|
||||||
|
"A migration-owning module must register and document its canonical DSAR provider.",
|
||||||
|
"Every other active module requires a reviewed explanation of why it owns no",
|
||||||
|
"persistent subject-data store. Adding a migration invalidates that explanation.",
|
||||||
|
"",
|
||||||
|
f"- Active modules: {len(ordered)}",
|
||||||
|
f"- Registered and documented DSAR providers: {providers}",
|
||||||
|
f"- Reviewed no-store rationales: {len(ordered) - providers}",
|
||||||
|
"- Unexplained coverage gaps: 0",
|
||||||
|
"",
|
||||||
|
"| Module | Repository | Persistence | Coverage | Rationale |",
|
||||||
|
"| --- | --- | --- | --- | --- |",
|
||||||
|
]
|
||||||
|
for row in ordered:
|
||||||
|
lines.append(
|
||||||
|
"| "
|
||||||
|
+ " | ".join(
|
||||||
|
(
|
||||||
|
f"`{row.module_id}`",
|
||||||
|
f"`{row.repository}`",
|
||||||
|
"Migration-owned" if row.migration_owned else "No module migration",
|
||||||
|
"Provider" if row.capability else "Reviewed no-store rationale",
|
||||||
|
row.rationale.replace("|", "\\|"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
+ " |"
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
(
|
||||||
|
"",
|
||||||
|
"Provider search, export minimization, retention, and erasure behavior remains",
|
||||||
|
"documented and tested by each owning module. This matrix verifies adoption and",
|
||||||
|
"ownership coverage; Core continues to test disabled providers, partial failure,",
|
||||||
|
"retry, authorization evidence, and horizontally coordinated execution.",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -38,14 +38,24 @@ cd "$ROOT"
|
|||||||
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
GOVOPLAN_CORE_ROOT="$ROOT" PYTHON="$PYTHON" CHECK_TESTCLIENT_DEPRECATIONS=1 bash "$META_ROOT/tools/checks/check-dependency-hygiene.sh"
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
"$PYTHON" "$META_ROOT/tools/checks/check-contracts.py" --no-impact
|
||||||
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py" --require-architecture
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-manifest-shapes.py" --require-architecture
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 "$PYTHON" "$META_ROOT/tools/checks/check-dsar-coverage.py"
|
||||||
|
"$NODE/node" "$META_ROOT/tests/test-jsx-value-imports.mjs"
|
||||||
|
"$NODE/node" "$META_ROOT/tools/checks/check-jsx-value-imports.mjs"
|
||||||
|
"$NODE/node" "$META_ROOT/../govoplan-files/webui/scripts/test-archive-client.mjs"
|
||||||
|
|
||||||
cd "$META_ROOT"
|
cd "$META_ROOT"
|
||||||
"$PYTHON" tools/inventory/platform-interface-inventory.py --strict-declarations --strict-endpoints
|
"$PYTHON" tools/inventory/platform-interface-inventory.py --strict-declarations --strict-endpoints
|
||||||
"$PYTHON" tools/repo/sync-module-package-workflows.py --check
|
"$PYTHON" tools/repo/sync-module-package-workflows.py --check
|
||||||
"$PYTHON" tools/release/generate-developer-meta-package.py --check
|
"$PYTHON" tools/release/generate-developer-meta-package.py --check
|
||||||
|
"$PYTHON" tools/checks/check-webui-package-facades.py
|
||||||
|
"$PYTHON" -m unittest tests.test_webui_package_facades
|
||||||
"$PYTHON" -m unittest tests.test_module_package_workflows tests.test_package_registry_release
|
"$PYTHON" -m unittest tests.test_module_package_workflows tests.test_package_registry_release
|
||||||
"$PYTHON" -m unittest tests.test_deployment_installer
|
"$PYTHON" -m unittest tests.test_deployment_installer tests.test_webui_release_dependency_retries
|
||||||
|
"$PYTHON" -m pytest -q tests/test_release_meta_source_tag.py tests/test_release_source_tag_batch.py tests/test_release_meta_preparation.py
|
||||||
|
"$PYTHON" -m unittest tests.test_isolated_work_composition
|
||||||
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
"$PYTHON" -m unittest tests.test_capability_fit_evidence
|
||||||
|
"$PYTHON" -m unittest tests.test_capability_fit_generation tests.test_capability_fit_review
|
||||||
|
"$PYTHON" tools/assessments/generate-capability-fit-report.py --check
|
||||||
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
||||||
"$PYTHON" -m unittest tests.test_institutional_governance_journey
|
"$PYTHON" -m unittest tests.test_institutional_governance_journey
|
||||||
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
||||||
@@ -89,7 +99,26 @@ PY
|
|||||||
|
|
||||||
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
"$PYTHON" -c 'import govoplan_core.db.bootstrap; import govoplan_access.backend.admin.service; import govoplan_addresses.backend.manifest; import govoplan_files.backend.router; import govoplan_mail.backend.sending.imap; print("targeted backend imports passed")'
|
||||||
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-layouts.py"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-primitives.py"
|
||||||
|
"$PYTHON" "$META_ROOT/tools/checks/check-shared-webui-foundations.py"
|
||||||
"$PYTHON" -m unittest tests.test_module_system
|
"$PYTHON" -m unittest tests.test_module_system
|
||||||
|
"$PYTHON" -m unittest tests.test_bounded_process
|
||||||
|
"$PYTHON" -m unittest tests.test_ownership_history_migration tests.test_ownership tests.test_ownership_api
|
||||||
|
"$PYTHON" -m unittest tests.test_navigation_preferences tests.test_api_smoke.ApiSmokeTests.test_navigation_separator_layout_survives_system_tenant_and_personal_saves
|
||||||
|
"$PYTHON" -m pytest -q \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_managed_archives.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archive_work.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archive_staging.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_upload_response_batching.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archive_performance.py
|
||||||
|
"$PYTHON" -m pytest -q \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archive_workers.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archive_inspection_bounds.py \
|
||||||
|
/mnt/DATA/git/govoplan-files/tests/test_archives.py
|
||||||
|
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-access/tests/test_external_function_mapping_migration.py
|
||||||
|
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-access/tests
|
||||||
|
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-templates/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-connectors/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-datasources/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dataflow/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dataflow/tests
|
||||||
@@ -100,6 +129,7 @@ PY
|
|||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dashboard/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-dashboard/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-postbox/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-postbox/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-portal/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-portal/tests
|
||||||
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-payments/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms-runtime/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-forms-runtime/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-cases/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-cases/tests
|
||||||
@@ -108,16 +138,50 @@ PY
|
|||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-approvals/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-approvals/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-identity-trust/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-identity-trust/tests
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-encryption/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-encryption/tests
|
||||||
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-campaign/tests/test_approval_gate.py
|
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-wiki/tests
|
||||||
|
"$PYTHON" -m pytest -q \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_approval_gate.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_editor_state_security.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_mail_profile_boundary.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_independent_configuration_repairs.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_incremental_review_persistence.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_reviewed_build_mock.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_delivery_policy_settings.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_synchronous_delivery_policy.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_workerless_recovery.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_imap_batch_integration.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_testbed_claim_recovery.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_campaign_optimistic_concurrency.py \
|
||||||
|
/mnt/DATA/git/govoplan-campaign/tests/test_archive_encryption_governance.py \
|
||||||
|
/mnt/DATA/git/govoplan-policy/tests/test_campaign_archive_encryption.py \
|
||||||
|
/mnt/DATA/git/govoplan-policy/tests/test_archive_encryption_api.py
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check-datasource-composition.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check-datasource-composition.py"
|
||||||
"$PYTHON" "$META_ROOT/tools/checks/check-sanctions-screening-composition.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check-sanctions-screening-composition.py"
|
||||||
|
"$PYTHON" -m pytest -q /mnt/DATA/git/govoplan-mail/tests/test_campaign_protocol_authorization.py /mnt/DATA/git/govoplan-mail/tests/test_campaign_imap_batch.py
|
||||||
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
"$PYTHON" -m unittest discover -s /mnt/DATA/git/govoplan-mail/tests
|
||||||
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
"$PYTHON" -m unittest tests.test_api_smoke.ApiSmokeTests.test_mailbox_message_listing_reports_total_count
|
||||||
|
"$PYTHON" -m unittest \
|
||||||
|
tests.test_api_smoke.ApiSmokeTests.test_managed_attachment_patterns_preview_build_and_mock_send \
|
||||||
|
tests.test_api_smoke.ApiSmokeTests.test_reports_and_job_review_are_scoped_to_the_selected_version \
|
||||||
|
tests.test_api_smoke.ApiSmokeTests.test_worker_loss_becomes_unknown_and_requires_reconciliation_before_retry
|
||||||
|
|
||||||
cd "$ROOT/webui"
|
cd "$ROOT/webui"
|
||||||
|
"$NPM" run test:api-client-cache
|
||||||
|
"$NPM" run test:auth-action-state
|
||||||
|
"$NPM" run test:dependency-security
|
||||||
|
"$NPM" run test:layout-primitives
|
||||||
"$NPM" run test:mail-components
|
"$NPM" run test:mail-components
|
||||||
"$NPM" run test:module-capabilities
|
"$NPM" run test:module-capabilities
|
||||||
"$NPM" run test:module-permutations
|
"$NPM" run test:module-permutations
|
||||||
|
"$NPM" run test:conformance
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-access/webui
|
||||||
|
"$NPM" run test:passwords
|
||||||
|
|
||||||
|
"$WEBUI_BIN/tsc" -p /mnt/DATA/git/govoplan-payments/webui/tsconfig.json
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-payments/webui
|
||||||
|
"$NPM" run test:interface-pattern
|
||||||
|
|
||||||
cd /mnt/DATA/git/govoplan-dataflow/webui
|
cd /mnt/DATA/git/govoplan-dataflow/webui
|
||||||
"$NPM" run test:structure
|
"$NPM" run test:structure
|
||||||
@@ -131,12 +195,28 @@ cd /mnt/DATA/git/govoplan-workflow/webui
|
|||||||
cd /mnt/DATA/git/govoplan-dashboard/webui
|
cd /mnt/DATA/git/govoplan-dashboard/webui
|
||||||
"$NPM" run test:dashboard-layout
|
"$NPM" run test:dashboard-layout
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-approvals/webui
|
||||||
|
"$NPM" run test:workspace-layout
|
||||||
|
|
||||||
cd /mnt/DATA/git/govoplan-postbox/webui
|
cd /mnt/DATA/git/govoplan-postbox/webui
|
||||||
"$NPM" run test:ui-structure
|
"$NPM" run test:ui-structure
|
||||||
|
|
||||||
cd /mnt/DATA/git/govoplan-mail/webui
|
cd /mnt/DATA/git/govoplan-mail/webui
|
||||||
"$NPM" run test:mail-ui
|
"$NPM" run test:mail-ui
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-files/webui
|
||||||
|
"$NPM" run test:managed-archive
|
||||||
|
|
||||||
cd /mnt/DATA/git/govoplan-campaign/webui
|
cd /mnt/DATA/git/govoplan-campaign/webui
|
||||||
"$NPM" run test:policy-ui
|
"$NPM" run test:policy-ui
|
||||||
"$NPM" run test:template-preview
|
"$NPM" run test:template-preview
|
||||||
|
"$NPM" run test:review-workflow
|
||||||
|
"$NPM" run test:accessibility-contract
|
||||||
|
"$NPM" run test:campaign-collaboration
|
||||||
|
"$NPM" run test:campaign-work
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-policy/webui
|
||||||
|
"$NPM" run test:archive-encryption
|
||||||
|
|
||||||
|
cd /mnt/DATA/git/govoplan-wiki/webui
|
||||||
|
"$NPM" run test:interface-pattern
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Reject erased type-only imports used as runtime JSX component tags. */
|
||||||
|
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { resolve, relative } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const workspaceRoot = resolve(import.meta.dirname, "../../..");
|
||||||
|
const require = createRequire(resolve(workspaceRoot, "govoplan-core/webui/package.json"));
|
||||||
|
const ts = require("typescript");
|
||||||
|
|
||||||
|
function isTypeOnlyImport(declaration) {
|
||||||
|
if (ts.isImportSpecifier(declaration)) return declaration.isTypeOnly || declaration.parent.parent.isTypeOnly;
|
||||||
|
if (ts.isNamespaceImport(declaration)) return declaration.parent.isTypeOnly;
|
||||||
|
return (ts.isImportClause(declaration) || ts.isImportEqualsDeclaration(declaration)) && declaration.isTypeOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve lexical bindings, including shadowing; do not typecheck unrelated
|
||||||
|
* optional dependencies or report ordinary application diagnostics.
|
||||||
|
*/
|
||||||
|
export function findTypeOnlyJsxImports(sources) {
|
||||||
|
const files = new Map(sources.map(({ path, source }) => [resolve(path),
|
||||||
|
ts.createSourceFile(resolve(path), source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)]));
|
||||||
|
const options = { noEmit: true, noResolve: true, noLib: true, types: [], jsx: ts.JsxEmit.Preserve };
|
||||||
|
const host = ts.createCompilerHost(options);
|
||||||
|
host.getSourceFile = (path) => files.get(resolve(path));
|
||||||
|
const program = ts.createProgram([...files.keys()], options, host);
|
||||||
|
const checker = program.getTypeChecker();
|
||||||
|
const findings = [];
|
||||||
|
for (const [path, source] of files) {
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
|
||||||
|
let root = node.tagName;
|
||||||
|
// Lower-case direct tags are intrinsic HTML, not runtime bindings.
|
||||||
|
if (!(ts.isIdentifier(root) && /^[a-z]/.test(root.text))) {
|
||||||
|
while (ts.isPropertyAccessExpression(root)) root = root.expression;
|
||||||
|
const declarations = checker.getSymbolAtLocation(root)?.declarations ?? [];
|
||||||
|
if (declarations.some(isTypeOnlyImport)) {
|
||||||
|
const position = source.getLineAndCharacterOfPosition(node.tagName.getStart(source));
|
||||||
|
findings.push({ path, line: position.line + 1, column: position.character + 1, component: node.tagName.getText(source) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(source);
|
||||||
|
}
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceFiles(directory) {
|
||||||
|
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const path = resolve(directory, entry.name);
|
||||||
|
return entry.isDirectory() ? sourceFiles(path) : entry.name.endsWith(".tsx") ? [path] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkWorkspace(root = workspaceRoot) {
|
||||||
|
const modules = readdirSync(root, { withFileTypes: true })
|
||||||
|
.filter((entry) => entry.isDirectory() && entry.name.startsWith("govoplan"))
|
||||||
|
.map((entry) => resolve(root, entry.name, "webui/src")).filter(existsSync);
|
||||||
|
const paths = modules.flatMap(sourceFiles);
|
||||||
|
const findings = findTypeOnlyJsxImports(paths.map((path) => ({ path, source: readFileSync(path, "utf8") })));
|
||||||
|
for (const finding of findings) {
|
||||||
|
console.error(`${relative(root, finding.path)}:${finding.line}:${finding.column}: JSX component ${finding.component} is imported type-only and will be erased at runtime.`);
|
||||||
|
}
|
||||||
|
if (!findings.length) console.log(`JSX runtime-import contract passed: ${paths.length} TSX files across ${modules.length} WebUI modules.`);
|
||||||
|
return findings.length ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||||
|
process.exitCode = checkWorkspace();
|
||||||
|
}
|
||||||
@@ -17,6 +17,70 @@ MODULE_NAME_PATTERN = re.compile(
|
|||||||
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*"
|
||||||
)
|
)
|
||||||
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin", "user"})
|
REQUIRED_DOCUMENTATION_TYPES = frozenset({"admin", "user"})
|
||||||
|
CANONICAL_PRODUCT_AREAS = {
|
||||||
|
"work": (
|
||||||
|
"i18n:govoplan-core.product_area.work",
|
||||||
|
"list-checks",
|
||||||
|
"i18n:govoplan-core.product_area.work_description",
|
||||||
|
10,
|
||||||
|
),
|
||||||
|
"services-cases": (
|
||||||
|
"i18n:govoplan-core.product_area.services_cases",
|
||||||
|
"landmark",
|
||||||
|
"i18n:govoplan-core.product_area.services_cases_description",
|
||||||
|
20,
|
||||||
|
),
|
||||||
|
"records-documents": (
|
||||||
|
"i18n:govoplan-core.product_area.records_documents",
|
||||||
|
"folder",
|
||||||
|
"i18n:govoplan-core.product_area.records_documents_description",
|
||||||
|
30,
|
||||||
|
),
|
||||||
|
"communication": (
|
||||||
|
"i18n:govoplan-core.product_area.communication",
|
||||||
|
"mail",
|
||||||
|
"i18n:govoplan-core.product_area.communication_description",
|
||||||
|
40,
|
||||||
|
),
|
||||||
|
"meetings-decisions": (
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions",
|
||||||
|
"calendar",
|
||||||
|
"i18n:govoplan-core.product_area.meetings_decisions_description",
|
||||||
|
50,
|
||||||
|
),
|
||||||
|
"data-assurance": (
|
||||||
|
"i18n:govoplan-core.product_area.data_assurance",
|
||||||
|
"database-zap",
|
||||||
|
"i18n:govoplan-core.product_area.data_assurance_description",
|
||||||
|
60,
|
||||||
|
),
|
||||||
|
"people-responsibility": (
|
||||||
|
"i18n:govoplan-core.product_area.people_responsibility",
|
||||||
|
"users",
|
||||||
|
"i18n:govoplan-core.product_area.people_responsibility_description",
|
||||||
|
70,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
# These surfaces are intentionally global, administrative, security-policy, or
|
||||||
|
# shell infrastructure. They remain discoverable through their dedicated shell
|
||||||
|
# affordance or through "All available tools" instead of a business area.
|
||||||
|
PRODUCT_AREA_EXEMPT_MODULES = frozenset(
|
||||||
|
{
|
||||||
|
"access",
|
||||||
|
"admin",
|
||||||
|
"audit",
|
||||||
|
"dashboard",
|
||||||
|
"docs",
|
||||||
|
"encryption",
|
||||||
|
"identity_trust",
|
||||||
|
"ops",
|
||||||
|
"policy",
|
||||||
|
"quick_access",
|
||||||
|
"search",
|
||||||
|
"tenancy",
|
||||||
|
"views",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -113,6 +177,42 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
frontend = manifest.frontend
|
||||||
|
has_user_facing_surface = frontend is not None and bool(
|
||||||
|
frontend.routes
|
||||||
|
or frontend.public_routes
|
||||||
|
or frontend.nav_items
|
||||||
|
or frontend.settings_routes
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
has_user_facing_surface
|
||||||
|
and not frontend.product_areas
|
||||||
|
and manifest.id not in PRODUCT_AREA_EXEMPT_MODULES
|
||||||
|
):
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: user-facing module {manifest.id!r} has no "
|
||||||
|
"ProductAreaContribution and is not an explicit global/technical exemption"
|
||||||
|
)
|
||||||
|
if frontend is not None:
|
||||||
|
for contribution in frontend.product_areas:
|
||||||
|
expected = CANONICAL_PRODUCT_AREAS.get(contribution.id)
|
||||||
|
actual = (
|
||||||
|
contribution.label,
|
||||||
|
contribution.icon,
|
||||||
|
contribution.description,
|
||||||
|
contribution.order,
|
||||||
|
)
|
||||||
|
if expected is None:
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: module {manifest.id!r} uses unknown product "
|
||||||
|
f"area {contribution.id!r}"
|
||||||
|
)
|
||||||
|
elif actual != expected:
|
||||||
|
errors.append(
|
||||||
|
f"{repository_name}: module {manifest.id!r} redefines canonical "
|
||||||
|
f"product area {contribution.id!r}; expected {expected!r}, found {actual!r}"
|
||||||
|
)
|
||||||
|
|
||||||
repository_root = manifest_path.parents[3]
|
repository_root = manifest_path.parents[3]
|
||||||
if manifest.architecture is None:
|
if manifest.architecture is None:
|
||||||
if args.require_architecture:
|
if args.require_architecture:
|
||||||
@@ -142,6 +242,23 @@ def main() -> int:
|
|||||||
print("\n".join(errors), file=sys.stderr)
|
print("\n".join(errors), file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
contributed_product_areas = {
|
||||||
|
contribution.id
|
||||||
|
for manifest in manifests
|
||||||
|
if manifest.frontend is not None
|
||||||
|
for contribution in manifest.frontend.product_areas
|
||||||
|
}
|
||||||
|
missing_product_areas = sorted(
|
||||||
|
set(CANONICAL_PRODUCT_AREAS) - contributed_product_areas
|
||||||
|
)
|
||||||
|
if missing_product_areas:
|
||||||
|
print(
|
||||||
|
"Canonical product areas have no contributing module: "
|
||||||
|
+ ", ".join(missing_product_areas),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
registry = PlatformRegistry()
|
registry = PlatformRegistry()
|
||||||
try:
|
try:
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ cd "$ROOT"
|
|||||||
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
"$PYTHON" "$META_ROOT/tools/checks/check_dependency_boundaries.py"
|
||||||
|
|
||||||
cd "$META_ROOT"
|
cd "$META_ROOT"
|
||||||
|
"$PYTHON" -m unittest tests.test_capability_fit_generation
|
||||||
|
"$PYTHON" tools/assessments/generate-capability-fit-report.py --check
|
||||||
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
"$PYTHON" -m unittest tests.test_configuration_package_artifacts
|
||||||
PYTHONPATH="$META_ROOT/../govoplan-portal/src:$META_ROOT/../govoplan-forms/src:$META_ROOT/../govoplan-forms-runtime/src:$META_ROOT/../govoplan-cases/src:$ROOT/src${PYTHONPATH:+:$PYTHONPATH}" \
|
PYTHONPATH="$META_ROOT/../govoplan-portal/src:$META_ROOT/../govoplan-forms/src:$META_ROOT/../govoplan-forms-runtime/src:$META_ROOT/../govoplan-cases/src:$ROOT/src${PYTHONPATH:+:$PYTHONPATH}" \
|
||||||
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
"$PYTHON" -m unittest tests.test_institutional_service_journey
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ cd "$WORK_ROOT/govoplan-campaign/webui"
|
|||||||
"$NPM" run test:policy-ui
|
"$NPM" run test:policy-ui
|
||||||
"$NPM" run test:template-preview
|
"$NPM" run test:template-preview
|
||||||
"$NPM" run test:import-utils
|
"$NPM" run test:import-utils
|
||||||
|
"$NPM" run test:campaign-collaboration
|
||||||
|
"$NPM" run test:campaign-work
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "Release integration check passed."
|
echo "Release integration check passed."
|
||||||
|
|||||||
@@ -677,6 +677,7 @@ run_gitleaks() {
|
|||||||
prepare_machine_report "$REPORTS_DIR/gitleaks-history-$name.json" || return 2
|
prepare_machine_report "$REPORTS_DIR/gitleaks-history-$name.json" || return 2
|
||||||
prepare_machine_report "$REPORTS_DIR/gitleaks-worktree-$name.json" || return 2
|
prepare_machine_report "$REPORTS_DIR/gitleaks-worktree-$name.json" || return 2
|
||||||
gitleaks git \
|
gitleaks git \
|
||||||
|
--redact=100 \
|
||||||
--config "$ROOT/.gitleaks.toml" \
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
--report-format json \
|
--report-format json \
|
||||||
--report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
|
--report-path "$REPORTS_DIR/gitleaks-history-$name.json" \
|
||||||
@@ -687,6 +688,7 @@ run_gitleaks() {
|
|||||||
# Scan the directory as well so pre-commit audits cover the exact code
|
# Scan the directory as well so pre-commit audits cover the exact code
|
||||||
# under review, while retaining the history scan above.
|
# under review, while retaining the history scan above.
|
||||||
gitleaks dir \
|
gitleaks dir \
|
||||||
|
--redact=100 \
|
||||||
--config "$ROOT/.gitleaks.toml" \
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
--report-format json \
|
--report-format json \
|
||||||
--report-path "$REPORTS_DIR/gitleaks-worktree-$name.json" \
|
--report-path "$REPORTS_DIR/gitleaks-worktree-$name.json" \
|
||||||
@@ -696,6 +698,7 @@ run_gitleaks() {
|
|||||||
else
|
else
|
||||||
prepare_machine_report "$REPORTS_DIR/gitleaks-$name.json" || return 2
|
prepare_machine_report "$REPORTS_DIR/gitleaks-$name.json" || return 2
|
||||||
gitleaks detect \
|
gitleaks detect \
|
||||||
|
--redact=100 \
|
||||||
--source "$repo" \
|
--source "$repo" \
|
||||||
--config "$ROOT/.gitleaks.toml" \
|
--config "$ROOT/.gitleaks.toml" \
|
||||||
--report-format json \
|
--report-format json \
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Enforce Core ownership of WebUI visual foundations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
|
REPOS_ROOT = META_ROOT.parent
|
||||||
|
TOKENS_PATH = REPOS_ROOT / "govoplan-core/webui/src/styles/tokens.css"
|
||||||
|
|
||||||
|
RAW_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b")
|
||||||
|
RAW_COLOR_FUNCTION = re.compile(r"\b(?:rgb|rgba|hsl|hsla)\((?!\s*var\()", re.IGNORECASE)
|
||||||
|
RADIUS_DECLARATION = re.compile(r"border-radius\s*:\s*([^;}]+)")
|
||||||
|
MEDIA_MAX_WIDTH = re.compile(r"@media[^\n{]*\(max-width\s*:\s*(\d+)px\)")
|
||||||
|
|
||||||
|
RESPONSIVE_BANDS = {560, 600, 680, 760, 900, 1100, 1280}
|
||||||
|
REQUIRED_TOKENS = {
|
||||||
|
"--radius-hairline",
|
||||||
|
"--radius-tight",
|
||||||
|
"--radius-xs",
|
||||||
|
"--radius-sm",
|
||||||
|
"--radius-compact",
|
||||||
|
"--radius-md",
|
||||||
|
"--radius-lg",
|
||||||
|
"--radius-xl",
|
||||||
|
"--radius-round",
|
||||||
|
"--radius-pill",
|
||||||
|
"--shadow-drawer-side",
|
||||||
|
"--shadow-drawer-bottom",
|
||||||
|
"--action-primary-bg",
|
||||||
|
"--action-primary-border",
|
||||||
|
"--action-primary-text",
|
||||||
|
"--action-danger-bg",
|
||||||
|
"--action-danger-text",
|
||||||
|
"--badge-accent-text",
|
||||||
|
"--data-category-blue",
|
||||||
|
"--data-category-green",
|
||||||
|
"--data-category-amber",
|
||||||
|
"--data-category-purple",
|
||||||
|
"--data-category-rose",
|
||||||
|
*(f"--data-series-{index}" for index in range(1, 9)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def line_number(source: str, offset: int) -> int:
|
||||||
|
return source.count("\n", 0, offset) + 1
|
||||||
|
|
||||||
|
|
||||||
|
def css_files() -> list[pathlib.Path]:
|
||||||
|
files: list[pathlib.Path] = []
|
||||||
|
for repository in sorted(REPOS_ROOT.glob("govoplan-*")):
|
||||||
|
styles = repository / "webui/src"
|
||||||
|
if styles.is_dir():
|
||||||
|
files.extend(sorted(styles.rglob("*.css")))
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def display_path(path: pathlib.Path) -> str:
|
||||||
|
return str(path.relative_to(REPOS_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
errors: list[str] = []
|
||||||
|
tokens = TOKENS_PATH.read_text(encoding="utf-8")
|
||||||
|
for token in sorted(REQUIRED_TOKENS):
|
||||||
|
if f"{token}:" not in tokens:
|
||||||
|
errors.append(f"{display_path(TOKENS_PATH)}: missing required foundation token {token}")
|
||||||
|
|
||||||
|
files = css_files()
|
||||||
|
for path in files:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
owns_literals = path == TOKENS_PATH
|
||||||
|
|
||||||
|
if not owns_literals:
|
||||||
|
for pattern, label in (
|
||||||
|
(RAW_HEX_COLOR, "raw color"),
|
||||||
|
(RAW_COLOR_FUNCTION, "raw color function"),
|
||||||
|
):
|
||||||
|
for match in pattern.finditer(source):
|
||||||
|
errors.append(
|
||||||
|
f"{display_path(path)}:{line_number(source, match.start())}: "
|
||||||
|
f"{label} must use a Core theme token"
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in RADIUS_DECLARATION.finditer(source):
|
||||||
|
value = match.group(1).strip()
|
||||||
|
if "var(" not in value and value not in {"0", "inherit", "initial", "unset"}:
|
||||||
|
errors.append(
|
||||||
|
f"{display_path(path)}:{line_number(source, match.start())}: "
|
||||||
|
f"border radius {value!r} must use a Core radius token"
|
||||||
|
)
|
||||||
|
|
||||||
|
for match in MEDIA_MAX_WIDTH.finditer(source):
|
||||||
|
width = int(match.group(1))
|
||||||
|
if width not in RESPONSIVE_BANDS:
|
||||||
|
errors.append(
|
||||||
|
f"{display_path(path)}:{line_number(source, match.start())}: "
|
||||||
|
f"{width}px is not a shared responsive band; use one of "
|
||||||
|
f"{', '.join(f'{value}px' for value in sorted(RESPONSIVE_BANDS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\n".join(errors))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
"Shared WebUI foundation contract passed for "
|
||||||
|
f"{len(files)} stylesheets and {len(RESPONSIVE_BANDS)} responsive bands."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user