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

Tic-tac-toe, IV: many games, and where the game belongs

Parts I to III play one game. There is one board per kernel, which is why each page starts on a fresh one. Two players in two games need two boards, and the question this part answers is where the game goes. It could go in every name, so that a cell is a cell of game 7. Or it could go around the names, as the context they are resolved in, so that a cell stays a cell and the game is where you are standing when you ask for it. The eighth part of the series Part I credits takes the second answer, with contextual configuration. This part takes it too, argues against the first, and changes no name and no endpoint above the atom to do it.

Only the store holds a game

Look back over the three parts for what holds state. The stored cell does. Everything above it is a pure function of names it sources: the platonic cell reads the stored cell, a line reads cells, the board reads rows, the winner reads lines, the turn reads the winner and the board, and the move reads four of those before it writes the stored cell. So a game is what urn:iki:tutorial:ttt:stored:{x}:{y} resolves to. Change that, for one request and everything it gives rise to, and the whole game changes with it. No code above the store has to know.

That makes two small changes, and they are the whole of this part’s code.

The store is a space

Part II showed the game’s space with the stored cell already in a space of its own, after the composites. Here is why: the space takes the store as an argument, and the store is any space at all:

/// The game's space: the endpoints, wrapped in the table that names the lines.
pub fn space() -> Alias {
    space_over(Arc::default())
}

/// [`space`], with the stored cells in a [`CellStore`] the caller keeps — so a test can
/// read the counter.
pub fn space_over(store: Arc<CellStore>) -> Alias {
    space_with_store(Arc::new(stored_space(store)))
}

/// The game over ANY store: every composite, then `store` for the stored cells, all
/// wrapped in the table that names the lines.
///
/// Nothing here holds state. `store` is whatever binds [`STORED`] — the in-memory
/// [`stored_space`], or a peer in another process or language — and the composites reach
/// it by name, through the kernel, so they cannot tell which one answered.
pub fn space_with_store(store: Arc<dyn Space>) -> Alias {
    // One table, shared: the aliases rewrite through it, and the rules read it.
    let table = Arc::new(lines());
    let composites = EndpointSpace::new()
        .bind(template(CELL), platonic_cell())
        .bind(template(CELLS), line_of_cells())
        .bind(template(BOARD), board())
        .bind(template(CHECKSET), checkset(Arc::clone(&table)))
        .bind(template(WINNER), winner(Arc::clone(&table)))
        .bind(template(TURN), turn())
        .bind(template(MOVE), play_move())
        .bind(template(RESET), reset(Arc::clone(&table)))
        .bind(template(TEMPLATE), templates())
        .bind(template(VIEW_BOARD), view("board"))
        .bind(template(VIEW_SQUARE), view("square"))
        .bind(template(VIEW_STATUS), view("status"))
        .bind(template(VIEW_REPLY), view("reply"))
        .bind(template(VIEW_GAME), view("game"))
        .bind(template(VIEW_PLAY), view_play())
        .bind(template(VIEW_RESET), view_reset());
    Alias::new(
        table,
        Arc::new(Fallback::new(vec![Arc::new(composites), store])),
    )
}

/// A store: the stored cell over one [`CellStore`], and nothing else.
pub fn stored_space(store: Arc<CellStore>) -> EndpointSpace {
    EndpointSpace::new().bind(template(STORED), stored_cell(store))
}

space_with_store binds every composite, then puts the store after them in a Fallback, and wraps the lot in Part II’s table. The composites reach the store by name, through the kernel, so they cannot tell which space answered. space() and space_over() are what they were in Parts I–III, spelled as the special case: the in-memory store, stored_space, over a CellStore.

A game is a corridor

The second change is a game: a resolution chain whose one corridor binds the stored cell to that game’s own store.

/// A game: the name of the corridor it is played in.
///
/// Not a resource. Nothing is bound at this name and nothing resolves it; it is the name a
/// resolution chain carries, and so the name the cache files a game's answers under.
pub const GAME: &str = "urn:iki:tutorial:ttt:game:{id}";

