From c8994e99d53a8771c78552a5a10e67ad3c367195 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Thu, 1 Oct 2026 02:34:00 +0800 Subject: [PATCH] feat: add context-owned business clock --- examples/business-clock/README.md | 11 +++++++++++ examples/business-clock/main.py | 11 +++++++++++ src/teaql/runtime/__init__.py | 3 ++- src/teaql/runtime/business_clock.py | 27 +++++++++++++++++++++++++++ src/teaql/runtime/context.py | 23 ++++++++++++++++++++--- tests/runtime/test_runtime.py | 22 +++++++++++++++++++++- 6 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 examples/business-clock/README.md create mode 100644 examples/business-clock/main.py create mode 100644 src/teaql/runtime/business_clock.py diff --git a/examples/business-clock/README.md b/examples/business-clock/README.md new file mode 100644 index 0000000..adb0b3b --- /dev/null +++ b/examples/business-clock/README.md @@ -0,0 +1,11 @@ +# Context-owned Business Clock example + +This minimal example proves that a fixed provider, scoped to one `UserContext`, +drives both business time and business date. It does not change the process +clock used by cache expiry, telemetry, or security-token lifetimes. + +Run it from the repository root: + +```bash +PYTHONPATH=src python examples/business-clock/main.py +``` diff --git a/examples/business-clock/main.py b/examples/business-clock/main.py new file mode 100644 index 0000000..9e6b0de --- /dev/null +++ b/examples/business-clock/main.py @@ -0,0 +1,11 @@ +from datetime import date, datetime, timezone + +from teaql.runtime import FixedBusinessClock, UserContext + + +expected = datetime(2026, 10, 1, 14, 20, tzinfo=timezone.utc) +context = UserContext.new().with_business_clock(FixedBusinessClock(expected)) + +assert context.business_time() == expected +assert context.business_date() == date(2026, 10, 1) +print("PASS Python context-owned Business Clock example") diff --git a/src/teaql/runtime/__init__.py b/src/teaql/runtime/__init__.py index 2421648..a3a9a1c 100644 --- a/src/teaql/runtime/__init__.py +++ b/src/teaql/runtime/__init__.py @@ -10,6 +10,7 @@ from .module import RuntimeModule, DefaultEntityDataServiceBehavior from .store import DataStore from .audit import RawAuditEvent, SafeAuditEvent, MutationAuditKind +from .business_clock import BusinessClock, FixedBusinessClock, SystemBusinessClock from .mutation_policy import ( MISSING_APPROVAL, MISSING_POLICY, @@ -34,7 +35,7 @@ from .wire_fields import NormalizedWireInput, WireEntityMetadata, WireFieldMetadata, WireInputError, create_wire_entity_metadata, encode_wire_output, normalize_wire_input, retain_submitted_paths from teaql.core.entity import EntityKey, EntityChangeSet, EntityRoot -__all__ = ["WireFieldMetadata", "WireEntityMetadata", "NormalizedWireInput", "WireInputError", "create_wire_entity_metadata", "normalize_wire_input", "encode_wire_output", "retain_submitted_paths", "EntityKey", "EntityChangeSet", "EntityRoot", "ContextEntityRef", "ContextRootError", "CheckException", "CheckResult", "I18nCatalog", "JsonFieldNamingProfile", "Locale", "ObjectLocation", "UnsupportedLocaleError", "UserContext", "TeaqlRuntime", "SqlLogEntry", "SqlLogOperation", "DiagnosticSqlLogSink", "TextDiagnosticSqlLogSink", "ServiceRuntimeFromEnv", "RuntimeModule", "DataStore", "RawAuditEvent", "SafeAuditEvent", "MutationAuditKind", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"] +__all__ = ["WireFieldMetadata", "WireEntityMetadata", "NormalizedWireInput", "WireInputError", "create_wire_entity_metadata", "normalize_wire_input", "encode_wire_output", "retain_submitted_paths", "EntityKey", "EntityChangeSet", "EntityRoot", "ContextEntityRef", "ContextRootError", "CheckException", "CheckResult", "I18nCatalog", "JsonFieldNamingProfile", "Locale", "ObjectLocation", "UnsupportedLocaleError", "UserContext", "TeaqlRuntime", "SqlLogEntry", "SqlLogOperation", "DiagnosticSqlLogSink", "TextDiagnosticSqlLogSink", "ServiceRuntimeFromEnv", "RuntimeModule", "DataStore", "RawAuditEvent", "SafeAuditEvent", "MutationAuditKind", "BusinessClock", "FixedBusinessClock", "SystemBusinessClock", "ContextTools", "ExecutableHttpTool", "HTTP_TOOL", "HttpIntentPhase", "HttpTool", "HttpToolProvider", "ToolDeniedError", "ToolError", "ToolPolicy", "ToolRisk", "Tools", "ToolToken", "ToolUnavailableError"] __all__ += [ "MISSING_APPROVAL", "MISSING_POLICY", diff --git a/src/teaql/runtime/business_clock.py b/src/teaql/runtime/business_clock.py new file mode 100644 index 0000000..0c17e24 --- /dev/null +++ b/src/teaql/runtime/business_clock.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Protocol + + +class BusinessClock(Protocol): + """Supplies time for domain behavior and Checker/Fix decisions.""" + + def now(self) -> datetime: + ... + + +class SystemBusinessClock: + """Default context-owned clock backed by the UTC system clock.""" + + def now(self) -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True) +class FixedBusinessClock: + """Deterministic clock for tests and replay.""" + + value: datetime + + def now(self) -> datetime: + return self.value diff --git a/src/teaql/runtime/context.py b/src/teaql/runtime/context.py index 0c3d0ca..f582c4f 100644 --- a/src/teaql/runtime/context.py +++ b/src/teaql/runtime/context.py @@ -3,6 +3,9 @@ import contextvars from dataclasses import dataclass from array import array +from datetime import date, datetime + +from .business_clock import BusinessClock, SystemBusinessClock TEntity = TypeVar("TEntity") @@ -105,6 +108,7 @@ def __init__(self): self._fix_evidence_current: List[FixEvidence] = [] self._fix_evidence_last: List[FixEvidence] = [] self._checked_mutations = set() + self._business_clock: BusinessClock = SystemBusinessClock() from .mutation_policy import MutationPolicyRuntimeState self._mutation_policy = MutationPolicyRuntimeState() @@ -164,7 +168,7 @@ async def execute_graph_save(self, work): self._mutation_policy.begin_graph() self._graph_commit_actions = [] self._graph_rollback_actions = [] - self.insert_resource("fix_time", datetime.now()) + self.insert_resource("fix_time", self.business_time()) self.begin_fix_evidence() self.insert_resource("dataService", transaction) try: @@ -585,10 +589,9 @@ def check_and_fix_mutation(self, mutation: Any): record = getattr(mutation, "values", None) if not entity or record is None: return - from datetime import datetime owns_fix_time = self.get_resource("fix_time") is None if owns_fix_time: - self.insert_resource("fix_time", datetime.now()) + self.insert_resource("fix_time", self.business_time()) self.begin_fix_evidence() self.insert_resource("fix_operation", type(mutation).__name__.replace("Command", "").lower()) try: @@ -599,6 +602,20 @@ def check_and_fix_mutation(self, mutation: Any): self.finish_fix_evidence() self._resources.pop("fix_operation", None) + def with_business_clock(self, clock: BusinessClock) -> 'UserContext': + if clock is None or not callable(getattr(clock, "now", None)): + raise TypeError("business clock must provide now()") + self._business_clock = clock + return self + + def business_time(self) -> datetime: + """Return the date-time used by business logic.""" + return self._business_clock.now() + + def business_date(self) -> date: + """Derive a date from the same context-owned business clock.""" + return self.business_time().date() + def mark_mutation_checked(self, mutation: Any) -> None: self._checked_mutations.add(id(mutation)) diff --git a/tests/runtime/test_runtime.py b/tests/runtime/test_runtime.py index 6c92975..32124f5 100644 --- a/tests/runtime/test_runtime.py +++ b/tests/runtime/test_runtime.py @@ -1,6 +1,7 @@ import os +from datetime import date, datetime, timezone import pytest -from teaql.runtime import ContextEntityRef, ContextRootError, CheckException, CheckResult, RuntimeModule, UserContext, TeaqlRuntime, ServiceRuntimeFromEnv +from teaql.runtime import ContextEntityRef, ContextRootError, CheckException, CheckResult, FixedBusinessClock, RuntimeModule, UserContext, TeaqlRuntime, ServiceRuntimeFromEnv from teaql.core.mutation import InsertCommand from teaql.runtime.audit import AuditFieldChange, MutationAuditKind, RawAuditEvent @@ -184,7 +185,9 @@ def check_and_fix(self, context, record, location, results): observed.append(context.require_resource("fix_time")) provider = RecordingTransactionProvider(events) + expected = datetime(2026, 10, 1, 9, 30, 15, tzinfo=timezone.utc) context = (UserContext.new() + .with_business_clock(FixedBusinessClock(expected)) .insert_resource("dataService", provider) .with_checker_registry(DummyCheckerRegistry(ClockChecker()))) @@ -195,10 +198,27 @@ async def graph(): await context.execute_graph_save(graph) assert len(observed) == 2 and observed[0] is observed[1] + assert observed[0] == expected assert context.get_resource("fix_time") is None assert events == ["begin", "commit"] +def test_fixed_business_clock_drives_time_and_date(): + expected = datetime(2026, 10, 1, 23, 45, 30, tzinfo=timezone.utc) + context = UserContext.new().with_business_clock(FixedBusinessClock(expected)) + + assert context.business_time() == expected + assert context.business_date() == date(2026, 10, 1) + + +def test_system_business_clock_is_default(): + before = datetime.now(timezone.utc) + actual = UserContext.new().business_time() + after = datetime.now(timezone.utc) + + assert before <= actual <= after + + @pytest.mark.asyncio async def test_independent_concurrent_graph_saves_do_not_join_transaction(): events = []