From c6c23aab8c5ff0004871b5801e163cc68c563a64 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 1 Aug 2026 17:48:39 +0200 Subject: [PATCH] feat: implement institutional service catalogue --- .gitea/ISSUE_TEMPLATE/bug_report.md | 35 ++ .gitea/ISSUE_TEMPLATE/config.yaml | 1 + .gitea/ISSUE_TEMPLATE/docs_workflow.md | 27 ++ .gitea/ISSUE_TEMPLATE/feature_request.md | 32 ++ .gitea/ISSUE_TEMPLATE/task.md | 28 ++ .gitea/ISSUE_TEMPLATE/tech_debt.md | 25 ++ .gitea/PULL_REQUEST_TEMPLATE.md | 15 + .gitignore | 353 +++++++++++++++++ README.md | 31 ++ docs/SERVICES_DOMAIN.md | 30 ++ pyproject.toml | 21 + src/govoplan_services/__init__.py | 3 + src/govoplan_services/backend/__init__.py | 1 + src/govoplan_services/backend/db/__init__.py | 3 + src/govoplan_services/backend/db/models.py | 45 +++ src/govoplan_services/backend/manifest.py | 132 +++++++ .../backend/migrations/__init__.py | 1 + .../backend/migrations/versions/__init__.py | 1 + .../b9c2d3e4f5a6_v0114_services_baseline.py | 48 +++ src/govoplan_services/backend/router.py | 123 ++++++ src/govoplan_services/backend/schemas.py | 27 ++ src/govoplan_services/backend/service.py | 360 ++++++++++++++++++ src/govoplan_services/py.typed | 1 + tests/test_services.py | 102 +++++ 24 files changed, 1445 insertions(+) create mode 100644 .gitea/ISSUE_TEMPLATE/bug_report.md create mode 100644 .gitea/ISSUE_TEMPLATE/config.yaml create mode 100644 .gitea/ISSUE_TEMPLATE/docs_workflow.md create mode 100644 .gitea/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitea/ISSUE_TEMPLATE/task.md create mode 100644 .gitea/ISSUE_TEMPLATE/tech_debt.md create mode 100644 .gitea/PULL_REQUEST_TEMPLATE.md create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/SERVICES_DOMAIN.md create mode 100644 pyproject.toml create mode 100644 src/govoplan_services/__init__.py create mode 100644 src/govoplan_services/backend/__init__.py create mode 100644 src/govoplan_services/backend/db/__init__.py create mode 100644 src/govoplan_services/backend/db/models.py create mode 100644 src/govoplan_services/backend/manifest.py create mode 100644 src/govoplan_services/backend/migrations/__init__.py create mode 100644 src/govoplan_services/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_services/backend/migrations/versions/b9c2d3e4f5a6_v0114_services_baseline.py create mode 100644 src/govoplan_services/backend/router.py create mode 100644 src/govoplan_services/backend/schemas.py create mode 100644 src/govoplan_services/backend/service.py create mode 100644 src/govoplan_services/py.typed create mode 100644 tests/test_services.py diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..cf14bd1 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: "Bug" +about: "Report a reproducible defect, regression, or incorrect behavior" +title: "[Bug] " +labels: + - type/bug + - status/triage + - module/core +--- + +## Scope + +- Repository: +- Area/module: +- Affected version or commit: + +## Behavior + +Expected: + +Actual: + +## Reproduction + +1. +2. +3. + +## Evidence + +Logs, screenshots, traces, or failing test output: + +## Verification Target + +Command or workflow that should pass when fixed: diff --git a/.gitea/ISSUE_TEMPLATE/config.yaml b/.gitea/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.gitea/ISSUE_TEMPLATE/docs_workflow.md b/.gitea/ISSUE_TEMPLATE/docs_workflow.md new file mode 100644 index 0000000..a3b0cca --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/docs_workflow.md @@ -0,0 +1,27 @@ +--- +name: "Docs / workflow" +about: "Request documentation, process, or developer workflow changes" +title: "[Docs] " +labels: + - type/docs + - status/triage + - module/core + - area/docs +--- + +## Scope + +- Repository: +- Document or workflow: + +## Current State + +What is missing, unclear, duplicated, or stale? + +## Desired State + +What should the docs or workflow make clear? + +## Verification Target + +How should this be checked? diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b286a18 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: "Feature" +about: "Propose new user-visible behavior or platform capability" +title: "[Feature] " +labels: + - type/feature + - status/triage + - module/core +--- + +## Problem + +What user, operator, or developer problem should this solve? + +## Proposed Capability + +What should exist when this is done? + +## Ownership + +- Owning repository: +- Related module repositories: +- Extension point or integration boundary: + +## Acceptance Criteria + +- [ ] +- [ ] + +## Verification Target + +Command, scenario, or UI flow that should prove completion: diff --git a/.gitea/ISSUE_TEMPLATE/task.md b/.gitea/ISSUE_TEMPLATE/task.md new file mode 100644 index 0000000..30ea442 --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/task.md @@ -0,0 +1,28 @@ +--- +name: "Task" +about: "Track implementation, maintenance, or migration work" +title: "[Task] " +labels: + - type/task + - status/triage + - module/core +--- + +## Objective + +What needs to be completed? + +## Scope + +- Owning repository: +- In-scope: +- Out-of-scope: + +## Checklist + +- [ ] +- [ ] + +## Verification Target + +Command or manual check: diff --git a/.gitea/ISSUE_TEMPLATE/tech_debt.md b/.gitea/ISSUE_TEMPLATE/tech_debt.md new file mode 100644 index 0000000..09eec4f --- /dev/null +++ b/.gitea/ISSUE_TEMPLATE/tech_debt.md @@ -0,0 +1,25 @@ +--- +name: "Tech debt" +about: "Track cleanup, refactoring, risk reduction, or deferred engineering work" +title: "[Debt] " +labels: + - type/debt + - status/triage + - module/core +--- + +## Current Cost + +What does this make harder, riskier, slower, or more fragile? + +## Desired Shape + +What should the code, tests, or architecture look like afterwards? + +## Constraints + +What behavior, compatibility, or module boundary must be preserved? + +## Verification Target + +Focused checks that should pass: diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1984736 --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,15 @@ +## Issue + +Closes # + +## Summary + +- + +## Verification + +- + +## Notes + +Follow-up issues: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..252e52a --- /dev/null +++ b/.gitignore @@ -0,0 +1,353 @@ +# ---> Node +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# vitepress build output +**/.vitepress/dist + +# vitepress cache directory +**/.vitepress/cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Local WebUI test/build scratch directories +.component-test-build/ +.file-drop-test-build/ +.module-test-build/ +.policy-test-build/ +.template-preview-test-build/ +.import-test-build/ +webui/.component-test-build/ +webui/.file-drop-test-build/ +webui/.module-test-build/ +webui/.policy-test-build/ +webui/.template-preview-test-build/ +webui/.import-test-build/ + +# Security audit reports +audit-reports/ + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# ---> VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +*.db + +# GovOPlaN local runtime state +runtime/ + +# GovOPlaN WebUI test output +webui/.module-test-build/ +webui/.component-test-build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c947e96 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# GovOPlaN Services + + +**Repository type:** module (domain). + + +`govoplan-services` owns the institution's versioned service catalogue: the +audience, prerequisites, evidence, legal bases, channels, responsibility, +jurisdiction, forms, case/workflow/result bindings, remedies, service levels, +publication state, and availability requirements of each service. + +Portal presents services and Cases starts concrete matters, but both consume +the same exact provider-owned definition through Core contracts. Neither module +reads Services tables. + +See [docs/SERVICES_DOMAIN.md](docs/SERVICES_DOMAIN.md). + +## Runtime Bindings + +Services stores bindings but does not execute them. Portal resolves the exact +Service revision and delegates to the matching owner capability: + +- `case`: a Cases-owned catalogue or intake reference; +- `form`: an exact `/` reference launched by Forms + Runtime; +- `workflow`: an exact Workflow Engine definition binding; and +- `url` or `external`: a validated redirect target. + +Consumers must retain the exact Service revision and binding. A missing owner +launcher makes the Service unavailable; it must not fall back to an unrelated +runtime. diff --git a/docs/SERVICES_DOMAIN.md b/docs/SERVICES_DOMAIN.md new file mode 100644 index 0000000..855f713 --- /dev/null +++ b/docs/SERVICES_DOMAIN.md @@ -0,0 +1,30 @@ +# Services Domain + +## Ownership + +Services owns stable service identities, stable keys, and immutable definition +revisions. Portal owns public presentation and entry. Cases owns concrete case +lifecycles. Forms, Workflow, Mandates, Organizations, and Policy retain their +own definitions and are linked by exact versioned references. + +## Publication And Availability + +Publication and runtime availability are separate. Published definitions may +still be unavailable when a required module, capability, policy, mandate, +connector, maintenance state, or audience condition is not satisfied. The +provider reports what it can establish; consumers explain unknown requirements +instead of silently treating them as available. + +Draft and retired definitions are excluded from the general provider catalogue. +Administration APIs retain their complete revision history. + +## Revision And Recovery + +Writes are replay-safe and use optimistic concurrency against the current +revision. Definitions are append-only; only the prior row's supersession marker +changes. The stable key cannot change across revisions or collide with another +current service in the tenant. + +Database restore is the recovery unit. Consumers retain the exact service +reference and revision used for a case or publication, so restore and audit do +not depend on whatever definition happens to be current later. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..437fefc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-services" +version = "0.1.14" +description = "Versioned institutional service definitions for GovOPlaN." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = ["govoplan-core>=0.1.14"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_services = ["py.typed"] + +[project.entry-points."govoplan.modules"] +"services" = "govoplan_services.backend.manifest:get_manifest" diff --git a/src/govoplan_services/__init__.py b/src/govoplan_services/__init__.py new file mode 100644 index 0000000..91988a2 --- /dev/null +++ b/src/govoplan_services/__init__.py @@ -0,0 +1,3 @@ +"""GovOPlaN Services module.""" + +__version__ = "0.1.14" diff --git a/src/govoplan_services/backend/__init__.py b/src/govoplan_services/backend/__init__.py new file mode 100644 index 0000000..3a82427 --- /dev/null +++ b/src/govoplan_services/backend/__init__.py @@ -0,0 +1 @@ +"""Services backend.""" diff --git a/src/govoplan_services/backend/db/__init__.py b/src/govoplan_services/backend/db/__init__.py new file mode 100644 index 0000000..f3eb71b --- /dev/null +++ b/src/govoplan_services/backend/db/__init__.py @@ -0,0 +1,3 @@ +from govoplan_services.backend.db.models import ServiceDefinitionRevision + +__all__ = ["ServiceDefinitionRevision"] diff --git a/src/govoplan_services/backend/db/models.py b/src/govoplan_services/backend/db/models.py new file mode 100644 index 0000000..02b5664 --- /dev/null +++ b/src/govoplan_services/backend/db/models.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any +import uuid + +from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from govoplan_core.db.base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class ServiceDefinitionRevision(Base, TimestampMixin): + __tablename__ = "service_definition_revisions" + __table_args__ = ( + UniqueConstraint("tenant_id", "service_id", "revision", name="uq_service_definition_revision"), + Index("ix_service_definition_current", "tenant_id", "service_id", "superseded_at"), + Index("ix_service_definition_catalogue", "tenant_id", "publication_state", "service_key"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + service_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + service_key: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + revision: Mapped[str] = mapped_column(String(120), nullable=False) + previous_revision_id: Mapped[str | None] = mapped_column( + ForeignKey("service_definition_revisions.id", ondelete="RESTRICT"), + nullable=True, + index=True, + ) + publication_state: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + valid_to: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + superseded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + search_text: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + + +__all__ = ["ServiceDefinitionRevision"] diff --git a/src/govoplan_services/backend/manifest.py b/src/govoplan_services/backend/manifest.py new file mode 100644 index 0000000..1b41bfa --- /dev/null +++ b/src/govoplan_services/backend/manifest.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from pathlib import Path + +from govoplan_core.core.institutional import ( + CAPABILITY_SERVICE_AVAILABILITY, + CAPABILITY_SERVICE_DEFINITIONS, +) +from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard +from govoplan_core.core.modules import ( + CapabilityDocumentation, + DocumentationLink, + DocumentationTopic, + MigrationSpec, + ModuleContext, + ModuleInterfaceProvider, + ModuleManifest, + PermissionDefinition, + RoleTemplate, +) +from govoplan_core.core.provider_governance import declared_module_architecture +from govoplan_core.db.base import Base +from govoplan_services.backend.db import models as service_models +from govoplan_services.backend.service import RegistryServiceAvailabilityEvaluator, SqlServiceDefinitionProvider + + +MODULE_ID = "services" +MODULE_NAME = "Services" +MODULE_VERSION = "0.1.14" +READ_SCOPE = "services:definition:read" +WRITE_SCOPE = "services:definition:write" +ADMIN_SCOPE = "services:definition:admin" + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="Services", + level="tenant", + module_id=module_id, + resource=resource, + action=action, + ) + + +def _router(context: ModuleContext): + from govoplan_services.backend.router import configure_registry, router + + configure_registry(context.registry) + return router + + +def _definitions(_context: ModuleContext) -> SqlServiceDefinitionProvider: + return SqlServiceDefinitionProvider() + + +def _availability(context: ModuleContext) -> RegistryServiceAvailabilityEvaluator: + return RegistryServiceAvailabilityEvaluator(context.registry) + + +manifest = ModuleManifest( + id=MODULE_ID, + name=MODULE_NAME, + version=MODULE_VERSION, + optional_dependencies=("organizations", "mandates", "policy", "portal", "cases", "forms_runtime", "workflow_engine"), + provides_interfaces=( + ModuleInterfaceProvider(name="services.definition", version="0.1.0"), + ModuleInterfaceProvider(name="services.availability", version="0.1.0"), + ), + permissions=( + _permission(READ_SCOPE, "View service definitions", "View service definitions, bindings, and availability constraints."), + _permission(WRITE_SCOPE, "Manage service definitions", "Create and revise service definitions."), + _permission(ADMIN_SCOPE, "Administer services", "Administer service publication and recovery."), + ), + role_templates=( + RoleTemplate(slug="service_manager", name="Service manager", description="Manage the institutional service catalogue.", permissions=(READ_SCOPE, WRITE_SCOPE)), + RoleTemplate(slug="service_reader", name="Service reader", description="Inspect service definitions.", permissions=(READ_SCOPE,)), + ), + route_factory=_router, + capability_factories={ + CAPABILITY_SERVICE_DEFINITIONS: _definitions, + CAPABILITY_SERVICE_AVAILABILITY: _availability, + }, + capability_documentation={ + CAPABILITY_SERVICE_DEFINITIONS: CapabilityDocumentation(label="Service definitions", summary="Provides exact versioned institutional Service definitions.", contract_version="0.1.0"), + CAPABILITY_SERVICE_AVAILABILITY: CapabilityDocumentation(label="Service availability", summary="Evaluates provider-known runtime availability requirements without widening constraints.", contract_version="0.1.0"), + }, + migration_spec=MigrationSpec( + module_id=MODULE_ID, + metadata=Base.metadata, + script_location=str(Path(__file__).with_name("migrations") / "versions"), + retirement_supported=True, + retirement_provider=drop_table_retirement_provider(service_models.ServiceDefinitionRevision, label="Services"), + retirement_notes="Destructive retirement requires a database snapshot and removes service-definition history.", + ), + uninstall_guard_providers=(persistent_table_uninstall_guard(service_models.ServiceDefinitionRevision, label="Services"),), + documentation=( + DocumentationTopic( + id="services.catalogue", + title="Institutional service catalogue", + summary="Manage versioned service promises independently from presentation and case handling.", + body="Definitions retain audiences, prerequisites, evidence, responsibility, bindings, publication, and explainable availability requirements. Portal and Cases consume exact revisions through capabilities.", + layer="configured", + documentation_types=("admin", "user"), + audience=("user", "operator", "module_admin", "product_owner"), + links=(DocumentationLink(label="Services domain and recovery", href="govoplan-services/docs/SERVICES_DOMAIN.md", kind="repository"),), + ), + ), + architecture=declared_module_architecture( + layer="human_work_procedure", + kind="domain", + maturity="vertical_slice", + documentation_ref="docs/SERVICES_DOMAIN.md", + test_ref="tests/test_services.py", + known_limits=("The catalogue is API-first; Portal remains the presentation surface.",), + supported_authority_modes=("native_authoritative",), + owned_concepts=("service definition", "service publication", "service availability requirement"), + non_owned_concepts=("portal presentation", "case lifecycle", "form definition", "workflow definition"), + reference_packages=("product.service-to-decision",), + migration_docs=("docs/SERVICES_DOMAIN.md",), + recovery_docs=("docs/SERVICES_DOMAIN.md",), + security_docs=("docs/SERVICES_DOMAIN.md",), + operations_docs=("docs/SERVICES_DOMAIN.md",), + ), +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_services/backend/migrations/__init__.py b/src/govoplan_services/backend/migrations/__init__.py new file mode 100644 index 0000000..42b8b77 --- /dev/null +++ b/src/govoplan_services/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Services migrations.""" diff --git a/src/govoplan_services/backend/migrations/versions/__init__.py b/src/govoplan_services/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..217c13c --- /dev/null +++ b/src/govoplan_services/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Services migration revisions.""" diff --git a/src/govoplan_services/backend/migrations/versions/b9c2d3e4f5a6_v0114_services_baseline.py b/src/govoplan_services/backend/migrations/versions/b9c2d3e4f5a6_v0114_services_baseline.py new file mode 100644 index 0000000..7fe17f3 --- /dev/null +++ b/src/govoplan_services/backend/migrations/versions/b9c2d3e4f5a6_v0114_services_baseline.py @@ -0,0 +1,48 @@ +"""v0.1.14 Services baseline. + +Revision ID: b9c2d3e4f5a6 +Revises: None +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b9c2d3e4f5a6" +down_revision = None +branch_labels = None +depends_on = "4f2a9c8e7b6d" + + +def upgrade() -> None: + op.create_table( + "service_definition_revisions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("service_id", sa.String(length=255), nullable=False), + sa.Column("service_key", sa.String(length=255), nullable=False), + sa.Column("revision", sa.String(length=120), nullable=False), + sa.Column("previous_revision_id", sa.String(length=36), nullable=True), + sa.Column("publication_state", sa.String(length=30), nullable=False), + sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True), + sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True), + sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("superseded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("search_text", sa.Text(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["previous_revision_id"], ["service_definition_revisions.id"], name=op.f("fk_service_definition_revisions_previous_revision_id_service_definition_revisions"), ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_service_definition_revisions")), + sa.UniqueConstraint("tenant_id", "service_id", "revision", name="uq_service_definition_revision"), + ) + for column in ("tenant_id", "service_id", "service_key", "previous_revision_id", "publication_state", "recorded_at", "superseded_at", "created_by"): + op.create_index(op.f(f"ix_service_definition_revisions_{column}"), "service_definition_revisions", [column], unique=False) + op.create_index("ix_service_definition_current", "service_definition_revisions", ["tenant_id", "service_id", "superseded_at"], unique=False) + op.create_index("ix_service_definition_catalogue", "service_definition_revisions", ["tenant_id", "publication_state", "service_key"], unique=False) + + +def downgrade() -> None: + op.drop_table("service_definition_revisions") diff --git a/src/govoplan_services/backend/router.py b/src/govoplan_services/backend/router.py new file mode 100644 index 0000000..f1838a2 --- /dev/null +++ b/src/govoplan_services/backend/router.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.orm import Session + +from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.institutional import InstitutionalContextError +from govoplan_core.db.session import get_session +from govoplan_services.backend.manifest import READ_SCOPE, WRITE_SCOPE +from govoplan_services.backend.schemas import ( + ServiceAvailabilityRequest, + ServiceListResponse, + ServiceWriteRequest, +) +from govoplan_services.backend.service import ( + RegistryServiceAvailabilityEvaluator, + ServiceStoreError, + definition_from_mapping, + get_service_definition, + list_service_definitions, + record_service_definition, +) + + +router = APIRouter(prefix="/services", tags=["services"]) +_registry: object | None = None + + +def configure_registry(registry: object | None) -> None: + global _registry + _registry = registry + + +def _require(principal: ApiPrincipal, scope: str) -> None: + if not has_scope(principal, scope): + raise HTTPException(status_code=403, detail=f"Missing scope: {scope}") + + +def _error(exc: Exception) -> HTTPException: + message = str(exc) + code = 409 if "conflict" in message.lower() or "already" in message.lower() else 400 + return HTTPException(status_code=code, detail=message) + + +@router.get("/definitions", response_model=ServiceListResponse) +def api_list_services( + query: str = "", + publication_state: list[str] | None = Query(default=None), + limit: int = Query(default=100, ge=1, le=200), + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> ServiceListResponse: + _require(principal, READ_SCOPE) + items = list_service_definitions( + session, + principal, + query=query, + publication_states=publication_state, + limit=limit, + ) + return ServiceListResponse(services=[item.to_dict(include_inspection=True) for item in items]) + + +@router.get("/definitions/{service_id}", response_model=dict[str, Any]) +def api_get_service( + service_id: str, + revision: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, READ_SCOPE) + item = get_service_definition(session, principal, service_id=service_id, revision=revision) + if item is None: + raise HTTPException(status_code=404, detail="Service definition not found") + return item.to_dict(include_inspection=True) + + +@router.post("/definitions", response_model=dict[str, Any], status_code=status.HTTP_201_CREATED) +def api_record_service( + payload: ServiceWriteRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, WRITE_SCOPE) + try: + item = record_service_definition( + session, + principal, + definition=definition_from_mapping(payload.definition), + expected_revision=payload.expected_revision, + ) + session.commit() + except (ServiceStoreError, InstitutionalContextError) as exc: + session.rollback() + raise _error(exc) from exc + return item.to_dict(include_inspection=True) + + +@router.post("/availability", response_model=dict[str, Any]) +def api_service_availability( + payload: ServiceAvailabilityRequest, + principal: ApiPrincipal = Depends(get_api_principal), +) -> dict[str, Any]: + _require(principal, READ_SCOPE) + try: + result = RegistryServiceAvailabilityEvaluator(_registry).evaluate_service_availability( + None, + principal, + definition=definition_from_mapping(payload.definition), + effective_at=payload.effective_at, + ) + except (ServiceStoreError, InstitutionalContextError) as exc: + raise _error(exc) from exc + return { + "requirement_states": dict(result.requirement_states), + "reason_codes": list(result.reason_codes), + "evidence": [item.to_dict(include_inspection=True) for item in result.evidence], + } + + +__all__ = ["configure_registry", "router"] diff --git a/src/govoplan_services/backend/schemas.py b/src/govoplan_services/backend/schemas.py new file mode 100644 index 0000000..dbe404e --- /dev/null +++ b/src/govoplan_services/backend/schemas.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ServiceWriteRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + definition: dict[str, Any] + expected_revision: str | None = Field(default=None, max_length=120) + + +class ServiceListResponse(BaseModel): + services: list[dict[str, Any]] + + +class ServiceAvailabilityRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + definition: dict[str, Any] + effective_at: datetime + + +__all__ = ["ServiceAvailabilityRequest", "ServiceListResponse", "ServiceWriteRequest"] diff --git a/src/govoplan_services/backend/service.py b/src/govoplan_services/backend/service.py new file mode 100644 index 0000000..028a23b --- /dev/null +++ b/src/govoplan_services/backend/service.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Mapping, Sequence + +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + InstitutionalContextError, + InstitutionalReference, + ServiceAvailabilityAssessment, + ServiceDefinition, + TemporalRevision, +) +from govoplan_services.backend.db.models import ServiceDefinitionRevision + + +_PUBLICATION_TRANSITIONS: dict[str, frozenset[str]] = { + "draft": frozenset({"draft", "published", "retired"}), + "published": frozenset({"published", "suspended", "retired"}), + "suspended": frozenset({"suspended", "published", "retired"}), + "retired": frozenset(), +} + + +class ServiceStoreError(ValueError): + pass + + +def record_service_definition( + session: Session, + principal: object, + *, + definition: ServiceDefinition, + expected_revision: str | None = None, +) -> ServiceDefinition: + tenant_id = _principal_tenant(principal) + _validate_definition(definition, tenant_id=tenant_id) + payload = definition.to_dict(include_inspection=True) + replay = ( + session.query(ServiceDefinitionRevision) + .filter( + ServiceDefinitionRevision.tenant_id == tenant_id, + ServiceDefinitionRevision.service_id == definition.reference.object_id, + ServiceDefinitionRevision.revision == definition.temporal.revision, + ) + .one_or_none() + ) + if replay is not None: + if replay.payload != payload: + raise ServiceStoreError( + "A different Service payload already uses this revision." + ) + return _definition_from_row(replay) + + current = _current_row( + session, + tenant_id=tenant_id, + service_id=definition.reference.object_id, + lock=True, + ) + _validate_temporal(definition.temporal) + if current is None: + if expected_revision is not None: + raise ServiceStoreError( + "Service revision conflict: no current revision exists." + ) + collision = ( + session.query(ServiceDefinitionRevision.id) + .filter( + ServiceDefinitionRevision.tenant_id == tenant_id, + ServiceDefinitionRevision.service_key == definition.key, + ServiceDefinitionRevision.superseded_at.is_(None), + ) + .first() + ) + if collision is not None: + raise ServiceStoreError("Service key is already in use in this tenant.") + else: + current_definition = _definition_from_row(current) + if expected_revision != current.revision: + raise ServiceStoreError( + "Service revision conflict: the expected revision is stale." + ) + if definition.key != current_definition.key: + raise ServiceStoreError("A Service key cannot change across revisions.") + if definition.publication_state not in _PUBLICATION_TRANSITIONS[current.publication_state]: + raise ServiceStoreError( + f"Service publication transition {current.publication_state!r} to " + f"{definition.publication_state!r} is not allowed." + ) + current.superseded_at = definition.temporal.recorded_at + + row = ServiceDefinitionRevision( + tenant_id=tenant_id, + service_id=definition.reference.object_id, + service_key=definition.key, + revision=definition.temporal.revision, + previous_revision_id=current.id if current is not None else None, + publication_state=definition.publication_state, + valid_from=definition.temporal.valid_from, + valid_to=definition.temporal.valid_to, + recorded_at=_recorded_at(definition.temporal), + search_text=f"{definition.key} {definition.title}".casefold(), + payload=payload, + created_by=_principal_actor(principal), + ) + session.add(row) + session.flush() + return _definition_from_row(row) + + +def get_service_definition( + session: Session, + principal: object, + *, + service_id: str, + revision: str | None = None, +) -> ServiceDefinition | None: + tenant_id = _principal_tenant(principal) + query = session.query(ServiceDefinitionRevision).filter( + ServiceDefinitionRevision.tenant_id == tenant_id, + ServiceDefinitionRevision.service_id == service_id, + ) + if revision is None: + query = query.filter(ServiceDefinitionRevision.superseded_at.is_(None)) + else: + query = query.filter(ServiceDefinitionRevision.revision == revision) + row = query.order_by(ServiceDefinitionRevision.recorded_at.desc()).first() + return _definition_from_row(row) if row is not None else None + + +def list_service_definitions( + session: Session, + principal: object, + *, + query: str = "", + publication_states: Sequence[str] | None = None, + limit: int = 100, +) -> tuple[ServiceDefinition, ...]: + tenant_id = _principal_tenant(principal) + if not 1 <= limit <= 200: + raise ServiceStoreError("Service list limit must be between 1 and 200.") + statement = session.query(ServiceDefinitionRevision).filter( + ServiceDefinitionRevision.tenant_id == tenant_id, + ServiceDefinitionRevision.superseded_at.is_(None), + ) + if publication_states is not None: + statement = statement.filter( + ServiceDefinitionRevision.publication_state.in_(tuple(publication_states)) + ) + clean_query = query.strip().casefold() + if clean_query: + statement = statement.filter( + ServiceDefinitionRevision.search_text.contains(clean_query) + ) + rows = statement.order_by( + ServiceDefinitionRevision.service_key.asc(), + ServiceDefinitionRevision.recorded_at.desc(), + ).limit(limit).all() + return tuple(_definition_from_row(row) for row in rows) + + +class SqlServiceDefinitionProvider: + def get_service_definition( + self, + session: object, + principal: object, + *, + reference: InstitutionalReference, + effective_at: datetime | None = None, + ) -> ServiceDefinition | None: + tenant_id = _principal_tenant(principal) + if reference.kind != "service" or reference.tenant_id != tenant_id: + raise InstitutionalContextError( + "Service definition lookup requires a same-tenant Service reference." + ) + item = get_service_definition( + _session(session), + principal, + service_id=reference.object_id, + revision=reference.version, + ) + if item is None or ( + effective_at is not None and not item.temporal.effective_at(effective_at) + ): + return None + return item + + def list_service_definitions( + self, + session: object, + principal: object, + *, + tenant_id: str, + query: str = "", + limit: int = 100, + ) -> Sequence[ServiceDefinition]: + if tenant_id != _principal_tenant(principal): + raise InstitutionalContextError( + "Service catalogue lookup cannot cross tenants." + ) + return list_service_definitions( + _session(session), + principal, + query=query, + publication_states=("published", "suspended"), + limit=limit, + ) + + +class RegistryServiceAvailabilityEvaluator: + def __init__(self, registry: object | None) -> None: + self._registry = registry + + def evaluate_service_availability( + self, + session: object, + principal: object, + *, + definition: ServiceDefinition, + effective_at: datetime, + ) -> ServiceAvailabilityAssessment: + del session + tenant_id = _principal_tenant(principal) + if definition.reference.tenant_id != tenant_id: + raise InstitutionalContextError( + "Service availability cannot be evaluated across tenants." + ) + states: dict[str, bool] = {} + reasons: list[str] = [] + if not definition.temporal.effective_at(effective_at): + reasons.append("service.outside_effective_interval") + if definition.publication_state != "published": + reasons.append(f"service.publication.{definition.publication_state}") + for requirement in definition.availability_requirements: + if requirement.kind == "module": + states[requirement.key] = _registry_has(self._registry, requirement.reference) + elif requirement.kind == "capability": + states[requirement.key] = _registry_has_capability( + self._registry, + requirement.reference, + ) + return ServiceAvailabilityAssessment( + requirement_states=states, + reason_codes=tuple(reasons), + ) + + +def definition_from_mapping(value: Mapping[str, object]) -> ServiceDefinition: + try: + return ServiceDefinition.from_mapping(value) + except InstitutionalContextError as exc: + raise ServiceStoreError(str(exc)) from exc + + +def _current_row( + session: Session, + *, + tenant_id: str, + service_id: str, + lock: bool, +) -> ServiceDefinitionRevision | None: + query = session.query(ServiceDefinitionRevision).filter( + ServiceDefinitionRevision.tenant_id == tenant_id, + ServiceDefinitionRevision.service_id == service_id, + ServiceDefinitionRevision.superseded_at.is_(None), + ) + if lock: + query = query.with_for_update() + return query.one_or_none() + + +def _definition_from_row(row: ServiceDefinitionRevision) -> ServiceDefinition: + payload: dict[str, Any] = dict(row.payload) + temporal = dict(payload.get("temporal") or {}) + temporal["superseded_at"] = _datetime_text(row.superseded_at) + payload["temporal"] = temporal + return ServiceDefinition.from_mapping(payload) + + +def _validate_definition(definition: ServiceDefinition, *, tenant_id: str) -> None: + if definition.reference.owner_module != "services": + raise ServiceStoreError("Service definitions must be owned by Services.") + if definition.reference.tenant_id != tenant_id: + raise ServiceStoreError("Service definitions cannot cross tenants.") + if definition.reference.version != definition.temporal.revision: + raise ServiceStoreError( + "Service reference version must match its temporal revision." + ) + if definition.temporal.superseded_at is not None: + raise ServiceStoreError("Clients cannot set Service superseded_at.") + + +def _validate_temporal(temporal: TemporalRevision) -> None: + _recorded_at(temporal) + if not str(temporal.change_reason or "").strip(): + raise ServiceStoreError( + "A Service revision requires recorded_at and change_reason." + ) + + +def _recorded_at(temporal: TemporalRevision) -> datetime: + if temporal.recorded_at is None: + raise ServiceStoreError("A Service revision requires recorded_at.") + return temporal.recorded_at + + +def _principal_tenant(principal: object) -> str: + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id: + raise InstitutionalContextError( + "Service operations require a tenant-bound principal." + ) + return tenant_id + + +def _principal_actor(principal: object) -> str | None: + for name in ("account_id", "identity_id", "membership_id"): + value = str(getattr(principal, name, "") or "").strip() + if value: + return value + return None + + +def _session(value: object) -> Session: + if not hasattr(value, "query"): + raise InstitutionalContextError("Service provider requires a database session.") + return value # type: ignore[return-value] + + +def _registry_has(registry: object | None, module_id: str) -> bool: + return bool(registry is not None and hasattr(registry, "has") and registry.has(module_id)) + + +def _registry_has_capability(registry: object | None, capability: str) -> bool: + return bool( + registry is not None + and hasattr(registry, "has_capability") + and registry.has_capability(capability) + ) + + +def _datetime_text(value: datetime | None) -> str | None: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + return value.isoformat() + + +__all__ = [ + "RegistryServiceAvailabilityEvaluator", + "ServiceStoreError", + "SqlServiceDefinitionProvider", + "definition_from_mapping", + "get_service_definition", + "list_service_definitions", + "record_service_definition", +] diff --git a/src/govoplan_services/py.typed b/src/govoplan_services/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_services/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..8854963 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session + +from govoplan_core.core.institutional import ( + InstitutionalReference, + ServiceAvailabilityRequirement, + ServiceDefinition, + TemporalRevision, +) +from govoplan_services.backend.db.models import ServiceDefinitionRevision +from govoplan_services.backend.service import ( + RegistryServiceAvailabilityEvaluator, + ServiceStoreError, + SqlServiceDefinitionProvider, + record_service_definition, +) + + +NOW = datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + account_id: str = "account-1" + + +class Registry: + def has(self, module_id: str) -> bool: + return module_id == "cases" + + def has_capability(self, capability: str) -> bool: + return capability == "workflow.runtime" + + +def service(*, revision: str = "1", state: str = "published") -> ServiceDefinition: + recorded_at = NOW + timedelta(minutes=int(revision) - 1) + return ServiceDefinition( + reference=InstitutionalReference(kind="service", owner_module="services", object_id="permit", tenant_id="tenant-1", version=revision, valid_at=NOW), + key="permit", + temporal=TemporalRevision(revision=revision, valid_from=NOW, recorded_at=recorded_at, change_reason="Initial service." if revision == "1" else "Publication changed."), + title="Apply for a permit", + audience=("resident",), + channels=("portal",), + availability_requirements=( + ServiceAvailabilityRequirement(kind="module", reference="cases"), + ServiceAvailabilityRequirement(kind="capability", reference="workflow.runtime"), + ), + publication_state=state, # type: ignore[arg-type] + ) + + +class ServiceTests(unittest.TestCase): + def setUp(self) -> None: + self.engine = create_engine("sqlite+pysqlite:///:memory:") + ServiceDefinitionRevision.__table__.create(self.engine) + self.session = Session(self.engine) + self.principal = Principal() + + def tearDown(self) -> None: + self.session.close() + self.engine.dispose() + + def test_provider_returns_exact_published_revision(self) -> None: + first = record_service_definition(self.session, self.principal, definition=service()) + provider = SqlServiceDefinitionProvider() + listed = provider.list_service_definitions(self.session, self.principal, tenant_id="tenant-1") + self.assertEqual((first,), tuple(listed)) + exact = provider.get_service_definition(self.session, self.principal, reference=first.reference, effective_at=NOW) + self.assertEqual("1", exact.temporal.revision if exact else None) + + suspended = service(revision="2", state="suspended") + record_service_definition(self.session, self.principal, definition=suspended, expected_revision="1") + historical = provider.get_service_definition(self.session, self.principal, reference=first.reference) + self.assertEqual("1", historical.temporal.revision if historical else None) + + def test_occ_availability_and_tenant_boundary(self) -> None: + record_service_definition(self.session, self.principal, definition=service()) + with self.assertRaisesRegex(ServiceStoreError, "stale"): + record_service_definition(self.session, self.principal, definition=service(revision="2"), expected_revision="0") + assessment = RegistryServiceAvailabilityEvaluator(Registry()).evaluate_service_availability( + self.session, + self.principal, + definition=service(), + effective_at=NOW, + ) + self.assertEqual( + {"module:cases": True, "capability:workflow.runtime": True}, + assessment.requirement_states, + ) + with self.assertRaisesRegex(Exception, "cross tenants"): + SqlServiceDefinitionProvider().list_service_definitions(self.session, Principal("tenant-2"), tenant_id="tenant-1") + + +if __name__ == "__main__": + unittest.main()