/// The corridor name of game `id`. An id is letters, digits and `-`, so a game is named by
/// one segment, in one spelling.
pub fn game_name(id: &str) -> Result<Iri> {
    if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
        return Err(Error::InvalidArgument {
            name: "game".to_string(),
            detail: format!("`{id}` is not a game id: letters, digits and - only (e.g. a, b, 7)"),
        });
    }
    Iri::parse(format!("urn:iki:tutorial:ttt:game:{id}"))
        .map_err(|e| Error::Endpoint(format!("a game's name: {e}")))
}

/// Game `id`, played over `store`: a resolution chain whose one corridor is the store.
///
/// Resolve anything in it — a cell, the board, the winner, a move — and that request, and
/// every sub-request it gives rise to, looks for `stored:{x}:{y}` in `store` ahead of the
/// root. Every other name falls through to the root, so the game above the atom is the same
/// names and the same code in every game. Only what the corridor binds differs.
///
/// The name is a claim: *any corridor named `game:{id}` holds these doors*. The cache keys on
/// it, so build a game's corridor once and keep it, and never give two stores one id.
/// Injecting a corridor is the host's authority — whoever may push one chooses the game.
pub fn game(id: &str, store: Arc<dyn Space>) -> Result<Scope> {
    let name = game_name(id)?;
    // `Scope::with_named` refuses (panics on) a space that names itself otherwise; a store
    // is anyone's space, so say it as an error instead.
    if let Some(own) = store.id() {
        if own.as_str() != name.as_str() {
            return Err(Error::InvalidArgument {
                name: "game".to_string(),
                detail: format!(
                    "this store names itself `{}`, so it cannot be game `{id}`",
                    own.as_str()
                ),
            });
        }
    }
    Ok(Scope::empty().with_named(name, store))
}

Scope and alias showed what a corridor does. It sits ahead of the root, and a name it binds shadows the root’s binding for the request and every sub-request it gives rise to. Here the corridor binds exactly one template, the stored cell’s. Ask for the board in game a, and the board reads rows, the rows read cells, and every cell asks for its stored cell, which game a’s corridor answers. Every other name in that chain of requests missed the corridor and was answered by the root, by the same endpoints that answered it in Part III.

Two things about the corridor are rules rather than conveniences.

Its name is a claim. urn:iki:tutorial:ttt:game:a is not a resource: nothing binds it and nothing resolves it. It is the name the chain carries, and the cache files every answer computed in game a under it. with_named asserts any corridor with this name holds these doors, so a game’s corridor is built once and kept, and two stores never share an id.

Injecting it is authority. Whoever may push a corridor ahead of the root chooses what the stored cell means, for every sub-request below, so whoever may push one chooses the game. That is why the choice belongs to the host and not to a line of the REPL.

Choosing a game in this page

The engine’s grammar has no way to name a chain, and that is right: a line that could name one would let whoever wrote the line decide what every name in it means. So on this page the game is markup. A cell the chapter wrote with data-game='a' says so beside its label, Command, in game a, and the page’s own host code runs each of its lines in game a’s chain. You can edit the command. You cannot edit the game.

The page does it with the engine it already had. The engine takes any resolver, so the page gives it one that issues every request in one chain, on the same kernel every other cell uses:

/// A [`Resolver`] that issues every request in ONE resolution chain — a game's, a person's
/// scenario — on a shared kernel. The engine takes any resolver, so an engine over `InChain`
/// is the whole REPL grammar (`source`, `sink`, `cache`, `trace`, pipes, maps) in one chain:
/// every stage, every contract fetch and every cache probe carries it, and the kernel hands it
/// on to every sub-request.
///
/// A line may bring a chain of its own: `as-of=<instant>` resolves the line in a temporal
/// corridor. The engine hands that chain to [`issue_as_async_in`](Resolver::issue_as_async_in),
/// and `InChain` stacks it INSIDE its own (`Scope::stack`, core 0.1.82), so the line's corridor
/// is the innermost, and the one that answers first.
pub struct InChain {
    kernel: Arc<Kernel>,
    scope: Scope,
}

impl InChain {
    /// Resolve in `scope` on `kernel`.
    pub fn new(kernel: Arc<Kernel>, scope: Scope) -> Self {
        Self { kernel, scope }
    }

