feat: honor temporal read context
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user