A spreadsheet, VI: a sheet is a space, and spaces stack
Five parts have composed resources: a value is a function of other values, a view is a template
over names, and the kernel keeps the dependencies. This part composes the context the names are
read in. The same name, urn:iki:tutorial:sheet:cell:B2, answers one thing on the shared sheet,
another in the sheet one person is playing with (“what if we sold twelve?”), and a third in the
sheet as it stood last Tuesday. None of the three is a copy of the sheet, and not one line of the
code that computes a value knows which of them it is computing in.
Each is a corridor: a space pushed in front of the shared names for the length of a request, answering a few of them its own way and letting every other name through. Tic-tac-toe used one per game (Part IV of that arc). This part builds two kinds, a personal one and a temporal one, and then stacks them.
The model, on paper
One atom changes, and two corridors are added:
| the space | its name | what it answers | built from |
|---|---|---|---|
| the shared sheet | the root | every name, as Parts I–V built it | the input, which now keeps every write it was given |
| a person’s scenario | urn:iki:tutorial:sheet:scenario:{who} | input:{ref} for the cells that person overrode, and nothing else | a store of overrides, behind a capability of their own |
| the sheet as of an instant | urn:ctx:time:{instant} | every input:{ref} as it stood then; now and today at that instant | the input’s history, and the time chapter’s clock |
| both at once | a stack of the two | whatever the innermost corridor answers, then the next | Scope::stack |
A corridor’s name is not a resource: nothing binds it, and nothing resolves it. It is the name the request’s chain carries, and the cache files every answer computed in the corridor under it.
A clock the story keeps
This part is about “as of last Tuesday”, so its page cannot use your clock: on your page, last
Tuesday would be a date before anything here was typed, and every cell below would answer an empty
sheet. So the page keeps a story clock. Its markup carries data-clock='2026-09-22T12:00Z', an
instant where Part IV’s page carried a bare data-clock, and the page’s kernel is built with a
clock stopped there. A cell that says when it runs (data-at) moves the clock before it runs, and
says so beside its label:
/// Build this page's kernel with a STORY clock, stopped at `instant` (`YYYY-MM-DDTHH:MMZ`),
/// which the page moves when a cell says when it runs (`data-at`, [`set_story_clock`]).
///
/// For a chapter about time as context, where a cell reads the sheet as of last Tuesday: with
/// the browser's clock, "last Tuesday" would be a date before anything on the page was
/// typed, and every such cell would answer an empty sheet. With the story's, every reader's
/// page runs at the instants the chapter says, and answers what the chapter shows.
#[wasm_bindgen(js_name = useStoryClock)]
pub fn use_story_clock(instant: String) -> Result<(), JsValue> {
before_the_kernel()?;
let clock = Arc::new(ManualClock::at(story_millis(&instant)?));
STORY.with(|story| *story.borrow_mut() = Some(Arc::clone(&clock)));
CLOCK.with(|slot| *slot.borrow_mut() = Some(clock as Arc<dyn Clock>));
Ok(())
}
/// Move the story clock to `instant`: what `js/run.js` does before a cell that carries
/// `data-at` runs. Refused on a page with no story clock.
#[wasm_bindgen(js_name = setStoryClock)]
pub fn set_story_clock(instant: String) -> Result<(), JsValue> {
let millis = story_millis(&instant)?;
STORY.with(|story| match story.borrow().as_ref() {
Some(clock) => {
clock.set(millis);
Ok(())
}
None => Err(JsValue::from_str(
"this page has no story clock: its markup does not carry data-clock='<instant>'",
)),
})
}
A week of the sheet
It is Tuesday, 22 September, at noon. A price, a quantity and their product; a cost, and the
profit; and, in C1, the time:
100 [uncacheable] 10 [uncacheable] =A1*A2 [uncacheable] 600 [uncacheable] =A3-B1 [uncacheable] =NOW() [uncacheable] 400 [computed]
A week later, the price goes up:
110 [uncacheable] 500 [computed]
The sheet as it was
Since Part I the input has kept every write, each stamped with the kernel’s clock, and a clear is a write of nothing. Until now only the latest one mattered:
/// What has been typed into each cell, and when: every write, oldest first. The sheet's one
/// piece of state. A write is stamped with the kernel's clock (0 on a kernel with none), and a
/// clear is a write of nothing.
#[derive(Debug, Default)]
pub struct InputStore {
inputs: Mutex<BTreeMap<CellRef, Vec<Write>>>,
}
/// One write to a cell: when, in milliseconds since 1970, and what was typed (`None` clears).
#[derive(Clone, Debug)]
struct Write {
at: u64,
typed: Option<String>,
}
That history is all the temporal view needs from the sheet. The rest is a corridor. Inside it,
input:{ref} is answered from the history, as of the instant the corridor is pinned to:
/// `sheet-input-as-of`: what was typed into the cell `ref` at the instant the request's chain
/// is pinned to — the input read from the atom's history, not its latest write.
///
/// It is bound only inside a temporal corridor ([`as_of_doors`]), whose clock is that
/// instant, so [`Invocation::now`] is "then". The answer is cacheable and names no thread:
/// the kernel hangs it from its own name, `input:{ref}`, which every write to the cell cuts.
/// For an instant in the past that is more than it needs (a write now cannot change what the
/// cell held then), and never less.
///
/// The past is read-only: `Sink` and `Delete` are a `Conflict`, the state saying no.
pub fn input_as_of(store: Arc<InputStore>) -> FnEndpoint {
FnEndpoint::new("sheet-input-as-of", move |inv: &Invocation<'_>| {
let at = cell_binding(inv, "ref")?;
let then = inv.now().ok_or_else(|| {
Error::Endpoint(
"an input as of an instant needs the instant: bind it in a temporal corridor \
(Scope::with_named_at), whose clock says when"
.to_string(),
)
})?;
match inv.request.verb {
Verb::Sink | Verb::Delete => Err(Error::Conflict(format!(
"the sheet in `{}` is the sheet as it was, and the past does not change: \
write to the live sheet, or to a scenario",
inv.scope()
))),
_ => match store.as_of(at, then.as_millis()) {
Some(typed) => Ok(typed_repr(typed).cacheable()),
None => Err(Error::NotFound(format!(
"nothing had been typed into {at} by then"
))),
},
}
})
.with_description(
Description::new("sheet-input-as-of")
.title("Input, as of an instant")
.summary(
"What was typed into a cell at the instant this corridor is pinned to, from \
the input's history. Read-only.",
)
.verb(Verb::Source)
.verb(Verb::Meta)
.input(ref_input("the cell, A1 to D6"))
.output(TEXT_PLAIN_UTF8)
.output(FORMULA_TYPE),
)
}
/// The sheet's doors for a temporal corridor: `input:{ref}` answered from the history in
/// `store` (the live sheet's own store) as of the corridor's instant. A host hands these, with
/// the time chapter's, to whatever builds its `as-of=` corridors. Nothing else in the sheet
/// changes: every other name reads the inputs, and in this corridor the inputs are "then".
pub fn as_of_doors(store: Arc<InputStore>) -> EndpointSpace {
EndpointSpace::new().bind(template(INPUT), input_as_of(store))
}
A line of the REPL asks for one with as-of=: the engine builds a corridor named for the instant,
urn:ctx:time:2026-09-22T18:00:00Z, over doors the host hands it, with a clock stopped at that
instant. Which names the corridor rebinds is the host’s business, so the page hands the engine the
sheet’s doors and the time chapter’s:
/// The doors a temporal corridor binds on this page, for every line that says `as-of=`: the
/// sheet's inputs as of the instant, from the page's own store, and the time chapter's `now`
/// and `today`, pinned. Which names a corridor rebinds is a fact about the host, so the host
/// hands them to the engine; the engine builds the corridor, named for its instant.
pub fn as_of_doors(inputs: Arc<InputStore>) -> Arc<dyn Space> {
Arc::new(Fallback::new(vec![
Arc::new(spreadsheet::as_of_doors(inputs)) as Arc<dyn Space>,
Arc::new(time_resource::as_of_doors()),
]))
}
Here is the profit as it stood on Tuesday evening, twice, and at eleven that morning, before anything had been typed:
400 [computed] 400 [cached] [computed]
The price was 100 on Tuesday, so the profit was 400. The formulas, the compiler, the evaluator and
the views are the code Parts I–V wrote, and they read input:{ref} as they always did. In this
corridor, that name means “then”. At eleven there was nothing to read, and an empty cell’s value is
empty.
The answer is cached, and it hangs from the input’s golden thread, as every value does. For an instant in the past that is more than it needs: a write today cannot change what a cell held last Tuesday, but it cuts the thread, and the value as of Tuesday is computed again, the same. It is never less than it needs, which is the direction that matters.
And the past is read-only. A write in the corridor is a Conflict: the state says no. The sheet
as it was is something to read, and a change belongs to the live sheet, or to a scenario.
Inside the corridor, there is one now
C1 holds =NOW(). Read it as of Tuesday evening:
2026-09-22T18:00Z [computed] 2026-09-22T18:00Z [cached] 2026-09-29T14:05Z [cached]
NOW() reads the time chapter’s instant, which reads today and now. The corridor binds both,
pinned to its instant, so every read of the time in the corridor gets the same one:
/// The time chapter's doors for a temporal corridor: `now` and `today` at the instant the
/// corridor is pinned to, and cacheable for as long as the corridor, since a "then" does not
/// change. (The live `now` and `today` would answer the same text, from the same pinned clock,
/// but with the next minute and the next midnight as deadlines, and those have already passed
/// by the kernel's own clock, so nothing built on them would ever be cached.)
///
/// ⚠ Bind these only in a corridor whose name is its instant (`Scope::with_named_at`). At the
/// root they would cache the first minute they were asked forever.
pub fn as_of_doors() -> EndpointSpace {
EndpointSpace::new()
.bind(
template(NOW),
pinned("time-now-then", |minute| {
format!("{:02}:{:02}", (minute / 60) % 24, minute % 60)
}),
)
.bind(
template(TODAY),
pinned("time-today-then", |minute| {
date_text(minute.div_euclid(MINUTES_PER_DAY))
}),
)
}
/// A door that answers `text` of the corridor's minute, cacheable with no deadline.
fn pinned(id: &'static str, text: fn(i64) -> String) -> FnEndpoint {
FnEndpoint::new(id, move |inv: &Invocation<'_>| {
let minute = (clock(inv)?.as_millis() / MINUTE_MS) as i64;
Ok(Representation::new(text_plain_utf8(), text(minute).into_bytes()).cacheable())
})
.with_description(
Description::new(id)
.title("The time, pinned")
.summary("The time at the instant this corridor is pinned to; cacheable, since a then does not change.")
.verb(Verb::Source)
.verb(Verb::Meta)
.output(TEXT_PLAIN_UTF8),
)
}
Two things follow. The first is the answer to the torn reading Part IV guarded
against by hand, the date read on one side of midnight and the time on the other: inside a temporal
corridor there is only one instant to read, however many reads a composite makes. The second is that
a “then” is cached. Live, NOW() is good until the next minute and computed again after it; as of
Tuesday evening it is good for as long as the corridor, because Tuesday evening does not change.
(The live value above says [cached] because the sheet at the bottom of the page read it after the
last cell.)
A scenario: what if we sold twelve?
Alice wants to know what twelve units would make, without changing the sheet everybody else reads. Her scenario is a corridor too, and what it holds is a store of the cells she overrode:
/// The cells one person has overridden, and what with: a scenario's one piece of state.
#[derive(Debug, Default)]
pub struct OverrideStore {
overrides: Mutex<BTreeMap<CellRef, String>>,
}
As a space, it answers input:{ref} for the cells in that store, and misses for every other read,
so the resolution falls through to whatever is outside the corridor. A write is always the
scenario’s: overriding a cell nobody has overridden is how an override starts, and it must never
reach the shared sheet:
/// A scenario, as a space: `input:{ref}` for the cells the person overrode, and a miss for
/// every other read, so the resolution falls through to whatever is outside the corridor (the
/// shared sheet, or the sheet as of an instant). A write is always the scenario's: overriding
/// a cell nobody overrode yet is how an override starts, and it must never reach the shared
/// sheet.
pub struct Scenario {
store: Arc<OverrideStore>,
doors: EndpointSpace,
}
impl Space for Scenario {
fn resolve(&self, request: &Request, scope: &Scope) -> Resolution {
let reads = matches!(request.verb, Verb::Source | Verb::Exists | Verb::Meta);
let overridden = request
.target
.as_str()
.strip_prefix("urn:iki:tutorial:sheet:input:")
.and_then(CellRef::parse)
.is_some_and(|cell| self.store.holds(cell));
if reads && !overridden {
return Resolution::Miss;
}
self.doors.resolve(request, scope)
}
fn entries(&self) -> Option<Vec<SpaceEntry>> {
self.doors.entries()
}
fn topology(&self) -> Topology {
self.doors.topology()
}
}
/// `who`'s scenario: a corridor named for them, binding their overrides in `store`.
pub fn scenario(who: &str, store: Arc<OverrideStore>) -> Result<Scope> {
let name = scenario_name(who)?;
let doors = EndpointSpace::new().bind(template(INPUT), override_input(who, Arc::clone(&store)));
Ok(Scope::empty().with_named(name, Arc::new(Scenario { store, doors })))
}
A cell with data-scenario='alice' runs in her scenario, and says so beside its label. Like a game,
the scenario is markup, not something a line can choose: whoever may push a corridor chooses what
the names in it mean.
12 [uncacheable] 720 [computed]
110 × 12 − 600. On the shared sheet, nothing moved:
500 [computed] 10 [cached]
Still 500, and A2 is still 10. But look at the verdict: the shared B2 was computed, not
served, although nothing on the shared sheet changed. Alice’s write cut it. A golden thread is a
name, input:A2, and a write in her corridor cuts that name’s thread for everybody: for her
values, which needed it, and for the shared sheet’s, which did not. The shared value is computed
again and comes out the same, so nothing is ever wrong. It is only work, and the next section
says what it costs.
One name, a cache entry in each space
Every answer is filed under the chain it was computed in, so cell:B2 on the shared sheet and
cell:B2 in Alice’s scenario are two entries. A write to the shared sheet cuts both, because both
read the shared B1:
650 [uncacheable] not cached
not cached 670 [computed]
Alice’s value followed the shared cost without her doing anything: her scenario holds the one cell she changed, and reads every other one from the sheet everybody reads. It is a corridor, not a copy.
A write in her scenario should cut only her own values. It does not, yet:
15 [uncacheable] 1000 [computed]
not cached 450 [computed]
This is the one thing a personal space should buy that the kernel does not deliver yet. It is sound: the kernel only ever computes too much, never serves a wrong answer. But the waste grows with the number of people. A hundred people with scenarios over one sheet, each editing, would each cut the shared values, and every other scenario’s, for every name they touch. The fix is for a thread to know which corridor answered the read it hangs from, so that a write in Alice’s corridor cuts only what her corridor answered (ledger item 581). The crate’s tests pin the behavior as it is, so the day it changes, a test fails and this section is rewritten.
Spaces stack
Alice’s scenario and the sheet as of Tuesday are two corridors. A request can carry both, and
then the order matters, because the innermost corridor is asked first. The page’s engine for a
scenario cell holds her chain, and a line’s as-of= brings another; the page stacks the line’s
inside hers (Scope::stack, core 0.1.82):
/// This chain with the line's inside it: the line's corridor innermost, answering first.
fn with_line(&self, line: &Scope) -> Scope {
self.scope.clone().stack(line)
}
So a line that says as-of= in her scenario is Tuesday’s sheet, whole. The temporal corridor
answers every input, hers included, before her scenario is asked:
400 [computed]
The other order puts her scenario inside Tuesday: her overrides first, and Tuesday’s sheet for
everything else. That is “what would fifteen units have made last week?”, and the page builds it
for a cell that says both data-scenario='alice' and data-as-of:
/// Chain `spec`, made on first use, handed to `with`. A spec is what the page's markup
/// says: `scenario:alice` is alice's scenario over the shared sheet;
/// `scenario:alice@2026-09-22T18:00Z` is her scenario inside the sheet as of that instant,
/// so her overrides win over the past; anything else is a tic-tac-toe game's id.
fn chain<T>(&self, spec: &str, with: impl FnOnce(&Chain) -> T) -> Result<T, String> {
self.build(spec)?;
Ok(with(self.chains.borrow().get(spec).expect("built above")))
}
/// Make chain `spec`, unless the page has it already.
fn build(&self, spec: &str) -> Result<(), String> {
if self.chains.borrow().contains_key(spec) {
return Ok(());
}
let scope = match spec.strip_prefix("scenario:") {
// One person, one corridor: the scenario over an instant stacks the SAME scope
// (the same store, under the same name) as the scenario alone, since a name is a
// claim that its doors are one set.
Some(rest) => match rest.split_once('@') {
Some((who, at)) => {
let alone = format!("scenario:{who}");
self.build(&alone)?;
let alone = self.chains.borrow()[&alone].scope.clone();
self.as_of(at)?.stack(&alone)
}
None => spreadsheet::scenario(rest, Arc::default()).map_err(|e| e.to_string())?,
},
None => {
let store = Arc::new(tic_tac_toe::stored_space(Arc::default()));
tic_tac_toe::game(spec, store).map_err(|e| e.to_string())?
}
};
let chain = Chain {
engine: Rc::new(
Engine::new(InChain::new(Arc::clone(&self.kernel), scope.clone()))
.with_as_of_doors(Arc::clone(&self.doors)),
),
scope,
};
self.chains.borrow_mut().insert(spec.to_string(), chain);
Ok(())
}
/// The sheet as of `instant` (`YYYY-MM-DDTHH:MMZ`): the corridor a line's `as-of=` builds,
/// named the way the engine names it, over the same doors.
fn as_of(&self, instant: &str) -> Result<Scope, String> {
let millis =
story_millis(instant).map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
let canonical = format!("{}:00Z", instant.trim_end_matches('Z'));
let name = Iri::parse(format!("urn:ctx:time:{canonical}")).map_err(|e| e.to_string())?;
Ok(Scope::empty().with_named_at(
name,
Arc::clone(&self.doors),
Arc::new(FixedClock::at(millis)),
))
}
900 [computed] 95 [uncacheable] 825 [computed]
Tuesday’s price and cost, 100 and 600, and her fifteen units: 900. And a write in this chain goes to her, since her corridor is the innermost: a price of 95 is her override now, and last Tuesday’s sheet is untouched. In the first order, the same write would have reached Tuesday’s corridor first, and been refused, because the past is read-only. The crate’s test says all of this at once:
#[test]
fn stacked_corridors_answer_in_order_innermost_first() {
let host = Host::at("2026-09-22T12:00Z");
let root = the_week(&host);
let alice = spreadsheet::scenario("alice", Arc::new(OverrideStore::default())).unwrap();
host.type_into(&alice, "A2", "15").unwrap();
let tuesday = host.as_of("2026-09-22T18:00Z");
// The scenario innermost: alice's override over Tuesday's sheet, 100 × 15 − 600.
let what_if_then = tuesday.clone().stack(&alice);
assert_eq!(host.value(&what_if_then, "B2"), "900");
// The instant innermost: its doors answer every input, alice's included. Tuesday's sheet.
let then_in_alice = alice.clone().stack(&tuesday);
assert_eq!(host.value(&then_in_alice, "B2"), "400");
// Two orders, two chains, two cache entries.
assert_ne!(what_if_then.fingerprint(), then_in_alice.fingerprint());
// And writes go where the innermost corridor says: to alice, or refused by the past.
assert_eq!(host.type_into(&what_if_then, "A1", "95").unwrap(), "95");
assert_eq!(host.value(&what_if_then, "B2"), "825");
assert!(matches!(
host.type_into(&then_in_alice, "A1", "95"),
Err(Error::Conflict(_))
));
assert_eq!(
host.value(&root, "B2"),
"500",
"and the shared sheet never moved"
);
}
Neither corridor knows the other exists. The scenario was written for the shared sheet, and the temporal view for the live one; stacked, each answers what it binds and misses the rest, and the kernel asks them in order.
Who may read a scenario
Alice’s overrides are hers. The endpoint that answers them declares a capability named for her, and the kernel enforces what an endpoint declares before it runs it:
/// The name a person's scenario corridor is injected under: `urn:iki:tutorial:sheet:scenario:`
/// and who it is. Not a resource: nothing binds it, and the cache files every answer computed
/// in the scenario under it.
pub const SCENARIO: &str = "urn:iki:tutorial:sheet:scenario:{who}";
/// The capability that reads and writes `who`'s overrides.
pub const SCENARIO_CAP: &str = "urn:cap:iki:tutorial:sheet:scenario:{who}";
Narrow this cell’s capability to Bob’s, and read her profit in her scenario:
narrowed — capability: urn:cap:iki:tutorial:sheet:scenario:bob error: denied: capability does not grant `urn:cap:iki:tutorial:sheet:scenario:alice` (declared by `urn:iki:tutorial:sheet:input:A1`) 650 [computed] reset to identity — capability: root (full authority)
Bob can stand in Alice’s corridor, and the door is still locked. B2 reads her price, which is an
override, so it is refused; B1 she never overrode, so it is the shared sheet’s, and needs nothing.
Two authorities are at work, and they are different ones. Who may create a scenario, or put a
reader in one, is whoever may push a corridor: the host, here the page. Who may read one is
whoever holds its capability. A corridor decides what a name means; a capability decides who may
hear the answer.
The shared sheet
The sheet everybody reads, at the story’s current instant. Its edits land on the shared sheet, and Alice’s scenario reads them through.
The sheet appears here when the in-page kernel has loaded.
What was built
| the set | the name | what it is |
|---|---|---|
| the input’s history | urn:iki:tutorial:sheet:input:{ref} | code: the same atom, every write it kept, read for the first time |
| an input as of an instant | input:{ref} in urn:ctx:time:{instant} | code: the history, read at the corridor’s clock; writes refused |
| the time, pinned | urn:iki:tutorial:time:now, …:today in urn:ctx:time:{instant} | code: the corridor’s clock, as text, cacheable for as long as the corridor |
| a person’s override | input:{ref} in urn:iki:tutorial:sheet:scenario:{who} | code: another atom, a store per person, behind their capability |
| a scenario | urn:iki:tutorial:sheet:scenario:{who} | a corridor: a space that misses on every read it does not hold |
| a scenario over an instant | a stack of the two | Scope::stack: no code |
Not one line in the formulas, the compiler, the evaluator or the views changed, and none of them knows about people or time. The whole of what this part added is one atom’s worth of state (a store of overrides), a first reading of the input’s history, three small doors, and the arrangement: which corridors a request carries, and in what order. That arrangement is where the new behavior came from: a “what if” that costs one cell, a past that caches, a single now, and a capability that locks a person’s cells without locking the sheet. The one thing it promised and could not deliver, a personal edit that leaves everybody else’s cache alone, is a kernel change, and it has a name.