From 6144fba6ced1d39deb9145568c36f1a77f243f25 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/MANDATES_DOMAIN.md | 5 ++++ src/govoplan_mandates/backend/manifest.py | 3 ++- src/govoplan_mandates/backend/service.py | 31 ++++++++++++----------- tests/test_mandates.py | 30 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/docs/MANDATES_DOMAIN.md b/docs/MANDATES_DOMAIN.md index e4237bc..0085731 100644 --- a/docs/MANDATES_DOMAIN.md +++ b/docs/MANDATES_DOMAIN.md @@ -21,6 +21,11 @@ Consumers must freeze the exact mandate reference and evidence used for a consequential action. A later mandate correction does not rewrite historical decisions or effects. +Catalogue reads follow the platform temporal-data context, independently +selecting valid time and the system's recorded-state cutoff. A resolver's +explicit effective instant takes precedence. Competence and authorization for +a new action are never recovered merely by browsing historical data. + ## Revision And Recovery Creation and revision use optimistic concurrency. Every new revision has a new diff --git a/src/govoplan_mandates/backend/manifest.py b/src/govoplan_mandates/backend/manifest.py index a64d3c7..f13a5e6 100644 --- a/src/govoplan_mandates/backend/manifest.py +++ b/src/govoplan_mandates/backend/manifest.py @@ -117,7 +117,8 @@ manifest = ModuleManifest( summary="Define and resolve effective authority, jurisdiction, legal basis, and evidence.", body=( "Mandates stores immutable revisions and resolves the one effective authority for a task. " - "Conflicting or missing authority fails closed. Consequential consumers retain the exact revision and evidence." + "Conflicting or missing authority fails closed. Consequential consumers retain the exact revision and evidence. " + "Catalogue reads follow the titlebar valid-time and recorded-time selection; explicit resolution times take precedence and current authorization is unchanged." ), layer="configured", documentation_types=("admin", "user"), diff --git a/src/govoplan_mandates/backend/service.py b/src/govoplan_mandates/backend/service.py index 78e94c2..31c4c97 100644 --- a/src/govoplan_mandates/backend/service.py +++ b/src/govoplan_mandates/backend/service.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import UTC, datetime from typing import Any, Mapping -from sqlalchemy import or_ from sqlalchemy.orm import Session from govoplan_core.core.institutional import ( @@ -15,6 +14,8 @@ from govoplan_core.core.institutional import ( resolve_mandate_candidates, revise_mandate_definition, ) +from govoplan_core.core.temporal import TemporalDataContext +from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_mandates.backend.db.models import MandateRevision @@ -120,7 +121,7 @@ def get_mandate( if revision is not None: query = query.filter(MandateRevision.revision == revision) else: - query = query.filter(MandateRevision.superseded_at.is_(None)) + query = apply_temporal_revision_filter(query, MandateRevision) row = query.order_by(MandateRevision.recorded_at.desc()).first() return _definition_from_row(row) if row is not None else None @@ -137,8 +138,8 @@ def list_mandates( raise MandateStoreError("Mandate list limit must be between 1 and 200.") query = session.query(MandateRevision).filter( MandateRevision.tenant_id == tenant_id, - MandateRevision.superseded_at.is_(None), ) + query = apply_temporal_revision_filter(query, MandateRevision) if status: query = query.filter(MandateRevision.status == status) rows = query.order_by( @@ -162,19 +163,19 @@ class SqlMandateResolver: "Mandate resolution cannot cross tenants." ) typed_session = _session(session) + query = typed_session.query(MandateRevision).filter( + MandateRevision.tenant_id == tenant_id, + ) + query = apply_temporal_revision_filter( + query, + MandateRevision, + context=TemporalDataContext( + validity_mode="at", + valid_at=request.effective_at, + ), + ) rows = ( - typed_session.query(MandateRevision) - .filter( - MandateRevision.tenant_id == tenant_id, - or_( - MandateRevision.valid_from.is_(None), - MandateRevision.valid_from <= request.effective_at, - ), - or_( - MandateRevision.valid_to.is_(None), - MandateRevision.valid_to > request.effective_at, - ), - ) + query .order_by( MandateRevision.mandate_id.asc(), MandateRevision.recorded_at.desc(), diff --git a/tests/test_mandates.py b/tests/test_mandates.py index 6827cb9..6a9af2a 100644 --- a/tests/test_mandates.py +++ b/tests/test_mandates.py @@ -13,6 +13,11 @@ from govoplan_core.core.institutional import ( MandateResolutionRequest, TemporalRevision, ) +from govoplan_core.core.temporal import ( + TemporalDataContext, + bind_temporal_data_context, + reset_temporal_data_context, +) from govoplan_mandates.backend.db.models import MandateRevision from govoplan_mandates.backend.service import ( MandateStoreError, @@ -124,6 +129,31 @@ class MandateTests(unittest.TestCase): ) ) + def test_read_context_can_reconstruct_recorded_state(self) -> None: + record_mandate(self.session, self.principal, definition=definition()) + record_mandate( + self.session, + self.principal, + definition=definition(revision="2", status="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 = get_mandate( + self.session, + self.principal, + mandate_id="committee-permit", + ) + self.assertEqual("1", result.temporal.revision if result else None) + finally: + reset_temporal_data_context(token) + if __name__ == "__main__": unittest.main()