    /// 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)
    }

    /// How the cache served a resolution, from a probe taken before it.
    fn status(was_cached: bool, representation: &Representation) -> CacheStatus {
        match (representation.expiry == Expiry::Always, was_cached) {
            (true, _) => CacheStatus::Uncacheable,
            (false, true) => CacheStatus::Hit,
            (false, false) => CacheStatus::Miss,
        }
    }
}

#[async_trait::async_trait]
impl Resolver for InChain {
    fn issue(&self, request: Request) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as(request, &Capability::root())
    }

    fn issue_as(
        &self,
        request: Request,
        capability: &Capability,
    ) -> Result<(Representation, CacheStatus), Error> {
        futures::executor::block_on(self.issue_as_async(request, capability))
    }

    async fn issue_as_async(
        &self,
        request: Request,
        capability: &Capability,
    ) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as_async_in(request, capability, None, Scope::empty())
            .await
    }

    async fn issue_as_async_with_incoming(
        &self,
        request: Request,
        capability: &Capability,
        incoming: Provenance,
    ) -> Result<(Representation, CacheStatus), Error> {
        self.issue_as_async_in(request, capability, Some(incoming), Scope::empty())
            .await
    }

    async fn issue_as_async_in(
        &self,
        request: Request,
        capability: &Capability,
        incoming: Option<Provenance>,
        line: Scope,
    ) -> Result<(Representation, CacheStatus), Error> {
        let scope = self.with_line(&line);
        let was_cached = self.kernel.is_cached_in(&request, capability, &scope);
        let representation = match incoming {
            Some(incoming) => {
                self.kernel
                    .issue_with_incoming_in(request, capability, incoming, scope)
                    .await?
            }
            None => self.kernel.issue_in(request, capability, scope).await?,
        };
        let status = Self::status(was_cached, &representation);
        Ok((representation, status))
    }

    fn is_cached(&self, request: &Request, capability: &Capability) -> bool {
        self.kernel.is_cached_in(request, capability, &self.scope)
    }

    fn is_cached_in(&self, request: &Request, capability: &Capability, line: &Scope) -> bool {
        self.kernel
            .is_cached_in(request, capability, &self.with_line(line))
    }

    fn set_tracer(&self, tracer: Arc<dyn Tracer>) {
        self.kernel.set_tracer(tracer);
    }

    fn clear_tracer(&self) {
        self.kernel.clear_tracer();
    }

    fn entries(&self) -> Option<Vec<SpaceEntry>> {
        self.kernel.entries()
    }

    /// What `trace` prints as the transport — so a traced line says which chain it ran in.
    fn transport(&self) -> String {
        format!("embedded · in-process · chain {}", self.scope)
    }
}

One engine per game, each built the first time a cell names that game and kept for the life of the page, each with its own empty in-memory store. One kernel under all of them, so one cache and one set of golden threads, partitioned by the chain each request carries. A cell with no data-game plays the root’s game, the one every earlier part played.

Two games, side by side

X opens in game a, in the center:

X plays 1,1
[uncacheable]
---
-X-
---
[computed]

Game b has its own board. It is empty, and its first move is X’s, because its turn is read from its own board:

---
---
---
[computed]
X plays 0,0
[uncacheable]
X--
---
---
[computed]

And the root’s game, which no cell has named yet on this page, is untouched by either:

---
---
---
[computed]

The same names, the same endpoints, three boards. The crate says the same:

/// Two games on one kernel: the same names, the same code, two boards. A move in one
/// appears in that game and in no other — including the root's, the game a request plays
/// when it names none.
#[test]
fn two_games_are_two_boards_under_the_same_names() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, _) = new_game("a");
    let (b, _) = new_game("b");

    assert_eq!(play_in(&kernel, &a, 1, 1)?, "X plays 1,1");
    assert_eq!(play_in(&kernel, &b, 0, 0)?, "X plays 0,0");
    assert_eq!(play_in(&kernel, &b, 2, 2)?, "O plays 2,2");

    assert_eq!(source_in(&kernel, &a, BOARD)?, "---\n-X-\n---");
    assert_eq!(source_in(&kernel, &b, BOARD)?, "X--\n---\n--O");
    assert_eq!(source_in(&kernel, &Scope::empty(), BOARD)?, FRESH);
    Ok(())
}

