From 25c4267dc6ff45877fbc4359d746295491b81900 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 5 Aug 2026 00:03:32 +0200 Subject: [PATCH] feat: honor temporal read context --- docs/SERVICES_DOMAIN.md | 6 +++++ src/govoplan_services/backend/manifest.py | 2 +- src/govoplan_services/backend/service.py | 23 +++++++++++++---- tests/test_services.py | 30 +++++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/SERVICES_DOMAIN.md b/docs/SERVICES_DOMAIN.md index 855f713..8d89cda 100644 --- a/docs/SERVICES_DOMAIN.md +++ b/docs/SERVICES_DOMAIN.md @@ -18,6 +18,12 @@ 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. +Catalogue and unversioned detail reads follow the platform temporal-data +context. Valid time answers when a definition applied; the independent +recorded cutoff reconstructs what was known then. Exact service references +remain exact, and current availability and authorization are revalidated for +new launches. + ## Revision And Recovery Writes are replay-safe and use optimistic concurrency against the current diff --git a/src/govoplan_services/backend/manifest.py b/src/govoplan_services/backend/manifest.py index adeed3b..c07651a 100644 --- a/src/govoplan_services/backend/manifest.py +++ b/src/govoplan_services/backend/manifest.py @@ -102,7 +102,7 @@ manifest = ModuleManifest( 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.", + body="Definitions retain audiences, prerequisites, evidence, responsibility, bindings, publication, and explainable availability requirements. Catalogue and unversioned detail reads follow the titlebar valid-time and recorded-time selection. Portal and Cases consume exact revisions through capabilities, and current authorization is unchanged.", layer="configured", documentation_types=("admin", "user"), audience=("user", "operator", "module_admin", "product_owner"), diff --git a/src/govoplan_services/backend/service.py b/src/govoplan_services/backend/service.py index 028a23b..c236ddb 100644 --- a/src/govoplan_services/backend/service.py +++ b/src/govoplan_services/backend/service.py @@ -12,6 +12,8 @@ from govoplan_core.core.institutional import ( ServiceDefinition, TemporalRevision, ) +from govoplan_core.core.temporal import TemporalDataContext +from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_services.backend.db.models import ServiceDefinitionRevision @@ -116,6 +118,7 @@ def get_service_definition( *, service_id: str, revision: str | None = None, + temporal_context: TemporalDataContext | None = None, ) -> ServiceDefinition | None: tenant_id = _principal_tenant(principal) query = session.query(ServiceDefinitionRevision).filter( @@ -123,7 +126,11 @@ def get_service_definition( ServiceDefinitionRevision.service_id == service_id, ) if revision is None: - query = query.filter(ServiceDefinitionRevision.superseded_at.is_(None)) + query = apply_temporal_revision_filter( + query, + ServiceDefinitionRevision, + context=temporal_context, + ) else: query = query.filter(ServiceDefinitionRevision.revision == revision) row = query.order_by(ServiceDefinitionRevision.recorded_at.desc()).first() @@ -143,7 +150,10 @@ def list_service_definitions( 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), + ) + statement = apply_temporal_revision_filter( + statement, + ServiceDefinitionRevision, ) if publication_states is not None: statement = statement.filter( @@ -180,10 +190,13 @@ class SqlServiceDefinitionProvider: principal, service_id=reference.object_id, revision=reference.version, + temporal_context=( + TemporalDataContext(validity_mode="at", valid_at=effective_at) + if effective_at is not None and reference.version is None + else None + ), ) - if item is None or ( - effective_at is not None and not item.temporal.effective_at(effective_at) - ): + if item is None or (effective_at is not None and reference.version is not None and not item.temporal.effective_at(effective_at)): return None return item diff --git a/tests/test_services.py b/tests/test_services.py index 8854963..ac1c7f1 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -13,6 +13,11 @@ from govoplan_core.core.institutional import ( ServiceDefinition, TemporalRevision, ) +from govoplan_core.core.temporal import ( + TemporalDataContext, + bind_temporal_data_context, + reset_temporal_data_context, +) from govoplan_services.backend.db.models import ServiceDefinitionRevision from govoplan_services.backend.service import ( RegistryServiceAvailabilityEvaluator, @@ -97,6 +102,31 @@ class ServiceTests(unittest.TestCase): with self.assertRaisesRegex(Exception, "cross tenants"): SqlServiceDefinitionProvider().list_service_definitions(self.session, Principal("tenant-2"), tenant_id="tenant-1") + def test_read_context_can_reconstruct_recorded_state(self) -> None: + record_service_definition(self.session, self.principal, definition=service()) + record_service_definition( + self.session, + self.principal, + definition=service(revision="2", state="suspended"), + expected_revision="1", + ) + token = bind_temporal_data_context( + TemporalDataContext( + validity_mode="at", + valid_at=NOW + timedelta(hours=1), + recorded_at=NOW + timedelta(seconds=30), + ) + ) + try: + result = SqlServiceDefinitionProvider().list_service_definitions( + self.session, + self.principal, + tenant_id="tenant-1", + ) + self.assertEqual("1", result[0].temporal.revision if result else None) + finally: + reset_temporal_data_context(token) + if __name__ == "__main__": unittest.main()