feat: implement institutional service catalogue

This commit is contained in:
2026-08-01 17:48:39 +02:00
commit c6c23aab8c
24 changed files with 1445 additions and 0 deletions
+35
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+27
View File
@@ -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?
+32
View File
@@ -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:
+28
View File
@@ -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:
+25
View File
@@ -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:
+15
View File
@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:
+353
View File
@@ -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/
+31
View File
@@ -0,0 +1,31 @@
# GovOPlaN Services
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
`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 `<form-id>/<form-revision>` 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.
+30
View File
@@ -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.
+21
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Services module."""
__version__ = "0.1.14"
@@ -0,0 +1 @@
"""Services backend."""
@@ -0,0 +1,3 @@
from govoplan_services.backend.db.models import ServiceDefinitionRevision
__all__ = ["ServiceDefinitionRevision"]
@@ -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"]
+132
View File
@@ -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
@@ -0,0 +1 @@
"""Services migrations."""
@@ -0,0 +1 @@
"""Services migration revisions."""
@@ -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")
+123
View File
@@ -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"]
+27
View File
@@ -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"]
+360
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
+102
View File
@@ -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()