A move stays in its game

The move is the resource that could get this wrong. It reads four resources before it writes (the CheckSet, the winner, the cell and the turn) and then issues a Sink. Every one of those five requests has to resolve in the move’s own game. If any of them escaped to another game, a player would be refused because someone else’s game was over, or told a square was taken because someone else had taken it.

They cannot escape, because the move does not choose where they go. It issues them through its invocation, and the invocation hands its chain to every sub-request. So finish game a. X takes the top-left corner, which in game b is already X’s, and in game a is free:

O plays 1,0
[uncacheable]
X plays 0,0
[uncacheable]
O plays 2,0
[uncacheable]
X plays 2,2
[uncacheable]
X
[computed]

Game a is over, so a move in game a is refused:

error: invalid argument `x, y`: the game is over — X has won

The same move in game b plays, and game b goes on:

O plays 0,2
[uncacheable]
-
[computed]
X
[computed]

The crate pins it both ways round, and counts reads on each game’s store to show that nothing game b asked touched game a’s:

/// A move reads four resources before it writes — the CheckSet, the winner, the cell and
/// the turn — and every one of them, and the write, is in the move's own game. Game A is
/// over; that refuses a move in A, and nowhere else. Reading B's board touches B's store
/// and never A's.
#[test]
fn a_move_stays_in_its_game() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, a_store) = new_game("a");
    let (b, b_store) = new_game("b");

    x_wins(&kernel, &a)?;
    assert_eq!(source_in(&kernel, &a, WINNER)?, "X");
    match play_in(&kernel, &a, 0, 2) {
        Err(Error::InvalidArgument { detail, .. }) => {
            assert_eq!(detail, "the game is over — X has won")
        }
        other => panic!("a move in a finished game must be refused, got {other:?}"),
    }

    let a_reads = a_store.reads.load(Ordering::SeqCst);
    assert_eq!(play_in(&kernel, &b, 0, 2)?, "X plays 0,2");
    assert_eq!(source_in(&kernel, &b, WINNER)?, EMPTY);
    assert_eq!(source_in(&kernel, &b, BOARD)?, "---\n---\nX--");
    assert_eq!(
        a_store.reads.load(Ordering::SeqCst),
        a_reads,
        "B read A's store"
    );
    assert!(b_store.reads.load(Ordering::SeqCst) > 0);

    // And the root's game, which nobody named, is untouched by either.
    assert_eq!(source_in(&kernel, &Scope::empty(), BOARD)?, FRESH);
    assert_eq!(play_in(&kernel, &Scope::empty(), 1, 1)?, "X plays 1,1");
    Ok(())
}

Two games, two caches

The cache key carries the chain’s fingerprint, so an answer computed in one game is never served in another. For the board that is exactly right. For the CheckSet it is a cost.

Ask game a which lines pass through the middle of the right-hand column, twice:

urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[computed]
urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[cached]

Game b has not got it, and computes the same two names again:

not cached
urn:iki:tutorial:ttt:column:2
urn:iki:tutorial:ttt:row:1
[computed]

Nor has the root:

not cached

Part III called the CheckSet platonic in the strong sense: it depends on its name and a constant table, nothing a move can change, so it is computed once in the life of the kernel. Under games that is no longer true. It is computed once in the life of each game. The crate measures it: every CheckSet of the board, read in the root and in three games, is nine distinct answers filed thirty-six times.

/// Two games are two cache partitions: the key carries the chain's fingerprint, so an
/// answer computed in one game is never served in another. Right for the board. For the
/// CheckSet — which depends on nothing a game changes — it means one copy per game.
#[test]
fn every_game_computes_its_own_checkset() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let games: Vec<Scope> = ["a", "b", "c"].iter().map(|id| new_game(id).0).collect();
    let center = checkset_name(1, 1);

    source_in(&kernel, &games[0], &center)?;
    assert!(cached_in(&kernel, &games[0], &center));
    assert!(!cached_in(&kernel, &games[1], &center));
    assert!(!cached_in(&kernel, &Scope::empty(), &center));

    // Every CheckSet of the board, in the root and in each of three games: nine answers,
    // the same nine every time, filed four times over.
    let before = kernel.cache_len();
    let mut answers = BTreeMap::new();
    for scope in std::iter::once(&Scope::empty()).chain(&games) {
        for x in 0..3 {
            for y in 0..3 {
                let name = checkset_name(x, y);
                let answer = source_in(&kernel, scope, &name)?;
                assert_eq!(*answers.entry(name).or_insert(answer.clone()), answer);
            }
        }
    }
    assert_eq!(answers.len(), 9);
    assert_eq!(kernel.cache_len() - before, 4 * 9 - 1); // the center in `a` was already there
    Ok(())
}

