feat: honor temporal read context

This commit is contained in:
2026-08-05 00:03:32 +02:00
parent f7993786df
commit c9bd2bfab8
4 changed files with 75 additions and 4 deletions
+5
View File
@@ -11,6 +11,11 @@ publication, remedy, and review references.
The ordinary read projection withholds protected reasoning and operative The ordinary read projection withholds protected reasoning and operative
content. A separate sensitive-read permission is required to disclose it. 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 ## Lifecycle
Writes use the shared Decision transition matrix and optimistic concurrency. Writes use the shared Decision transition matrix and optimistic concurrency.
+1 -1
View File
@@ -68,7 +68,7 @@ manifest = ModuleManifest(
id="decisions.formal-outcome", id="decisions.formal-outcome",
title="Formal institutional Decisions", title="Formal institutional Decisions",
summary="Reconstruct authority, evidence, reasoning, outcome, effects, and review history.", 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", layer="configured",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "auditor"), audience=("user", "operator", "module_admin", "auditor"),
+3 -2
View File
@@ -12,6 +12,7 @@ from govoplan_core.core.institutional import (
TemporalRevision, TemporalRevision,
revise_formal_decision, revise_formal_decision,
) )
from govoplan_core.db.temporal import apply_temporal_revision_filter
from govoplan_decisions.backend.db.models import FormalDecisionRevision from govoplan_decisions.backend.db.models import FormalDecisionRevision
@@ -37,7 +38,7 @@ class SqlDecisionRegistry:
FormalDecisionRevision.decision_id == reference.object_id, FormalDecisionRevision.decision_id == reference.object_id,
) )
if reference.version is None: if reference.version is None:
query = query.filter(FormalDecisionRevision.superseded_at.is_(None)) query = apply_temporal_revision_filter(query, FormalDecisionRevision)
else: else:
query = query.filter(FormalDecisionRevision.revision == reference.version) query = query.filter(FormalDecisionRevision.revision == reference.version)
row = query.order_by(FormalDecisionRevision.recorded_at.desc()).first() 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.") raise DecisionStoreError("Decision list limit must be between 1 and 200.")
query = session.query(FormalDecisionRevision).filter( query = session.query(FormalDecisionRevision).filter(
FormalDecisionRevision.tenant_id == tenant_id, FormalDecisionRevision.tenant_id == tenant_id,
FormalDecisionRevision.superseded_at.is_(None),
) )
query = apply_temporal_revision_filter(query, FormalDecisionRevision)
if state: if state:
query = query.filter(FormalDecisionRevision.state == state) query = query.filter(FormalDecisionRevision.state == state)
if decision_type: if decision_type:
+66 -1
View File
@@ -17,8 +17,17 @@ from govoplan_core.core.institutional import (
TemporalRevision, TemporalRevision,
revise_formal_decision, 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.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) NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
@@ -96,6 +105,62 @@ class DecisionTests(unittest.TestCase):
with self.assertRaisesRegex(Exception, "same-tenant"): with self.assertRaisesRegex(Exception, "same-tenant"):
registry.get_decision(self.session, Principal("tenant-2"), reference=first.reference) 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__": if __name__ == "__main__":
unittest.main() unittest.main()