DEV Community

Russell Jones
Russell Jones

Posted on • Originally published at jonesrussell.github.io on

Building a temporal layer so your AI never lies about time

Ahnii!

Series context: This post builds on the Waaseyaa series. Claudriel is an AI personal operations system built on the Waaseyaa framework. You don't need to have read the earlier posts, but they cover the entity system and architecture that this temporal layer sits on top of.

Most applications treat time as a free function call. Need the current time? new DateTime(). Need it again three lines later? new DateTime() again. In a request that takes 200ms, nobody notices the two-millisecond difference between those calls.

An AI system that reasons about your schedule, detects drifting commitments, and nudges you before meetings does notice. If the commitment extractor captures "now" at 14:00:00.003 and the drift detector captures it at 14:00:00.217, you get inconsistent temporal reasoning. Worse, if the system clock drifts from reality and nobody checks, every time-based decision is quietly wrong.

This post covers Claudriel's Temporal subsystem: how it pins time per request, resolves the right timezone from context, and monitors clock health before letting agents reason about your schedule.

The Core Problem: Scattered Time Calls

The naive approach looks like this:

// In the commitment extractor
$extractedAt = new \DateTimeImmutable();

// 50ms later, in the drift detector
$checkedAt = new \DateTimeImmutable();

// These are different instants. Now your "simultaneous"
// checks disagree about what time it is.
Enter fullscreen mode Exit fullscreen mode

In isolation, the difference is trivial. But when four components in a single request each capture their own "now," you get four slightly different timestamps in the same response. Temporal agents comparing those timestamps draw wrong conclusions.

The fix is simple in concept: capture time once, share it everywhere.

AtomicTimeService

AtomicTimeService is the single source of time for any request. It captures a TimeSnapshot that bundles wall-clock time, monotonic time, and timezone into one immutable object.

