diff --git a/docs/DECISIONS_DOMAIN.md b/docs/DECISIONS_DOMAIN.md index 6f86b4f..7b406d6 100644 --- a/docs/DECISIONS_DOMAIN.md +++ b/docs/DECISIONS_DOMAIN.md @@ -11,6 +11,11 @@ publication, remedy, and review references. The ordinary read projection withholds protected reasoning and operative content. A separate sensitive-read permission is required to disclose it. +Unversioned list and detail reads follow the platform temporal-data context. +Valid time determines when the outcome applied; recorded time reconstructs +which revision the system knew then. Exact revision references bypass that +projection. Permissions and mutation targets always use current state. + ## Lifecycle Writes use the shared Decision transition matrix and optimistic concurrency. diff --git a/src/govoplan_decisions/backend/manifest.py b/src/govoplan_decisions/backend/manifest.py index 6d5e175..7485461 100644 --- a/src/govoplan_decisions/backend/manifest.py +++ b/src/govoplan_decisions/backend/manifest.py @@ -68,7 +68,7 @@ manifest = ModuleManifest( id="decisions.formal-outcome", title="Formal institutional Decisions", summary="Reconstruct authority, evidence, reasoning, outcome, effects, and review history.", - body="Decisions preserves immutable formal outcomes. Corrections and revocations create linked revisions; requested and observed effects remain distinct for reconciliation.", + body="Decisions preserves immutable formal outcomes. Corrections and revocations create linked revisions; requested and observed effects remain distinct for reconciliation. List and unversioned detail reads follow the titlebar valid-time and recorded-time selection; exact revision references remain exact and current authorization is unchanged.", layer="configured", documentation_types=("admin", "user"), audience=("user", "operator", "module_admin", "auditor"), diff --git a/src/govoplan_decisions/backend/service.py b/src/govoplan_decisions/backend/service.py index 8990f9f..741adb0 100644 --- a/src/govoplan_decisions/backend/service.py +++ b/src/govoplan_decisions/backend/service.py @@ -12,6 +12,7 @@ from govoplan_core.core.institutional import ( TemporalRevision, revise_formal_decision, ) +from govoplan_core.db.temporal import apply_temporal_revision_filter from govoplan_decisions.backend.db.models import FormalDecisionRevision @@ -37,7 +38,7 @@ class SqlDecisionRegistry: FormalDecisionRevision.decision_id == reference.object_id, ) if reference.version is None: - query = query.filter(FormalDecisionRevision.superseded_at.is_(None)) + query = apply_temporal_revision_filter(query, FormalDecisionRevision) else: query = query.filter(FormalDecisionRevision.revision == reference.version) row = query.order_by(FormalDecisionRevision.recorded_at.desc()).first() @@ -142,8 +143,8 @@ def list_decisions( raise DecisionStoreError("Decision list limit must be between 1 and 200.") query = session.query(FormalDecisionRevision).filter( FormalDecisionRevision.tenant_id == tenant_id, - FormalDecisionRevision.superseded_at.is_(None), ) + query = apply_temporal_revision_filter(query, FormalDecisionRevision) if state: query = query.filter(FormalDecisionRevision.state == state) if decision_type: diff --git a/tests/test_decisions.py b/tests/test_decisions.py index 7af8c2e..799a1b4 100644 --- a/tests/test_decisions.py +++ b/tests/test_decisions.py @@ -17,8 +17,17 @@ from govoplan_core.core.institutional import ( TemporalRevision, revise_formal_decision, ) +from govoplan_core.core.temporal import ( + TemporalDataContext, + bind_temporal_data_context, + reset_temporal_data_context, +) from govoplan_decisions.backend.db.models import FormalDecisionRevision -from govoplan_decisions.backend.service import DecisionStoreError, SqlDecisionRegistry +from govoplan_decisions.backend.service import ( + DecisionStoreError, + SqlDecisionRegistry, + list_decisions, +) NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC) @@ -96,6 +105,62 @@ class DecisionTests(unittest.TestCase): with self.assertRaisesRegex(Exception, "same-tenant"): registry.get_decision(self.session, Principal("tenant-2"), reference=first.reference) + def test_temporal_context_selects_valid_and_recorded_state(self) -> None: + registry = SqlDecisionRegistry() + first = registry.record_decision(self.session, self.principal, decision=decision()) + temporal = TemporalRevision( + revision="2", + valid_from=NOW, + recorded_at=NOW + timedelta(minutes=1), + change_reason="Effect confirmed.", + ) + revised = revise_formal_decision( + first, + expected_revision="1", + temporal=temporal, + state="effective", + ) + registry.record_decision( + self.session, + self.principal, + decision=revised, + 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: + historical = registry.get_decision( + self.session, + self.principal, + reference=reference("decision", "decision-1", "decisions", None), + ) + self.assertEqual("1", historical.temporal.revision if historical else None) + finally: + reset_temporal_data_context(token) + + token = bind_temporal_data_context( + TemporalDataContext( + validity_mode="at", + valid_at=NOW - timedelta(days=1), + ) + ) + try: + self.assertEqual((), list_decisions(self.session, self.principal)) + finally: + reset_temporal_data_context(token) + + token = bind_temporal_data_context(TemporalDataContext(validity_mode="all")) + try: + self.assertEqual(1, len(list_decisions(self.session, self.principal))) + finally: + reset_temporal_data_context(token) + if __name__ == "__main__": unittest.main()