Nothing is wrong. Keying on the whole chain is sound and over-partitioned: two chains that differ only in a corridor a request never touched do not share. The CheckSet never touches the game’s corridor. It is answered by the root, and the chain it was asked in made no difference to the answer. The fix is to key an entry on the part of the chain that could have answered it, which since core 0.1.78 the kernel can see: a resolution reports the space that answered it. Keying the cache on that is a decision taken and not yet built (the resolution context, ledger item 563), so for now each game computes its own copy of something that is the same in every game.

A golden thread is a name

There is a second cost, the other way round. The key is partitioned by game, but a golden thread is not. A thread is a name, and a move cuts the thread named after the square it played, in every game at once.

It has already happened on this page. Game b read its top row with its board, several cells back, and nothing in game b has touched that row since. Yet read it now and it is computed, not served:

X--
[computed]
cached

Game a played three moves on the top row in between, and each one cut the thread of the square it played, in game b as well. Now the row is cached again, so watch it go on purpose.

The root’s game plays two moves, the second of them on the top-left corner:

X plays 1,1
[uncacheable]
O plays 0,0
[uncacheable]

Nothing in game b changed, and game b’s top row is no longer cached. Traced, game b’s corner shows why it was recomputed, and whose store answered it:

not cached
trace  urn:iki:tutorial:ttt:cell:0:0
  client      ikigai repl  ·  capability: root (full authority)
  transport   embedded · in-process · chain urn:iki:tutorial:ttt:game:b root
 
urn:iki:tutorial:ttt:cell:0:0   ttt-cell · computed · ThreadId(1) · — · scope=urn:iki:tutorial:ttt:game:b root   → 1b  X
└─ urn:iki:tutorial:ttt:stored:0:0   ttt-stored · computed · ThreadId(1) · — · answered-by=urn:iki:tutorial:ttt:game:b scope=urn:iki:tutorial:ttt:game:b root

Read the two nodes. Both ran in game b’s chain (scope=). The platonic cell was answered by the root, like every name but one. The stored cell was answered by game b’s corridor (answered-by=), and it still says X, which is game b’s mark, not the root’s O. The root’s move cut stored:0:0, and the cut reached every entry hanging from that thread, whatever game computed it. So game b recomputed a corner that had not changed, and got the same answer.

That is sound, because a cut can only make the kernel compute something again, never serve something stale. And it is a cost that grows with the number of games: a move in any game recomputes the lines through that square in all of them. A thread named by the square in its game would stop it. That is the same missing piece as the CheckSet’s, seen from the other side, and it waits on the same decision.

/// A golden thread is a NAME, not a name in a game. A move in A cuts `stored:0:0`, and every
/// entry that hangs from that thread goes, in every game: B's corner cell is recomputed,
/// although nothing in B changed. Sound — a cut can only over-invalidate — and a cost.
#[test]
fn a_move_in_one_game_uncaches_the_same_square_in_every_game() -> Result<()> {
    let kernel = kernel_over(Arc::default());
    let (a, _) = new_game("a");
    let (b, b_store) = new_game("b");
    let corner = CELL.replace("{x}", "0").replace("{y}", "0");

    source_in(&kernel, &a, &corner)?;
    source_in(&kernel, &b, &corner)?;
    source_in(&kernel, &b, ROW_0)?;
    assert!(cached_in(&kernel, &b, &corner) && cached_in(&kernel, &b, ROW_0));

    play_in(&kernel, &a, 0, 0)?;
    assert!(!cached_in(&kernel, &b, &corner));
    assert!(!cached_in(&kernel, &b, ROW_0));

    // Recomputed, and still right: B's corner is still empty.
    let reads = b_store.reads.load(Ordering::SeqCst);
    assert_eq!(source_in(&kernel, &b, &corner)?, EMPTY);
    assert_eq!(b_store.reads.load(Ordering::SeqCst), reads + 1);
    Ok(())
}