final class AtomicTimeService
{
    public function now(
        ?string $scopeKey = null,
        ?\DateTimeZone $timezone = null,
    ): TimeSnapshot {
        if ($scopeKey === null) {
            return $this->captureSnapshot($timezone);
        }

        return $this->snapshotStore()->remember(
            $this->snapshotScopeKey($scopeKey, $timezone),
            fn () => $this->captureSnapshot($timezone),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

When you pass a $scopeKey, the service captures the snapshot once and returns the same instance for every subsequent call with that key. No scope key means a fresh capture every time, which is useful for benchmarking or logging where you want the actual current instant.

The TimeSnapshot itself is a value object:

final class TimeSnapshot
{
    public function __construct(
        private readonly \DateTimeImmutable $capturedAtUtc,
        private readonly \DateTimeImmutable $capturedAtLocal,
        private readonly int $monotonicNanoseconds,
        private readonly string $timezone,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

UTC and local time are both captured at construction. Monotonic nanoseconds come from hrtime(), which is immune to NTP adjustments and clock corrections. You get wall time for display and monotonic time for duration calculations, both from the same instant.

RequestTimeSnapshotStore

The scoping mechanism is RequestTimeSnapshotStore, an in-memory map that lives for the duration of a single request.

final class RequestTimeSnapshotStore
{
    /** @var array<string, TimeSnapshot> */
    private array $snapshots = [];

    public function remember(string $scopeKey, callable $resolver): TimeSnapshot
    {
        if (!isset($this->snapshots[$scopeKey])) {
            $this->snapshots[$scopeKey] = $resolver();
        }

        return $this->snapshots[$scopeKey];
    }
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally simple. The store is not a cache, not a singleton, not a service locator. It holds snapshots for one request and gets garbage collected when the request ends. The remember pattern means the first component to ask for time in a given scope defines it for everyone else.

TimezoneResolver

An AI system that handles your calendar needs to know your timezone. But "your timezone" depends on context. Are you looking at a workspace configured for America/Toronto? Did the API request include an explicit timezone header? Does your account have a preference set?

TimezoneResolver walks a priority chain:

final class TimezoneResolver
{
    public function resolve(
        mixed $account = null,
        mixed $workspace = null,
        ?string $requestTimezone = null,
    ): ResolvedTimezone {
        // Resolution order:
        // 1. Explicit request override
        // 2. Workspace timezone
        // 3. Workspace metadata/settings
        // 4. Account timezone
        // 5. Account metadata/preferences/settings
        // 6. Default (UTC)
    }
}
Enter fullscreen mode Exit fullscreen mode

The resolver returns a ResolvedTimezone that carries both the DateTimeZone and a source string indicating where it came from ('request', 'workspace.timezone', 'account.settings.timezone', 'default'). This matters for debugging. When a user says "my times are wrong," you can check the resolution source and trace exactly where the timezone was picked up.

The resolver accepts mixed types for account and workspace because it needs to work with entity objects, arrays, and anything else that might carry timezone data. It probes fields and nested paths without assuming a specific object shape.

TemporalContextFactory

TemporalContextFactory ties the pieces together. Given a scope key, tenant, workspace, and optional account, it resolves the timezone and captures a snapshot in one call:

final class TemporalContextFactory
{
    public function snapshotForInteraction(
        string $scopeKey,
        ?string $tenantId = null,
        ?string $workspaceUuid = null,
        mixed $account = null,
        ?string $requestTimezone = null,
    ): TimeSnapshot {
        $workspace = $this->resolveWorkspace($workspaceUuid, $tenantId);
        $timezone = $this->timezoneResolver()
            ->resolve($account, $workspace, $requestTimezone)
            ->timezone();

        return $this->timeService()->now($scopeKey, $timezone);
    }
}
Enter fullscreen mode Exit fullscreen mode

Controllers and commands call snapshotForInteraction() once at the start of a request. Everything downstream receives the resulting TimeSnapshot as a dependency. No component further down the chain calls new DateTime() or asks what time it is. They already know.

ClockHealthMonitor

The temporal layer's most unusual component is ClockHealthMonitor. Before letting temporal agents reason about your schedule, Claudriel checks whether the system clock is trustworthy.

final class ClockHealthMonitor
{
    public function assess(string $referenceSource = 'reference-clock'): array
    {
        $sync = $this->syncProbe->read();
        $appNow = $this->timeService->wallNow(new \DateTimeZone('UTC'));
        $referenceNow = $this->referenceClock->now();
        $driftSeconds = abs($referenceNow->getTimestamp() - $appNow->getTimestamp());
        $unsafe = !$sync->synchronized()
            || $driftSeconds > $this->unsafeDriftThresholdSeconds;

        return [
            'state' => $unsafe ? 'unsafe' : 'healthy',
            'safe_for_temporal_reasoning' => !$unsafe,
            'drift_seconds' => $driftSeconds,
            'fallback_mode' => $unsafe ? 'wall-clock-only' : 'none',
            // ...
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The monitor compares the application's wall clock against a reference clock and checks NTP synchronization status via ClockSyncProbeInterface. If drift exceeds the threshold (default: 5 seconds), it marks the state as unsafe and sets safe_for_temporal_reasoning to false. Downstream agents check this flag. An agent that would normally say "your meeting starts in 3 minutes" stays quiet if it can't trust the clock.

Temporal Agents

The TemporalGuidanceAssembler is where clock health meets schedule awareness. It takes a day brief (your schedule, gaps, overruns) and a TimeSnapshot, runs both through a set of specialized agents, and produces notifications:

  • OverrunAlertAgent: flags when a meeting has gone past its end time
  • ShiftRiskAgent: warns when back-to-back blocks leave no buffer
  • WrapUpPromptAgent: nudges you to wrap up before the next block
  • UpcomingBlockPrepAgent: gives you a heads-up to prepare for what's next

Each agent receives the same TimeSnapshot. They all agree on what "now" is. The orchestrator filters their output through a delivery service that deduplicates and manages notification state, so you don't get the same "wrap up" nudge every time the brief refreshes.

Testing Without Real Clocks

Every component accepts its clock as a constructor dependency. WallClockInterface and MonotonicClockInterface have system implementations and test doubles:

$fixedWall = new class implements WallClockInterface {
    public function now(): \DateTimeImmutable {
        return new \DateTimeImmutable('2026-03-16T14:00:00Z');
    }
};

$service = new AtomicTimeService(
    wallClock: $fixedWall,
    monotonicClock: new FixedMonotonicClock(1_000_000_000),
);

$snapshot = $service->now('test-scope');
// Always 2026-03-16T14:00:00Z, always 1 second monotonic
Enter fullscreen mode Exit fullscreen mode

No global state. No mocking frameworks. Inject the clock, control the time. Tests for temporal agents can simulate "it's 2 minutes before your next meeting" by constructing the right snapshot and clock health state, then asserting the agent produces the expected notification.

Common Mistakes

Reaching for now() directly instead of using the scoped snapshot:

// Bad: bypasses the temporal layer entirely
$deadline = new \DateTimeImmutable();
$snapshot = $this->timeService->now('request');
// $deadline and $snapshot->utc() are different instants
Enter fullscreen mode Exit fullscreen mode
// Good: derive everything from the snapshot
$snapshot = $this->timeService->now('request');
$deadline = $snapshot->utc();
Enter fullscreen mode Exit fullscreen mode

The second version guarantees every timestamp in the request is consistent. The first introduces the exact drift the temporal layer exists to prevent.

Ignoring clock health before acting on temporal data:

// Bad: assumes the clock is trustworthy
$minutes = $snapshot->minutesUntil($nextMeeting);
$this->notify("Meeting in {$minutes} minutes");
Enter fullscreen mode Exit fullscreen mode
// Good: check health first
$health = $this->clockHealth->assess();
if (!$health['safe_for_temporal_reasoning']) {
    return; // suppress time-sensitive notifications
}
$minutes = $snapshot->minutesUntil($nextMeeting);
$this->notify("Meeting in {$minutes} minutes");
Enter fullscreen mode Exit fullscreen mode

If the system clock has drifted, that "3 minutes" could be wrong. The health check costs almost nothing and prevents confidently wrong notifications.

Framework Integration

To wire the temporal layer into a Laravel application, you register the components in a service provider:

// TemporalServiceProvider.php
public function register(): void
{
    $this->app->singleton(WallClockInterface::class, SystemWallClock::class);
    $this->app->singleton(MonotonicClockInterface::class, SystemMonotonicClock::class);
    $this->app->singleton(AtomicTimeService::class);
    $this->app->scoped(RequestTimeSnapshotStore::class);
    $this->app->singleton(TimezoneResolver::class);
    $this->app->singleton(ClockHealthMonitor::class);
    $this->app->singleton(TemporalContextFactory::class);
}
Enter fullscreen mode Exit fullscreen mode

The key binding is RequestTimeSnapshotStore as scoped. Laravel creates a fresh instance per request and discards it when the request ends. Everything else is a singleton — stateless services that can be shared safely.

A middleware layer captures the initial snapshot at the start of each request:

public function handle(Request $request, Closure $next): Response
{
    $this->temporalContext->snapshotForInteraction(
        scopeKey: 'request',
        tenantId: $request->header('X-Tenant-Id'),
        workspaceUuid: $request->route('workspace'),
        account: $request->user(),
        requestTimezone: $request->header('X-Timezone'),
    );

    return $next($request);
}
Enter fullscreen mode Exit fullscreen mode

By the time your controller runs, the snapshot is already captured. Controllers and jobs inject AtomicTimeService and call now('request') to get the pre-captured snapshot — they never need to think about time resolution themselves.

Try It Yourself

The temporal layer lives in the jonesrussell/claudriel package. To explore the code:

git clone https://github.com/jonesrussell/claudriel.git
cd claudriel
composer install
composer test -- --filter=Temporal
Enter fullscreen mode Exit fullscreen mode

The test suite includes fixed-clock scenarios that demonstrate scoped snapshots, timezone resolution, and clock health assessment. Read through tests/Unit/Temporal/ to see how each component is tested without real clocks.

Why This Matters for AI Systems

Traditional web apps can tolerate sloppy time handling. A blog post timestamped 200ms off doesn't matter. But AI systems that reason about your schedule, detect patterns in your behavior, and make proactive suggestions need temporal consistency the same way financial systems need transactional consistency.

The temporal layer is seven classes. It adds no external dependencies. The entire subsystem is injectable and testable. The cost of getting time right is low. The cost of getting it wrong is an AI assistant that confidently tells you the wrong thing about your own schedule.

Next: Deny-unless-granted: access control in waaseyaa.

Baamaapii

Top comments (0)