Time is a resource
Everything the book has built so far changes only when something writes. A Sink plays a
mark, the kernel cuts the stored cell’s golden thread, and whatever was computed from that
cell is computed again the next time somebody asks. Nothing changes on its own.
Time does. The answer to “what time is it?” goes out of date with nobody writing anything, so a golden thread cannot say when to let it go. The kernel has a second way to let an answer go, and this chapter is about it: expiry. A representation can say keep me until this instant, and the kernel serves it from its cache until then and not a millisecond longer.
The chapter builds a clock, a date, a named event and a countdown to it, and ends with a clock on the page that asks for the time every second. The lesson is in how often the kernel actually does any work when it is asked that often.
The model, on paper
As in the tic-tac-toe parts, the names come first:
| the set | its name | what it is |
|---|---|---|
| now | urn:iki:tutorial:time:now | the time of day, HH:MM, read from the kernel’s clock; good until the next minute |
| today | urn:iki:tutorial:time:today | the date, YYYY-MM-DD; good until the next midnight |
| an event | urn:iki:tutorial:time:event:{name} | a named instant, YYYY-MM-DDTHH:MMZ, set with Sink |
| the instant | urn:iki:tutorial:time:instant | today and now as one reading, YYYY-MM-DDTHH:MMZ; good until the next minute |
| a countdown | urn:iki:tutorial:time:until:{name} | minutes from now until the event |
| a template | urn:iki:tutorial:time:template:{name} | the chapter’s HTML |
| the clock view | urn:iki:tutorial:time:view:clock | the time as HTML, for a page to show |
Only the event holds state: it is the one name a Sink writes, and it holds an instant
until somebody moves it. now and today hold nothing. They read the clock, and the clock
is not theirs.
/// The time of day, `HH:MM`, UTC — until the next minute.
pub const NOW: &str = "urn:iki:tutorial:time:now";
/// The date, `YYYY-MM-DD`, UTC — until the next midnight.
pub const TODAY: &str = "urn:iki:tutorial:time:today";
/// The current minute as one instant, `YYYY-MM-DDTHH:MMZ`, never a torn reading of `today`
/// and `now` — until the next minute.
pub const INSTANT: &str = "urn:iki:tutorial:time:instant";
/// A named instant, `YYYY-MM-DDTHH:MMZ`: the chapter's one piece of state.
pub const EVENT: &str = "urn:iki:tutorial:time:event:{name}";
/// Whole minutes from now until the event named `name`; negative once it has passed.
pub const UNTIL: &str = "urn:iki:tutorial:time:until:{name}";
/// A piece of the chapter's HTML.
pub const TEMPLATE: &str = "urn:iki:tutorial:time:template:{name}";
/// The clock as HTML: the `clock` template, composed.
pub const VIEW_CLOCK: &str = "urn:iki:tutorial:time:view:clock";
All the times in this chapter are UTC. A time zone is not part of what time it is; it is part of where the person asking is standing, which makes it context rather than a name, and this chapter leaves it out.
A kernel has a clock only if its host gives it one
The core crate reads no clock of its own. A host that wants its kernel to know the time
injects a clock with Kernel::with_clock, the way it injects a space or a Meta renderer,
and an endpoint asks for the time with Invocation::now. So “what time is it?” is a
question the host answers, which is what lets a test answer it with a clock that stands
still, and a replay answer it with the instant being replayed.
Almost every page of this book runs a kernel with no clock. That is why a trace
in What resolution buys you prints — where a duration
would go. This page asks for one, and so do A spreadsheet, IV, whose
cells read the time, and A spreadsheet, VI, which keeps a story clock so its
week of history reads the same for every reader. This page’s markup carries a data-clock attribute, and when the
page sees it, it builds its kernel with the browser’s clock before anything resolves:
/// [`page_kernel`], with `clock` if there is one: the browser's on a page that asked for
/// it, a clock a test holds in a test.
pub fn page_kernel_with_clock(clock: Option<Arc<dyn Clock>>) -> Kernel {
page_kernel_over(Arc::default(), Arc::default(), clock)
}
/// The browser's clock: `Date.now()`, milliseconds since 1970. `std::time` has no clock on
/// `wasm32-unknown-unknown`, and ikigai-core reads none itself, so the page supplies one,
/// the way `ikigai-web-demo` does.
#[cfg(target_family = "wasm")]
struct BrowserClock;
#[cfg(target_family = "wasm")]
impl Clock for BrowserClock {
fn now(&self) -> ikigai_core::Time {
ikigai_core::Time::from_millis(js_sys::Date::now() as u64)
}
}
Without a clock, the time endpoints do not guess. They refuse, and say why:
/// The kernel's time, or a refusal that says why there is none.
///
/// [`Invocation::now`] is the kernel's clock as this request should see it, and a kernel
/// has one only when its host injected one with `Kernel::with_clock`. Without one there is
/// no honest answer to "what time is it", so there is none: the endpoint refuses rather
/// than read the wall clock behind the host's back.
fn clock(inv: &Invocation<'_>) -> Result<Time> {
inv.now().ok_or_else(|| {
Error::Endpoint(
"this kernel has no clock: its host did not give it one (Kernel::with_clock)"
.to_string(),
)
})
}
On any other page of this book, source urn:iki:tutorial:time:now answers with that
refusal.
Now, until the next minute
/// `time-now`: the time of day, `HH:MM`, UTC.
///
/// Cacheable until the next minute boundary, and not a millisecond longer: the answer is
/// right for exactly that long. A caller may ask every second; the kernel answers from its
/// cache until the deadline and runs this again once it has passed.
pub fn now() -> FnEndpoint {
FnEndpoint::new("time-now", |inv: &Invocation<'_>| {
let minute = clock(inv)?.as_millis() / MINUTE_MS;
let text = format!("{:02}:{:02}", (minute / 60) % 24, minute % 60);
let next_minute = Time::from_millis((minute + 1) * MINUTE_MS);
Ok(Representation::new(text_plain_utf8(), text.into_bytes()).cacheable_until(next_minute))
})
.with_description(
Description::new("time-now")
.title("Now")
.summary("The time of day, HH:MM, UTC, by the kernel's clock. Cacheable until the next minute.")
.verb(Verb::Source)
.verb(Verb::Meta)
.output(TEXT_PLAIN_UTF8),
)
}
The last line is the whole idea. The time is 14:05 from 14:05:00 to 14:05:59.999, so the
answer says so: cacheable_until the start of the next minute. That is an Expiry::At, a
deadline, and the kernel checks it against the same clock the answer was read from.
Ask twice. Your times will be your own; the outputs under each cell are from a test that stopped a clock at 14:05:20 UTC on 29 September 2026.
14:05 [computed] 14:05 [cached]
The second answer ran nothing. Now wait for the minute to turn, and ask whether the kernel still holds it:
not cached 14:06 [computed]
Nobody wrote anything and no thread was cut. The deadline passed, so the answer went. The crate’s tests put the boundary exactly where the code says it is, with a clock the test holds and moves by hand:
#[test]
fn now_is_cached_until_the_minute_turns_and_not_a_millisecond_longer() {
let (kernel, clock) = host(millis_at("2026-09-29T14:05Z", 20));
assert_eq!(source(&kernel, NOW).unwrap(), "14:05");
assert_eq!(
expiry(&kernel, NOW),
Expiry::At(Time::from_millis(millis_at("2026-09-29T14:06Z", 0)))
);
clock.set(millis_at("2026-09-29T14:05Z", 59) + 999);
assert!(cached(&kernel, NOW), "one millisecond before the minute");
clock.advance(1);
assert!(!cached(&kernel, NOW), "on the minute");
assert_eq!(source(&kernel, NOW).unwrap(), "14:06");
}
today is the same resource with a longer deadline: the date, cacheable until the next
midnight.
2026-09-29 [computed] cached
An event, and a countdown to it
The event is the atom: a map from a name to an instant, in memory, like the tic-tac-toe
stored cell. Sink takes an instant spelled YYYY-MM-DDTHH:MMZ, or +N for N minutes from
now, and answers the instant it now holds. +90 does not store “ninety minutes from
whenever you ask”; it reads the time once, when you set it, and stores where that lands.
The countdown is a composite. It reads the event, today and now, all through the
kernel, and subtracts:
/// `time-until`: whole minutes from now until the event, negative once it has passed.
///
/// A composite over three names, all read through the kernel: the event, `today` and
/// `now`. It declares itself `.cacheable()`, with no deadline, and says nothing else about
/// time. The kernel does the rest: a result is never fresher than its least fresh
/// dependency, so this one is cached until `now`'s deadline, the next minute, and cut
/// early by the event's golden thread when the event is moved.
pub fn until() -> AsyncFnEndpoint {
AsyncFnEndpoint::new("time-until", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
Box::pin(async move {
let name = event_binding(inv)?;
let at = text_of(inv, &event_name(&name)).await?;
let at = read_back(&event_name(&name), &at, parse_instant(&at))?;
let minutes = at - this_minute(inv).await?;
Ok(
Representation::new(text_plain_utf8(), minutes.to_string().into_bytes())
.cacheable(),
)
})
})
.with_description(
Description::new("time-until")
.title("Until")
.summary("Whole minutes from now until the event; negative once it has passed.")
.verb(Verb::Source)
.verb(Verb::Meta)
.input(name_input())
.output(TEXT_PLAIN_UTF8),
)
}
It says .cacheable(), with no deadline, as if its answer were good forever. Set an event
ninety minutes out and count down to it:
2026-09-29T15:36Z [uncacheable] 90 [computed] 90 [cached]
Now let the minute turn again, and ask:
not cached cached 89 [computed]
The countdown went at the minute, and the event it was computed from did not. Nothing in
the countdown’s code mentions a minute. The kernel met the countdown’s own expiry, never,
with the expiry of everything it read, and took the soonest: today’s midnight, now’s
next minute, and the event’s never come to now’s next minute. A composite is never
fresher than the least fresh thing it read. ikigai’s own working rules put it as
cacheability is only as good as the least cacheable dependency, and here it is working
for you: the countdown is exactly as cacheable as now, and not because anybody said so.
The rule has a second edge, and it cuts the other way. Add a clock to a composite that used to be cached for good, and it is cached for a minute from then on, with nothing in its code or its types to show for it. That is a performance change, even when it is not a correctness one, and the only way to see it is to measure the read.
The event’s golden thread still works as it always did. Move the event, and the countdown
goes at once, in the middle of the minute, while now stays cached:
2026-09-29T14:37Z [uncacheable] not cached cached 30 [computed]
Two ways for an answer to go, then, and a composite gets both from what it reads: a thread that a write cuts, and a deadline that a clock passes.
#[test]
fn a_countdown_is_exactly_as_cacheable_as_now() {
let (kernel, clock) = host(millis_at("2026-09-29T14:06Z", 10));
let (event, until) = (event_name("tea"), until_name("tea"));
assert_eq!(sink(&kernel, &event, "+90").unwrap(), "2026-09-29T15:36Z");
assert_eq!(source(&kernel, &until).unwrap(), "90");
// It declared no deadline. It has now's.
assert_eq!(expiry(&kernel, &until), expiry(&kernel, NOW));
assert!(matches!(expiry(&kernel, &until), Expiry::At(_)));
// The minute turns: the countdown goes with now, and comes back one less.
clock.set(millis_at("2026-09-29T14:07Z", 0));
assert!(!cached(&kernel, &until));
assert_eq!(source(&kernel, &until).unwrap(), "89");
assert!(cached(&kernel, &until));
// The event moves: its golden thread goes, and the countdown with it, mid-minute.
sink(&kernel, &event, "2026-09-29T14:37Z").unwrap();
assert!(!cached(&kernel, &until));
assert!(
cached(&kernel, NOW),
"moving the event does not touch the clock"
);
assert_eq!(source(&kernel, &until).unwrap(), "30");
}
Two readings of one clock
There is one trap in reading the date and the time as two resources, and it is worth a
paragraph because every composition over time meets it. They are read at two different
instants. Read today at 23:59:59.999 and now a millisecond later, and you have
yesterday’s date with today’s time: a countdown a whole day out. It is not cached a day
out: the date it read expired at the midnight it straddled, so the kernel never serves that
answer again. But it has already been served once, wrong.
So the countdown reads the date on both sides of the time. If the date is the same both times, the time was read on that date:
/// The current minute as one instant, read from `today` and `now` through the kernel.
///
/// Two resources, two deadlines, two reads: `today` can be read on one side of midnight and
/// `now` on the other, and the pair would name a minute a day away from the real one. So
/// the date is read on both sides of the time. If it is the same both times, the time was
/// read on that date; if it is not, midnight fell between the reads, and one more try
/// cannot meet another midnight.
async fn this_minute(inv: &Invocation<'_>) -> Result<i64> {
for _ in 0..2 {
let date = text_of(inv, TODAY).await?;
let time = text_of(inv, NOW).await?;
if text_of(inv, TODAY).await? == date {
let day = read_back(TODAY, &date, parse_date(&date))?;
let of_day = read_back(NOW, &time, parse_time(&time))?;
return Ok(day * MINUTES_PER_DAY + of_day);
}
}
Err(Error::Endpoint(
"the date changed twice while one minute was read".to_string(),
))
}
The test for it starts a clock that moves on a millisecond every time anybody reads it, just before midnight, two hundred times over, so that some resolution straddles the boundary. With the second read of the date taken out, it fails, a day out; with it, it passes.
A guard that every composite has to remember is a guard some composite will forget, so the
crate also gives the guarded reading a name of its own, urn:iki:tutorial:time:instant: the
current minute as one instant, YYYY-MM-DDTHH:MMZ. Whatever wants the date and the time
together reads that one name and cannot tear them. It declares no deadline of its own, and is
cached until the next minute, which it inherits from now:
/// `time-instant`: the current minute as one instant, `YYYY-MM-DDTHH:MMZ`.
///
/// The guarded reading of `this_minute`, given a name, so that anything wanting "the date
/// and the time" reads one resource rather than two and cannot tear them. Declared
/// `.cacheable()` with no deadline; it read `now`, so the kernel caches it until the next
/// minute, and not a millisecond longer.
pub fn instant() -> AsyncFnEndpoint {
AsyncFnEndpoint::new("time-instant", |inv: &Invocation<'_>| -> InvokeFuture<'_> {
Box::pin(async move {
let minute = this_minute(inv).await?;
Ok(
Representation::new(text_plain_utf8(), instant_text(minute).into_bytes())
.cacheable(),
)
})
})
.with_description(
Description::new("time-instant")
.title("Instant")
.summary(
"The current minute as one instant, YYYY-MM-DDTHH:MMZ, UTC: the date and the \
time read so that they agree. Cacheable until the next minute.",
)
.verb(Verb::Source)
.verb(Verb::Meta)
.output(TEXT_PLAIN_UTF8),
)
}
The spreadsheet’s NOW() reads it, in A spreadsheet, IV.
A page that polls
Here is the clock. It is paused until you start it, so the cells above answer as they say; press Poll and it asks the kernel for the time once a second, and counts what that cost.
The clock appears here when the in-page kernel has loaded.
Watch the count. The page asks sixty times a minute, and the kernel computes the time once: the first request after each minute boundary. The other fifty-nine are answered from the cache, which is a lookup, not a computation. So a poll is cheap when the answer is cached, and the thing that decides when work happens is the answer’s expiry, not how often anybody asks. Poll every tenth of a second and the kernel still computes the time once a minute.
The same thing as a test, with a clock the test moves one second at a time:
/// The page's poll, as a test: the clock view asked once a second for a minute. The cache
/// answers 59 of the 60; the one computation is the first, and the next one waits for the
/// minute, however often anybody asks.
#[test]
fn a_poll_every_second_computes_once_a_minute() {
let (kernel, clock) = host(millis_at("2026-09-29T14:05Z", 0));
let mut computed = 0;
for _ in 0..60 {
if !cached(&kernel, VIEW_CLOCK) {
computed += 1;
}
source(&kernel, VIEW_CLOCK).unwrap();
clock.advance(1000);
}
assert_eq!(computed, 1);
assert!(
!cached(&kernel, VIEW_CLOCK),
"14:06:00, and the minute has turned"
);
assert_eq!(
source(&kernel, VIEW_CLOCK).unwrap(),
"<time datetime=\"14:06\">14:06</time>"
);
}
The markup
The clock’s markup is two templates, served as resources like tic-tac-toe’s. The one the page loads is the poller:
<p class="time-clock" hx-get="iki/tutorial/time/view/clock" hx-trigger="load, every 1s" hx-target="find .time-clock-now">It is <span class="time-clock-now">--:--</span> UTC.</p>
It asks for iki/tutorial/time/view/clock on load and every second after, by the same
path rule as the board in Tic-tac-toe, V, and puts the answer into the
<span> inside it: hx-target names a child, not the element itself. The web demo’s
first poller targeted itself, and its host processed the swap target again after every
answer, which broke the poll cycle. A child is never the element htmx polls from, so
whatever a host does with the target, the poller is left alone and only what it shows
changes.
The view is the other template, and it has no view code of its own to speak of:
<time datetime="$h{urn:iki:tutorial:time:now}">$h{urn:iki:tutorial:time:now}</time>
A $h{…} marker names a resource, and the generic composer from ikigai-fn resolves every
name it finds and splices the answer in, escaped for HTML. That is the template language
Tic-tac-toe, V teaches, and the clock uses the smallest
part of it. The template names what it shows, so nothing fills it from code, and the view is the
template bound at a name of its own with compose_over, the way the game’s board and squares are:
/// `urn:iki:tutorial:time:view:clock`: the `clock` template, bound at a name of its own.
///
/// The template names the resource it shows (`$h{urn:iki:tutorial:time:now}`), and compose
/// resolves every name it finds and splices the answer in. `compose_over` is compose with
/// the template fixed, bound at the view's name, so the view has no code of its own.
pub fn view_clock() -> ComposeOver {
ikigai_fn::compose_over(
Iri::parse(template_name("clock")).expect("a template's name is a name"),
)
}
<time datetime="14:07">14:07</time> [computed] cached
Compose knows nothing about time. It declares its answer cacheable and lets the kernel
meet that with what it read, so the view expires with now, like the countdown.
There is no view code: compose_over is the composer with its template fixed, so binding it at
view:clock makes the view a resource like any other, with nothing of its own to run. (Before
ikigai-fn 0.3.0 the view was one request of glue, because a name could not carry the argument
that tells compose which template to fill.)
$h escapes what it splices. Here that changes nothing, since the only thing spliced is the
kernel’s own clock, but it is the marker to reach for by default: a value that is text somebody
else chose, a tic-tac-toe mark or a spreadsheet cell, must never be spliced in as markup.
What was built
| the set | the name | what it is |
|---|---|---|
| now, today | urn:iki:tutorial:time:now, …:time:today | code: small functions of the kernel’s clock, each with a deadline |
| the instant | urn:iki:tutorial:time:instant | code: the guarded reading of today and now, with now’s deadline inherited |
| an event | urn:iki:tutorial:time:event:{name} | code: the one atom |
| a countdown | urn:iki:tutorial:time:until:{name} | code: a small function of three names, with its deadline inherited |
| templates | urn:iki:tutorial:time:template:{name} | resources: HTML, one of which names what it shows |
| the clock view | urn:iki:tutorial:time:view:clock | a template bound at a name: no code |
Nothing here polls on the server side, nothing sets a timer, and nothing invalidates a cache. The page asks as often as it likes; each answer says how long it is good for; the kernel does the arithmetic. What is left for later is the time zone, which is context rather than a name, and making things happen at a time rather than answering when asked.