The game in the name

The other answer puts the game in every name. The stored cell becomes urn:iki:tutorial:ttt:game:{g}:stored:{x}:{y}, and no corridor is needed.

It does not stop at the stored cell. Every composite that names a cell now has to name the game too, so every template above the store gains {g}: the cell, the line, the board, the CheckSet, the winner, the turn and the move. Each endpoint has to read g out of its own name and spell it into every name it builds. Part II’s aliases are worse off. They are exact rules, eight of them, one per line of one board, and an exact rule does not parameterize. A prefix rule rewrites a prefix and not the middle of a name, so either the table grows eight rules per game, or the game moves to the end of every name, or the composites stop reading rows by their aliases and build line names themselves. Every one of those makes the code above the store know about games, which is the thing Parts I–III were built not to need.

Keeping the game as context keeps every name a game-free name, and keeps the one thing that differs between games in the one place it lives: the store.

A store you can swap

A game’s store is any space that binds the stored cell and keeps its contract. The contract is short, and the crate states it as a check that any store can be run against:

/// Check `store` against the **store contract** — what any space must do to be a game's
/// store, whatever language it is written in:
///
/// 1. It binds [`STORED`], `urn:iki:tutorial:ttt:stored:{x}:{y}`, for any integer `x`, `y`
///    in its one plain spelling; `01`, `+1` and `-0` are `InvalidArgument` naming `x` or `y`.
/// 2. `Source` answers the mark as `text/plain`, its bytes exactly the mark (no trailing
///    newline), cacheable. A cell nothing was played at is `NotFound` — not an empty
///    answer, and not `Unresolved`.
/// 3. `Sink` takes the mark as `content`, trimmed, and keeps any non-empty mark; an empty
///    one is `InvalidArgument` naming `content`.
/// 4. `Exists` answers `true` where a mark is played and `false` where none is — never
///    `NotFound` — as `text/plain`, cacheable.
/// 5. `Delete` clears the cell, and succeeds whether or not anything was there.
///
/// It WRITES: it plays and clears `(7, -3)`, a square off the board, and leaves it clear.
/// Point it at a scratch store, not a game in progress.
pub async fn check_store(store: Arc<dyn Space>) -> std::result::Result<(), String> {
    let kernel = Kernel::new(store);
    let root = Capability::root();
    let at = |name: &str| Iri::parse(name).expect("a constant name");
    let here = at(&stored_name(7, -3));
    let issue = |request: Request| kernel.issue(request, &root);
    let with = |verb: Verb, content: &str| {
        Request::new(verb, here.clone())
            .with_arg("content", ArgRef::Inline(content.as_bytes().to_vec()))
    };
    let read = || issue(Request::new(Verb::Source, here.clone()));

    for round in ["first", "second"] {
        issue(Request::new(Verb::Delete, here.clone()))
            .await
            .map_err(|e| format!("Delete must succeed on any cell ({round} delete): {e}"))?;
    }
    match read().await {
        Err(Error::NotFound(_)) => {}
        other => return Err(format!("an unplayed cell must be NotFound, got {other:?}")),
    }
    exists(&issue, &here, "false").await?;
    issue(with(Verb::Sink, " X\n"))
        .await
        .map_err(|e| format!("Sink of `content` must succeed: {e}"))?;
    let played = read()
        .await
        .map_err(|e| format!("a played cell must answer: {e}"))?;
    if played.bytes != b"X" {
        return Err(format!(
            "a played cell answers exactly its mark, trimmed: expected `X`, got {:?}",
            String::from_utf8_lossy(&played.bytes)
        ));
    }
    if !played.repr_type.to_string().starts_with("text/plain") {
        return Err(format!("a mark is text/plain, not {}", played.repr_type));
    }
    if played.expiry == Expiry::Always {
        return Err(
            "a mark must be cacheable, or nothing above the store can be cached".to_string(),
        );
    }
    exists(&issue, &here, "true").await?;
    match issue(with(Verb::Sink, "  ")).await {
        Err(Error::InvalidArgument { name, .. }) if name == "content" => {}
        other => return Err(format!("an empty mark must be refused, got {other:?}")),
    }
    for spelling in [
        "stored:07:-3",
        "stored:+7:-3",
        "stored:7:-03",
        "stored:0:-0",
    ] {
        let name = format!("urn:iki:tutorial:ttt:{spelling}");
        for verb in [Verb::Source, Verb::Exists] {
            match issue(Request::new(verb, at(&name))).await {
                Err(Error::InvalidArgument { name: arg, .. }) if arg == "x" || arg == "y" => {}
                other => {
                    return Err(format!(
                        "{name} must be refused by {verb:?} (one spelling), got {other:?}"
                    ))
                }
            }
        }
    }
    issue(Request::new(Verb::Delete, here.clone()))
        .await
        .map_err(|e| format!("Delete must clear a played cell: {e}"))?;
    match read().await {
        Err(Error::NotFound(_)) => {}
        other => {
            return Err(format!(
                "a cleared cell must be NotFound again, got {other:?}"
            ))
        }
    }
    exists(&issue, &here, "false").await
}

