Skip to content

ADR-0014 — TimeProvider with an in-product manual clock

Status: Accepted Date: 2026-09-15

Context

Almost everything interesting about the canary is a time transition: a bucket rolls over, a sentinel leaves the fresh window, a stale horizon expires, a rate-limit window resets, an issuance record is evicted after its retention. Testing those by calling Thread.Sleep would make the suite slow and flaky, and the stale-horizon transition (two hours of wall clock by default) is untestable that way at all.

TimeProvider (BCL, .NET 8+) is the right abstraction. The conventional companion is Microsoft.Extensions.TimeProvider.Testing, which supplies FakeTimeProvider.

There is a second requirement that package does not serve. The cross-platform evidence in docs/testing/ has to be reproducible on a machine that has no test runner installed — the point of occam canary selftest is that it runs from the shipped AOT binary. A selftest that proves bucket transitions needs a settable clock inside the product assembly.

Decision

Take TimeProvider? in every canary constructor, defaulting to TimeProvider.System.

Ship CanaryManualClock : TimeProvider in the product assembly (OccamMcp.Core.Canary), not in the test project, and use it from both occam canary selftest and the xunit suite. No new package.

Its class comment states why it lives there, so the next reader does not "clean up" test infrastructure they find in production code.

Consequences

Good. The full state machine — including the two-hour stale horizon — is asserted in milliseconds, both by dotnet test and by the shipped binary. The cross-platform logs in docs/testing/*/canary-aot.log show the AOT binary proving the same 17 transitions the unit suite proves. One fewer dependency, and the repository rule "no new dependency without an ADR" is satisfied by not needing one.

Costs. A small amount of test-shaped code ships in the product assembly (about 30 lines, AOT trimmed only if unreferenced — it is referenced, so it ships). A reviewer may reasonably flag it; the comment is the answer.

Precedent. This is consistent with what the repository already does: CodecBench, PlannerBench and the OCCAM_GATE conditional all put verification affordances in the product assembly so the shipped binary can prove things about itself.

Alternatives considered

Microsoft.Extensions.TimeProvider.Testing. Rejected: it would cover the xunit suite and leave the shipped selftest unable to move a clock, which is the harder and more valuable half.

internal clock plus InternalsVisibleTo. Rejected: InternalsVisibleTo already exists here for L0Gate and Abr512, and extending that pattern hides the type from the CLI verb that needs it — the verb is in the same assembly, so this would work, but the type is genuinely part of the canary's public testing surface and pretending otherwise adds no safety.

Static mutable "now" hook. Rejected: process-global mutable time makes parallel test execution unsound, and xunit runs collections in parallel by default.