Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 setits namewhat it is
nowurn:iki:tutorial:time:nowthe time of day, HH:MM, read from the kernel’s clock; good until the next minute
todayurn:iki:tutorial:time:todaythe date, YYYY-MM-DD; good until the next midnight
an eventurn:iki:tutorial:time:event:{name}a named instant, YYYY-MM-DDTHH:MMZ, set with Sink
the instanturn:iki:tutorial:time:instanttoday and now as one reading, YYYY-MM-DDTHH:MMZ; good until the next minute
a countdownurn:iki:tutorial:time:until:{name}minutes from now until the event
a templateurn:iki:tutorial:time:template:{name}the chapter’s HTML
the clock viewurn:iki:tutorial:time:view:clockthe 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 setthe namewhat it is
now, todayurn:iki:tutorial:time:now, …:time:todaycode: small functions of the kernel’s clock, each with a deadline
the instanturn:iki:tutorial:time:instantcode: the guarded reading of today and now, with now’s deadline inherited
an eventurn:iki:tutorial:time:event:{name}code: the one atom
a countdownurn:iki:tutorial:time:until:{name}code: a small function of three names, with its deadline inherited
templatesurn:iki:tutorial:time:template:{name}resources: HTML, one of which names what it shows
the clock viewurn:iki:tutorial:time:view:clocka 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.