/// Clause 4 of the contract: `Exists` at `here` answers exactly `expected`, cacheably.
async fn exists<F, Fut>(issue: &F, here: &Iri, expected: &str) -> std::result::Result<(), String>
where
    F: Fn(Request) -> Fut,
    Fut: std::future::Future<Output = Result<Representation>>,
{
    match issue(Request::new(Verb::Exists, here.clone())).await {
        Ok(answer) if answer.bytes == expected.as_bytes() && answer.expiry != Expiry::Always => {
            Ok(())
        }
        Ok(answer) if answer.bytes == expected.as_bytes() => Err(format!(
            "Exists must be cacheable like the mark (it answered `{expected}`, uncacheable)"
        )),
        Ok(answer) => Err(format!(
            "Exists must answer `{expected}` here, got {:?}",
            String::from_utf8_lossy(&answer.bytes)
        )),
        Err(e) => Err(format!(
            "Exists must answer true or false, never fail (expected `{expected}`): {e}"
        )),
    }
}

Each clause is there because something above the store depends on it. NotFound for an unplayed square is what the platonic cell turns into -; an empty answer instead would make an empty square read as nothing, and a line of X and two empty cells would read X rather than X--. The mark’s exact bytes are what the line concatenates, so a trailing newline would break every row. A mark that is not cacheable would leave nothing above the store cacheable, because a composite is only as cacheable as its least cacheable part. And one spelling per coordinate is Part I’s rule: two names for one square would be two golden threads over one piece of state.

A store written a different way, marks kept in a map by the cell’s name, keeps the same contract and plays the same game:

/// The in-memory store keeps the contract — and so does a store written differently, which
/// is then a game's store like any other. The game cannot tell them apart.
#[test]
fn any_space_that_keeps_the_contract_is_a_store() -> Result<()> {
    block_on(check_store(Arc::new(stored_space(Arc::default())))).expect("in-memory store");

    let other = Arc::new(by_name_store());
    block_on(check_store(other.clone())).expect("a store written differently");

    let kernel = kernel_over(Arc::default());
    let elsewhere = game("elsewhere", other)?;
    assert_eq!(play_in(&kernel, &elsewhere, 1, 1)?, "X plays 1,1");
    assert_eq!(source_in(&kernel, &elsewhere, BOARD)?, "---\n-X-\n---");
    Ok(())
}

That is the point of making the seam a Space. A game backed by the in-memory store and a game backed by a store in another process differ only in what their corridor binds. Stores written in Python and TypeScript, served over the wire by those languages’ ikigai faces, are next: the same contract, and the same game above them.

Three boards, one kernel

Everything played on this page, in each game:

XOO
-X-
--X
[computed]
X--
---
O--
[computed]
O--
-X-
---
[computed]

Next

Part V puts a board in this page that you can play by clicking, over the same kernel and the same resources, and draws Parts I to V together.