Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions examples/business-clock/README.md
Original file line number Diff line number Diff line change
@@ -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
```
11 changes: 11 additions & 0 deletions examples/business-clock/main.py
Original file line number Diff line number Diff line change
@@ -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")
3 changes: 2 additions & 1 deletion src/teaql/runtime/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions src/teaql/runtime/business_clock.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 20 additions & 3 deletions src/teaql/runtime/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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))

Expand Down
22 changes: 21 additions & 1 deletion tests/runtime/test_runtime.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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())))

Expand All @@ -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 = []
Expand Down
Loading