Kata B: Feature Envy

Twenty-eight moves on a build's event bus. When a method uses its parameter more than it uses this, the method is telling you where it lives. The switch left behind tells you the rest.

BuildFileSystem is the blackboard behind Bob: every collaborator posts events to it, and a question such as “where is this address written?” is an event like any other. Its answer goes to the asker alone. After a run of refactorings turned the class's query methods into events, it held long switch statements whose arms all looked the same.

The smell

Extracting those arms into private methods made the problem visible. Here is one of them:

private void found(final Find asked) {
    asked.asker().update(new Found(asked.asker(), writtenAt(asked.address()), Instant.now()));
}

Count what it uses. Three reaches into asked, and one call on this. The chain asked.asker().update(...) is a Law of Demeter violation, and that was the first thing the code said. The violation is a symptom. The name for the disease is FEATURE ENVY, from Fowler's Refactoring: a method more interested in another class than the one it sits in. The cure is MOVE METHOD.

The extraction was the step that made the envy countable. Inside a switch arm it reads as dispatch. As a method with one parameter, it is plainly in the wrong class.

The rule

  1. Count. A method that uses a parameter's members more than this is a candidate.
  2. Split. Sort its lines by whose data they use. What the host alone knows stays there and is passed in as a value.
  3. Never pass the host's collaborators. A method that seems to need them is not finished being split. Ask what each one needs to hear, and send it a message.
  4. Move the type test too. When every arm of a switch is one call, the call is an interface.
  5. Implement it only where it applies. A default -> { } arm and an override that returns nothing are one mistake: a type asked a question that was never its own.

A method of this shape first shrinks to one expression from the build:

case Locate asked -> asked.located(module);

The kata doesn't end there, because that line is still in a switch.

The moves

These landed in two commits, not twenty-eight, because they move together. They are shown here one file at a time, as each file finally stands, so a method moved twice appears once at its last home. Held, Kept and Archive make the same change as Scope, and BuildRoot and Busy barely change, so they aren't shown. One behavior changed on purpose: Find on /catalog now answers empty rather than the jar. The only test edit adds the new events to a list of every event. After a clean rebuild, reboot passed 521 of 521 and all 14 checkouts of Bob's chain published.

Six arms of one switch ended in one call, so the call becomes the interface, and the arm for everything else has no reason left to exist.

1. EXTRACT INTERFACE: Answered +13 −0
--- /dev/null
@@ -0,0 +1,13 @@
+++ b/src/main/java/software/visionary/reboot/fs/Answered.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+/** An event the build answers inside its lock, before the post returns: an event that is not one is answered by nobody there. */
+public sealed interface Answered extends BuildEvent permits Realize, Find, Halt, AssertHalt, DeleteHalt, Looked {
+
+    /** Does what this event asks, on the poster's thread. */
+    void answer();
+}

The first method the count pointed at. Where an address is written turned out to be the address's own knowledge too.

2. MOVE METHOD: Find.answer +7 −1
--- a/src/main/java/software/visionary/reboot/fs/Find.java
@@ -11,5 +11,11 @@ import java.nio.file.Path;
 import java.time.Instant;
 
 /** An observer asks where {@code address} is written on disk; the build answers {@link Found} before this has been posted, even from inside another post. */
-public record Find(Observer<? super BuildEvent> asker, Path address, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Find.java
@@ -11,5 +11,11 @@ import java.nio.file.Path;
 import java.time.Instant;
 
 /** An observer asks where {@code address} is written on disk; the build answers {@link Found} before this has been posted, even from inside another post. */
+public record Find(Observer<? super BuildEvent> asker, Path address, Instant when) implements Answered {
+
+    /** Answers the asker alone with where the address says it is written. */
+    @Override
+    public void answer() {
+        asker.update(new Found(asker, SubscribedPath.writtenAt(address), Instant.now()));
+    }
 }

writtenAt switched on the kind of path. Each kind now answers for itself. One static fallback remains, for pylon's Beneath, which reboot may not change.

3. REPLACE TYPE CODE WITH POLYMORPHISM: SubscribedPath.writtenAt +14 −1
--- a/src/main/java/software/visionary/reboot/fs/SubscribedPath.java
@@ -5,11 +5,24 @@
  */
 package software.visionary.reboot.fs;
 
 import java.nio.file.Path;
 
 /** A path of a module build, whose subscribers are asked of that build with {@link Readers}, {@link Writers}, {@link WritersWithin} and {@link ReadAbove}. */
-public interface SubscribedPath extends Path {
 
     @Override
     BuildFileSystem getFileSystem();
 }
+++ b/src/main/java/software/visionary/reboot/fs/SubscribedPath.java
@@ -5,11 +5,24 @@
  */
 package software.visionary.reboot.fs;
 
+import software.visionary.pylon.additional.Address;
+
 import java.nio.file.Path;
+import java.util.Optional;
 
 /** A path of a module build, whose subscribers are asked of that build with {@link Readers}, {@link Writers}, {@link WritersWithin} and {@link ReadAbove}. */
+public interface SubscribedPath extends Address {
 
     @Override
     BuildFileSystem getFileSystem();
+
+    /** {@return where this address is written on disk, by default its entry} */
+    default Optional<Path> writtenAt() {
+        return entry();
+    }
+
+    /** {@return where {@code address} is written on disk: an address pylon made beneath a link answers its entry} */
+    static Optional<Path> writtenAt(final Path address) {
+        return address instanceof SubscribedPath subscribed ? subscribed.writtenAt() : ((Address) address).entry();
+    }
 }

A level is written at the folder it holds.

4. Level answers writtenAt +6 −0
--- a/src/main/java/software/visionary/reboot/fs/Level.java
@@ -11,6 +11,7 @@ import java.nio.file.InvalidPathException;
 import java.nio.file.Path;
 import java.util.List;
 import java.util.Objects;
 
 import software.visionary.pylon.additional.Absolute;
 import software.visionary.pylon.additional.Address;
@@ -45,6 +46,11 @@ public abstract sealed class Level extends Node implements Address, SubscribedPa
         return directory;
     }
 
     /** The names this level offers, in the order it offers them. */
     abstract List<String> offers();
 
+++ b/src/main/java/software/visionary/reboot/fs/Level.java
@@ -11,6 +11,7 @@ import java.nio.file.InvalidPathException;
 import java.nio.file.Path;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 
 import software.visionary.pylon.additional.Absolute;
 import software.visionary.pylon.additional.Address;
@@ -45,6 +46,11 @@ public abstract sealed class Level extends Node implements Address, SubscribedPa
         return directory;
     }
 
+    @Override
+    public final Optional<Path> writtenAt() {
+        return Optional.of(directory);
+    }
+
     /** The names this level offers, in the order it offers them. */
     abstract List<String> offers();
 

A coordinate is written where it is kept.

5. Coordinate answers writtenAt +5 −0
--- a/src/main/java/software/visionary/reboot/fs/Coordinate.java
@@ -46,6 +46,11 @@ public final class Coordinate extends Link implements SubscribedPath {
         return published.kept();
     }
 
     void keep(final Path coordinate) throws IOException {
         if (!Published.namesARelease(coordinate)) {
             throw new IllegalArgumentException(coordinate + " is no group/artifact/version");
+++ b/src/main/java/software/visionary/reboot/fs/Coordinate.java
@@ -46,6 +46,11 @@ public final class Coordinate extends Link implements SubscribedPath {
         return published.kept();
     }
 
+    @Override
+    public Optional<Path> writtenAt() {
+        return Optional.of(kept());
+    }
+
     void keep(final Path coordinate) throws IOException {
         if (!Published.namesARelease(coordinate)) {
             throw new IllegalArgumentException(coordinate + " is no group/artifact/version");

The first draft put realPaths on every path, which forced BuildRoot and Catalog to override it just to return nothing. This interface is implemented only where the answer differs from writtenAt.

6. EXTRACT INTERFACE: Real +21 −0
--- /dev/null
@@ -0,0 +1,21 @@
+++ b/src/main/java/software/visionary/reboot/fs/Real.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Set;
+
+/** An address that exists on disk somewhere other than where it is written: any other exists only there. */
+interface Real {
+
+    /**
+     * {@return where this address exists on disk}
+     *
+     * @throws IOException when a link cannot be read
+     */
+    Set<Path> realPaths() throws IOException;
+}

A scope exists at its folder and at every jar its links name.

7. Scope is Real +14 −1
--- a/src/main/java/software/visionary/reboot/fs/Scope.java
@@ -12,11 +12,16 @@ import java.nio.file.NoSuchFileException;
 import java.nio.file.NotDirectoryException;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.SortedSet;
 import java.util.TreeSet;
 
 import software.visionary.pylon.additional.Absolute;
 import software.visionary.pylon.additional.Address;
 
@@ -28,7 +33,7 @@ import software.visionary.pylon.additional.Address;
  * <p>A goal declares the scope it reads and hands that {@code Path} to {@link Resolved}, which
  * walks the folder and follows each link.
  */
-public final class Scope extends Level {
 
     private final Dependencies kind;
 
@@ -73,6 +78,14 @@ public final class Scope extends Level {
         return named().stream().map(named -> kind.held(this, named)).toList();
     }
 
     private List<Held> shown() throws IOException {
         final List<Held> shown = new ArrayList<>();
         for (final Held held : kept()) {
+++ b/src/main/java/software/visionary/reboot/fs/Scope.java
@@ -12,11 +12,16 @@ import java.nio.file.NoSuchFileException;
 import java.nio.file.NotDirectoryException;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
+import software.visionary.oi.IoFunction;
 import software.visionary.pylon.additional.Absolute;
 import software.visionary.pylon.additional.Address;
 
@@ -28,7 +33,7 @@ import software.visionary.pylon.additional.Address;
  * <p>A goal declares the scope it reads and hands that {@code Path} to {@link Resolved}, which
  * walks the folder and follows each link.
  */
+public final class Scope extends Level implements Real {
 
     private final Dependencies kind;
 
@@ -73,6 +78,14 @@ public final class Scope extends Level {
         return named().stream().map(named -> kind.held(this, named)).toList();
     }
 
+    /** {@return the scope's folder and the jars its links name, in the repository} */
+    @Override
+    public Set<Path> realPaths() throws IOException {
+        return Stream.concat(entry().stream(), kept().stream()
+                        .map((IoFunction<Held, Optional<Path>>) Held::links).flatMap(Optional::stream))
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+
     private List<Held> shown() throws IOException {
         final List<Held> shown = new ArrayList<>();
         for (final Held held : kept()) {

What the build's switch over path kinds did is now one type test on Real.

8. MOVE METHOD: Realize.answer +19 −1
--- a/src/main/java/software/visionary/reboot/fs/Realize.java
@@ -7,9 +7,27 @@ package software.visionary.reboot.fs;
 
 import software.visionary.events.Observer;
 
 import java.nio.file.Path;
 import java.time.Instant;
 
 /** An observer asks where {@code path} exists on disk; the build answers {@link Realized} before this has been posted, even from inside another post. */
-public record Realize(Observer<? super BuildEvent> asker, Path path, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Realize.java
@@ -7,9 +7,27 @@ package software.visionary.reboot.fs;
 
 import software.visionary.events.Observer;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.nio.file.Path;
 import java.time.Instant;
+import java.util.Set;
 
 /** An observer asks where {@code path} exists on disk; the build answers {@link Realized} before this has been posted, even from inside another post. */
+public record Realize(Observer<? super BuildEvent> asker, Path path, Instant when) implements Answered {
+
+    /** Answers the asker alone with where the path says it exists. */
+    @Override
+    public void answer() {
+        try {
+            asker.update(new Realized(asker, realPaths(), Instant.now()));
+        } catch (final IOException unreadable) {
+            throw new UncheckedIOException(unreadable);
+        }
+    }
+
+    private Set<Path> realPaths() throws IOException {
+        return path instanceof Real real ? real.realPaths()
+                : SubscribedPath.writtenAt(path).map(Set::of).orElseGet(Set::of);
+    }
 }

Its javadoc already said so. Saying it in writtenAt makes the empty realPaths override unnecessary.

9. Catalog is written nowhere +6 −0
--- a/src/main/java/software/visionary/reboot/fs/Catalog.java
@@ -81,6 +81,12 @@ public final class Catalog extends Link implements SubscribedPath {
         return jar;
     }
 
     @Override
     public BuildFileSystem getFileSystem() {
         return (BuildFileSystem) super.getFileSystem();
+++ b/src/main/java/software/visionary/reboot/fs/Catalog.java
@@ -81,6 +81,12 @@ public final class Catalog extends Link implements SubscribedPath {
         return jar;
     }
 
+    /** {@return nowhere: what this JVM reads of the catalog is what it loaded} */
+    @Override
+    public Optional<Path> writtenAt() {
+        return Optional.empty();
+    }
+
     @Override
     public BuildFileSystem getFileSystem() {
         return (BuildFileSystem) super.getFileSystem();

Reading the halt file was the build's private business only because the arm sat in the build. The event already holds the build and the asker.

10. MOVE METHOD: Halt.answer +26 −1
--- a/src/main/java/software/visionary/reboot/fs/Halt.java
@@ -7,8 +7,33 @@ package software.visionary.reboot.fs;
 
 import software.visionary.events.Observer;
 
 import java.time.Instant;
 
 /** An observer asks whether this build is halted; the build answers {@link Halted} before this has been posted, even from inside another post. */
-public record Halt(BuildFileSystem build, Observer<? super BuildEvent> asker, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Halt.java
@@ -7,8 +7,33 @@ package software.visionary.reboot.fs;
 
 import software.visionary.events.Observer;
 
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
+import java.util.Optional;
 
 /** An observer asks whether this build is halted; the build answers {@link Halted} before this has been posted, even from inside another post. */
+public record Halt(BuildFileSystem build, Observer<? super BuildEvent> asker, Instant when) implements Answered {
+
+    /** Answers the asker alone with what {@code /control/HALT} says. */
+    @Override
+    public void answer() {
+        asker.update(new Halted(build, asker, reason(), Instant.now()));
+    }
+
+    /** {@return what {@code /control/HALT} says, asked of the checkout so every process over it agrees; empty while nothing is there} */
+    private Optional<String> reason() {
+        final Path halt = build.halt();
+        if (!Files.exists(halt)) {
+            return Optional.empty();
+        }
+        try (InputStream chain = Files.newInputStream(halt)) {
+            return Optional.of(new String(chain.readAllBytes(), StandardCharsets.UTF_8));
+        } catch (final IOException unreadable) {
+            return Optional.of("the chain could not be read");
+        }
+    }
 }

The build keeps one thing, where /control/HALT stands.

11. MOVE METHOD: AssertHalt.answer +21 −1
--- a/src/main/java/software/visionary/reboot/fs/AssertHalt.java
@@ -5,8 +5,28 @@
  */
 package software.visionary.reboot.fs;
 
 import java.time.Instant;
 
 /** Stops the build: {@code reason}, the chain that led here included, is kept at {@code /control/HALT} before this has been posted. */
-public record AssertHalt(BuildFileSystem build, String reason, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/AssertHalt.java
@@ -5,8 +5,28 @@
  */
 package software.visionary.reboot.fs;
 
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.time.Instant;
 
 /** Stops the build: {@code reason}, the chain that led here included, is kept at {@code /control/HALT} before this has been posted. */
+public record AssertHalt(BuildFileSystem build, String reason, Instant when) implements Answered {
+
+    /** Keeps the reason where {@code /control/HALT} stands. */
+    @Override
+    public void answer() {
+        final Path halt = build.halt();
+        try {
+            Files.createDirectories(halt.getParent());
+            try (OutputStream keeping = Files.newOutputStream(halt)) {
+                keeping.write(reason.getBytes(StandardCharsets.UTF_8));
+            }
+        } catch (final IOException unwritable) {
+            throw new UncheckedIOException("cannot halt " + build, unwritable);
+        }
+    }
 }

The same path, deleted.

12. MOVE METHOD: DeleteHalt.answer +14 −1
--- a/src/main/java/software/visionary/reboot/fs/DeleteHalt.java
@@ -5,8 +5,21 @@
  */
 package software.visionary.reboot.fs;
 
 import java.time.Instant;
 
 /** Lets the build go: {@code /control/HALT} is gone before this has been posted. */
-public record DeleteHalt(BuildFileSystem build, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/DeleteHalt.java
@@ -5,8 +5,21 @@
  */
 package software.visionary.reboot.fs;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
 import java.time.Instant;
 
 /** Lets the build go: {@code /control/HALT} is gone before this has been posted. */
+public record DeleteHalt(BuildFileSystem build, Instant when) implements Answered {
+
+    /** Removes what {@code /control/HALT} kept. */
+    @Override
+    public void answer() {
+        try {
+            Files.deleteIfExists(build.halt());
+        } catch (final IOException unremovable) {
+            throw new UncheckedIOException("cannot let " + build + " go", unremovable);
+        }
+    }
 }

The second switch ran after the lock and its arms took host values. The interface passes the host, and each event asks it for one named thing.

13. EXTRACT INTERFACE: LookedAt +13 −0
--- /dev/null
@@ -0,0 +1,13 @@
+++ b/src/main/java/software/visionary/reboot/fs/LookedAt.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+/** An event the build sees to itself after the outermost unlock: one that is not this asks nothing of the build there. */
+public sealed interface LookedAt extends BuildEvent permits Look, Digest, Locate, EnvironmentVariables {
+
+    /** Does what this event asks of {@code host}, the build it was posted to. */
+    void lookedAt(BuildFileSystem host);
+}

host.checkout() is the one thing only the build knows.

14. MOVE METHOD: Locate.lookedAt +7 −1
--- a/src/main/java/software/visionary/reboot/fs/Locate.java
@@ -10,5 +10,11 @@ import software.visionary.events.Observer;
 import java.time.Instant;
 
 /** An observer asks where the checkout this build addresses is; the build answers it {@link Located} before this has been posted. */
-public record Locate(BuildFileSystem build, Observer<? super BuildEvent> asker, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Locate.java
@@ -10,5 +10,11 @@ import software.visionary.events.Observer;
 import java.time.Instant;
 
 /** An observer asks where the checkout this build addresses is; the build answers it {@link Located} before this has been posted. */
+public record Locate(BuildFileSystem build, Observer<? super BuildEvent> asker, Instant when) implements LookedAt {
+
+    /** Answers the asker alone with the checkout {@code host} addresses. */
+    @Override
+    public void lookedAt(final BuildFileSystem host) {
+        asker.update(new Located(build, asker, host.checkout(), Instant.now()));
+    }
 }

This was the longest chain in the class: four reaches through asked, and one value from this.

15. MOVE METHOD: EnvironmentVariables.lookedAt +7 −1
--- a/src/main/java/software/visionary/reboot/fs/EnvironmentVariables.java
@@ -11,5 +11,11 @@ import java.time.Instant;
 
 /** An observer asks what the variable {@code named} says on this build; the build answers it {@link EnvironmentVariabled} before this has been posted. */
 public record EnvironmentVariables(BuildFileSystem build, Observer<? super BuildEvent> asker, String named, Instant when)
-        implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/EnvironmentVariables.java
@@ -11,5 +11,11 @@ import java.time.Instant;
 
 /** An observer asks what the variable {@code named} says on this build; the build answers it {@link EnvironmentVariabled} before this has been posted. */
 public record EnvironmentVariables(BuildFileSystem build, Observer<? super BuildEvent> asker, String named, Instant when)
+        implements LookedAt {
+
+    /** Answers the asker alone with what the variable says on {@code host}, empty when it is named nowhere. */
+    @Override
+    public void lookedAt(final BuildFileSystem host) {
+        asker.update(new EnvironmentVariabled(build, asker, named, host.variable(named), Instant.now()));
+    }
 }

Three events needed this static helper. It went to the type it describes, not to a utility class.

16. MOVE METHOD: digestOf to ReceiptDigest.at +5 −0
--- a/src/main/java/software/visionary/reboot/fs/ReceiptDigest.java
@@ -46,6 +46,11 @@ public final class ReceiptDigest extends Kept {
         super(under, named, under.directory().resolve(named));
     }
 
     /**
      * The name a digest of {@code address} is kept under: its elements, written with dots. Public so
      * bob names a receipt's inputs the way the build does when it asks whether a ring means work.
+++ b/src/main/java/software/visionary/reboot/fs/ReceiptDigest.java
@@ -46,6 +46,11 @@ public final class ReceiptDigest extends Kept {
         super(under, named, under.directory().resolve(named));
     }
 
+    /** {@return what exists at {@code address} now, as the path of two names a digest is kept as} */
+    static Path at(final Path address) throws IOException {
+        return Path.of(of((Address) address));
+    }
+
     /**
      * The name a digest of {@code address} is kept under: its elements, written with dots. Public so
      * bob names a receipt's inputs the way the build does when it asks whether a ring means work.

The purest case. The answer depends only on what Digest holds, so it ignores its host.

17. MOVE METHOD: Digest.lookedAt +13 −1
--- a/src/main/java/software/visionary/reboot/fs/Digest.java
@@ -5,9 +5,21 @@
  */
 package software.visionary.reboot.fs;
 
 import java.nio.file.Path;
 import java.time.Instant;
 
 /** A subscriber asks what exists at the path now; the build answers it {@link Digested} before this has been posted. */
-public record Digest(BuildFileSystem build, Subscriber subscriber, Path path, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Digest.java
@@ -5,9 +5,21 @@
  */
 package software.visionary.reboot.fs;
 
+import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.nio.file.Path;
 import java.time.Instant;
 
 /** A subscriber asks what exists at the path now; the build answers it {@link Digested} before this has been posted. */
+public record Digest(BuildFileSystem build, Subscriber subscriber, Path path, Instant when) implements LookedAt {
+
+    /** Posts what exists at the path now. */
+    @Override
+    public void lookedAt(final BuildFileSystem host) {
+        try {
+            build.notifyObservers(new Digested(build, subscriber, path, ReceiptDigest.at(path), Instant.now()));
+        } catch (final IOException unreadable) {
+            throw new UncheckedIOException(unreadable);
+        }
+    }
 }

A private Look interface and an attempted(path, look) helper were always handed a path and a look from the same event. Look becomes a sealed kind of LookedAt, and a failed look posts a Log.

18. REPLACE PRIVATE INTERFACE WITH EVENT: Look +35 −0
--- /dev/null
@@ -0,0 +1,35 @@
+++ b/src/main/java/software/visionary/reboot/fs/Look.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Instant;
+
+/** An event that sends the build to look at a path; one it cannot make is posted as a {@link Log}, never thrown at the poster. */
+public sealed interface Look extends LookedAt permits Changed, Modified {
+
+    /** {@return the path to look at} */
+    Path path();
+
+    /**
+     * Looks at the path.
+     *
+     * @param build the build it is looked at on
+     * @throws IOException when what exists there cannot be read
+     */
+    void look(BuildFileSystem build) throws IOException;
+
+    /** Looks, logging what goes wrong on {@code host}. */
+    @Override
+    default void lookedAt(final BuildFileSystem host) {
+        try {
+            look(host);
+        } catch (final IOException | RuntimeException unmade) {
+            host.notifyObservers(new Log(host, "cannot look at " + path() + ": " + unmade.getMessage(), Instant.now()));
+        }
+    }
+}

The digests stay private to the build, behind one message.

19. MOVE METHOD: Changed.look +8 −1
--- a/src/main/java/software/visionary/reboot/fs/Changed.java
@@ -5,9 +5,16 @@
  */
 package software.visionary.reboot.fs;
 
 import java.nio.file.Path;
 import java.time.Instant;
 
 /** What exists at the path may have changed: a save digested differently, a goal that writes there completed, or a receipt was kept. */
-public record Changed(Path path, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Changed.java
@@ -5,9 +5,16 @@
  */
 package software.visionary.reboot.fs;
 
+import java.io.IOException;
 import java.nio.file.Path;
 import java.time.Instant;
 
 /** What exists at the path may have changed: a save digested differently, a goal that writes there completed, or a receipt was kept. */
+public record Changed(Path path, Instant when) implements Look {
+
+    /** Keeps the digest of what exists at the path now. */
+    @Override
+    public void look(final BuildFileSystem build) throws IOException {
+        build.digested(path, ReceiptDigest.at(path));
+    }
 }

The arbiter looked like a reason compared could not move. It only needed to hear something.

20. INTRODUCE EVENT: ResetSettle +13 −0
--- /dev/null
@@ -0,0 +1,13 @@
+++ b/src/main/java/software/visionary/reboot/fs/ResetSettle.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+/** What exists at the path changed and no goal of this build wrote it: the arbiter's budget starts over. */
+public record ResetSettle(Path path, Instant when) implements BuildEvent {
+}

It resets on ResetSettle, and on Settled, which is what the build used to do by hand.

21. The arbiter hears it +4 −0
--- a/src/main/java/software/visionary/reboot/fs/arbiter/BlackboardArbiter.java
@@ -14,6 +14,8 @@ import software.visionary.reboot.fs.BuildFileSystem;
 import software.visionary.reboot.fs.Completed;
 import software.visionary.reboot.fs.Halt;
 import software.visionary.reboot.fs.Halted;
 import software.visionary.reboot.fs.Started;
 
 import java.time.Instant;
@@ -73,6 +75,8 @@ public final class BlackboardArbiter implements Observer<BuildEvent> {
     public void update(final BuildEvent event) {
         if (event instanceof Arbitrate asked) {
             asked.asker().update(new Arbitrated(fs, asked.asker(), this, Instant.now()));
         } else if (event instanceof Started started) {
             counted(starts, started.subscriber().name());
         } else if (event instanceof Completed completed) {
+++ b/src/main/java/software/visionary/reboot/fs/arbiter/BlackboardArbiter.java
@@ -14,6 +14,8 @@ import software.visionary.reboot.fs.BuildFileSystem;
 import software.visionary.reboot.fs.Completed;
 import software.visionary.reboot.fs.Halt;
 import software.visionary.reboot.fs.Halted;
+import software.visionary.reboot.fs.ResetSettle;
+import software.visionary.reboot.fs.Settled;
 import software.visionary.reboot.fs.Started;
 
 import java.time.Instant;
@@ -73,6 +75,8 @@ public final class BlackboardArbiter implements Observer<BuildEvent> {
     public void update(final BuildEvent event) {
         if (event instanceof Arbitrate asked) {
             asked.asker().update(new Arbitrated(fs, asked.asker(), this, Instant.now()));
+        } else if (event instanceof ResetSettle || event instanceof Settled) {
+            resetSettle();
         } else if (event instanceof Started started) {
             counted(starts, started.subscriber().name());
         } else if (event instanceof Completed completed) {

The same for the bout: a visit is paid when the look is over, whatever came of it.

22. INTRODUCE EVENT: Looked +19 −0
--- /dev/null
@@ -0,0 +1,19 @@
+++ b/src/main/java/software/visionary/reboot/fs/Looked.java
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+/** The {@link Modified} at the path has been looked at, or could not be: the build owes that visit no more. */
+public record Looked(BuildFileSystem build, Path path, Instant when) implements Answered {
+
+    /** Pays the build that visit. */
+    @Override
+    public void answer() {
+        build.paid();
+    }
+}

compared and looked arrive whole. The finally stays around the look, and the two collaborators are now two posts.

23. MOVE METHOD: Modified.look and Modified.lookedAt +29 −1
--- a/src/main/java/software/visionary/reboot/fs/Modified.java
@@ -5,6 +5,7 @@
  */
 package software.visionary.reboot.fs;
 
 import java.nio.file.Path;
 import java.time.Instant;
 import java.util.Optional;
@@ -14,10 +15,37 @@ import java.util.Optional;
  *
  * @param writer the goal of this build whose run wrote the path; empty for a change no goal of this build made
  */
-public record Modified(Path path, Optional<Subscriber> writer, Instant when) implements BuildEvent {
 
     /** A change no goal of this build made. */
     public Modified(final Path path, final Instant when) {
         this(path, Optional.empty(), when);
     }
 }
+++ b/src/main/java/software/visionary/reboot/fs/Modified.java
@@ -5,6 +5,7 @@
  */
 package software.visionary.reboot.fs;
 
+import java.io.IOException;
 import java.nio.file.Path;
 import java.time.Instant;
 import java.util.Optional;
@@ -14,10 +15,37 @@ import java.util.Optional;
  *
  * @param writer the goal of this build whose run wrote the path; empty for a change no goal of this build made
  */
+public record Modified(Path path, Optional<Subscriber> writer, Instant when) implements Look {
 
     /** A change no goal of this build made. */
     public Modified(final Path path, final Instant when) {
         this(path, Optional.empty(), when);
     }
+
+    /**
+     * What exists at the path now, digested; {@link Changed} when it differs from the last digest posted there, so a
+     * write that left the bytes as they were rings nobody. A change with no writer, such as a release landing or an
+     * edit, also posts {@link ResetSettle}: the budget catches goals of one build ringing each other, never the work
+     * an outside change asks for.
+     */
+    @Override
+    public void look(final BuildFileSystem build) throws IOException {
+        final Path now = ReceiptDigest.at(path);
+        if (!now.equals(build.digested(path, now))) {
+            if (writer.isEmpty()) {
+                build.notifyObservers(new ResetSettle(path, Instant.now()));
+            }
+            build.notifyObservers(new Changed(path, Instant.now()));
+        }
+    }
+
+    /** Looks, then posts {@link Looked} whatever came of it. */
+    @Override
+    public void lookedAt(final BuildFileSystem host) {
+        try {
+            Look.super.lookedAt(host);
+        } finally {
+            host.notifyObservers(new Looked(host, path, Instant.now()));
+        }
+    }
 }

An int, two ifs and two callbacks into the build were a state machine. The states take the build as a parameter and pick their successor.

24. REPLACE STATE-ALTERING CONDITIONALS WITH STATE: BoutState +31 −0
--- /dev/null
@@ -0,0 +1,31 @@
+++ b/src/main/java/software/visionary/reboot/fs/BoutState.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.time.Instant;
+
+/** Where a {@link Bout} stands, each state posted as it is entered: {@link Busy} first, {@link Paid} as visits are, {@link Settled} last. */
+public sealed interface BoutState extends BuildEvent permits Busy, Paid, Settled {
+
+    /** {@return whether no visit is owed} */
+    default boolean isSettled() {
+        return false;
+    }
+
+    /** One more visit is owed. */
+    default void owe(final BuildFileSystem build, final Visits bout) {
+        bout.owing(1);
+    }
+
+    /** One visit is paid: the last owed settles the bout. */
+    default void paid(final BuildFileSystem build, final Visits bout) {
+        final BoutState next = bout.owing(-1) == 0
+                ? new Settled(build, build.checkout(), bout, Instant.now())
+                : new Paid(build, build.checkout(), bout, Instant.now());
+        bout.become(next);
+        build.notifyObservers(next);
+    }
+}

It holds the current state and delegates. The handle events carry stays live, so an observer told late still gets a true answer.

25. The Context: Visits +21 −19
--- a/src/main/java/software/visionary/reboot/fs/Visits.java
@@ -5,38 +5,40 @@
  */
 package software.visionary.reboot.fs;
 
-import java.util.Objects;
 
-/** One build's visits owed and paid: the first owed begins a {@link Bout}, the last paid ends it. Guarded by the build's lock. */
 final class Visits implements Bout {
 
-    private final Runnable busy;
-    private final Runnable settled;
-    private volatile int owed;
 
-    Visits(final Runnable busy, final Runnable settled) {
-        this.busy = Objects.requireNonNull(busy, "busy");
-        this.settled = Objects.requireNonNull(settled, "settled");
     }
 
     void owe() {
-        final int before = owed;
-        owed = before + 1;
-        if (before == 0) {
-            busy.run();
-        }
     }
 
     void paid() {
-        final int left = owed - 1;
-        owed = left;
-        if (left == 0) {
-            settled.run();
-        }
     }
 
     @Override
     public boolean isSettled() {
-        return owed == 0;
     }
 }
+++ b/src/main/java/software/visionary/reboot/fs/Visits.java
@@ -5,38 +5,40 @@
  */
 package software.visionary.reboot.fs;
 
+import java.time.Instant;
 
+/** One build's visits owed and paid, which its {@link BoutState} sees to. Guarded by the build's lock. */
 final class Visits implements Bout {
 
+    private final BuildFileSystem build;
+    private volatile BoutState state;
+    private int owed;
 
+    Visits(final BuildFileSystem build) {
+        this.build = build;
+        this.state = new Settled(build, build.checkout(), this, Instant.now());
     }
 
     void owe() {
+        state.owe(build, this);
     }
 
     void paid() {
+        state.paid(build, this);
+    }
+
+    /** {@return the visits owed once {@code visits} more are} */
+    int owing(final int visits) {
+        owed += visits;
+        return owed;
+    }
+
+    void become(final BoutState next) {
+        state = next;
     }
 
     @Override
     public boolean isSettled() {
+        return state.isSettled();
     }
 }

Only here does owing a visit do anything more: it begins a bout.

26. Settled is a state +16 −1
--- a/src/main/java/software/visionary/reboot/fs/Settled.java
@@ -9,5 +9,20 @@ import java.nio.file.Path;
 import java.time.Instant;
 
 /** The build over {@code checkout} owes no visit: nothing queued, running or waiting to be looked at. */
-public record Settled(BuildFileSystem build, Path checkout, Bout bout, Instant when) implements BuildEvent {
 }
+++ b/src/main/java/software/visionary/reboot/fs/Settled.java
@@ -9,5 +9,20 @@ import java.nio.file.Path;
 import java.time.Instant;
 
 /** The build over {@code checkout} owes no visit: nothing queued, running or waiting to be looked at. */
+public record Settled(BuildFileSystem build, Path checkout, Bout bout, Instant when) implements BoutState {
+
+    /** {@return that no visit is owed} */
+    @Override
+    public boolean isSettled() {
+        return true;
+    }
+
+    /** The first visit owed begins a bout. */
+    @Override
+    public void owe(final BuildFileSystem host, final Visits visits) {
+        BoutState.super.owe(host, visits);
+        final Busy busy = new Busy(host, host.checkout(), visits, Instant.now());
+        visits.become(busy);
+        host.notifyObservers(busy);
+    }
 }

A visit paid with others still owed. It is the third state, and an event like the other two.

27. INTRODUCE EVENT: Paid +13 −0
--- /dev/null
@@ -0,0 +1,13 @@
+++ b/src/main/java/software/visionary/reboot/fs/Paid.java
@@ -0,0 +1,13 @@
+/*
+ * Copyright (C) 2026 Visionary Software Solutions
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+package software.visionary.reboot.fs;
+
+import java.nio.file.Path;
+import java.time.Instant;
+
+/** The build over {@code checkout} was paid a visit and still owes others. */
+public record Paid(BuildFileSystem build, Path checkout, Bout bout, Instant when) implements BoutState {
+}

What is left is a bus, a mount and a few values only it knows. Two switch statements became two type tests with no default arm, and the file is 150 lines shorter.

28. The host, after: BuildFileSystem +21 −171
--- a/src/main/java/software/visionary/reboot/fs/BuildFileSystem.java
@@ -9,25 +9,15 @@ import software.visionary.events.MutableObservable;
 import software.visionary.events.Observer;
 import software.visionary.pylon.PylonFileSystem;
 import software.visionary.pylon.access.ReadWriteAccessGuard;
-import software.visionary.pylon.additional.Address;
 import software.visionary.pylon.additional.Relative;
 import software.visionary.reboot.fs.arbiter.BlackboardArbiter;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.io.UncheckedIOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.spi.FileSystemProvider;
-import java.time.Instant;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Executor;
@@ -53,7 +43,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     private final Map<String, ?> environment;
     private final Path module;
     private final Coordination coordination = new Coordination();
-    private final Visits bout = new Visits(this::busyBout, this::settledBout);
     private final List<Observer<? super BuildEvent>> observers = new CopyOnWriteArrayList<>();
     private final Subscriptions subscriptions;
 
@@ -81,6 +71,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
         this.provider = Objects.requireNonNull(provider, "provider");
         this.environment = Map.copyOf(environment);
         this.module = module.toAbsolutePath();
         this.root = new BuildRoot(this, derived.toAbsolutePath(), named -> variable(named).orElse(null),
                 environment.containsKey(BuildFileSystemProvider.CATALOG)
                         ? Optional.of((Path) environment.get(BuildFileSystemProvider.CATALOG)) : Catalog.launched());
@@ -100,7 +91,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     }
 
     /** {@return what the variable {@code named} says on this build: as it was created with, else as this process's} */
-    private Optional<String> variable(final String named) {
         return Optional.ofNullable(environment.containsKey(named)
                 ? environment.get(named).toString() : System.getenv(named));
     }
@@ -122,8 +113,8 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     }
 
     /**
-     * The one entry point: the arbiter and the mediator are told inside the lock, and a {@link Realize}, a
-     * {@link Find}, a {@link Halt}, an {@link AssertHalt} and a {@link DeleteHalt} are answered there, every other observer after the outermost unlock on this thread, so nothing else that does IO runs under
      * the lock.
      */
     @Override
@@ -132,142 +123,32 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
             arbiter.update(event);
             subscriptions.update(event);
             observers.forEach(observer -> coordination.defer(() -> observer.update(event)));
-            answered(event);
         });
     }
 
-    private void answered(final BuildEvent event) {
-        switch (event) {
-            case Realize asked -> realized(asked);
-            case Find asked -> asked.asker().update(new Found(asked.asker(), writtenAt(asked.address()), Instant.now()));
-            case Halt asked -> asked.asker().update(new Halted(this, asked.asker(), haltReason(), Instant.now()));
-            case AssertHalt asserted -> assertHalt(asserted.reason());
-            case DeleteHalt deleted -> clearHalt();
-            default -> { }
-        }
-    }
-
-    private void realized(final Realize asked) {
-        try {
-            asked.asker().update(new Realized(asked.asker(), realPaths(asked.path()), Instant.now()));
-        } catch (final IOException unreadable) {
-            throw new UncheckedIOException(unreadable);
-        }
     }
 
-    /**
-     * The build's own observer: looks at a path the saves went quiet at, keeps the digest posted at a change, and
-     * answers who asks. {@link Located} and {@link EnvironmentVariabled} go to the asker alone, never the bus.
-     */
     private void lookedAt(final BuildEvent event) {
-        switch (event) {
-            case Modified modified -> looked(modified);
-            case Digest asked -> answered(asked);
-            case Locate asked -> asked.asker().update(new Located(this, asked.asker(), module, Instant.now()));
-            case EnvironmentVariables asked -> asked.asker().update(new EnvironmentVariabled(this, asked.asker(),
-                    asked.named(), variable(asked.named()), Instant.now()));
-            case Changed changed -> attempted(changed.path(), () -> digested.put(changed.path(), digestOf(changed.path())));
-            default -> { }
-        }
-    }
-
-    private void answered(final Digest asked) {
-        try {
-            notifyObservers(new Digested(this, asked.subscriber(), asked.path(), digestOf(asked.path()), Instant.now()));
-        } catch (final IOException unreadable) {
-            throw new UncheckedIOException(unreadable);
-        }
-    }
-
-    private void looked(final Modified modified) {
-        try {
-            attempted(modified.path(), () -> compared(modified));
-        } finally {
-            coordination.within(bout::paid);
-        }
-    }
-
-    private interface Look {
-        void look() throws IOException;
-    }
-
-    private void attempted(final Path path, final Look look) {
-        try {
-            look.look();
-        } catch (final IOException | RuntimeException unmade) {
-            notifyObservers(new Log(this, "cannot look at " + path + ": " + unmade.getMessage(), Instant.now()));
-        }
-    }
-
-    /**
-     * What exists at the path now, digested; {@link Changed} when it differs from the last digest posted there, so a
-     * write that left the bytes as they were rings nobody. A change with no writer, such as a release landing or an
-     * edit, starts the arbiter's budget over: the budget catches goals of one build ringing each other, never the
-     * work an outside change asks for.
-     */
-    private void compared(final Modified modified) throws IOException {
-        final Path path = modified.path();
-        final Path now = digestOf(path);
-        if (!now.equals(digested.put(path, now))) {
-            if (modified.writer().isEmpty()) {
-                arbiter.resetSettle();
-            }
-            notifyObservers(new Changed(path, Instant.now()));
         }
     }
 
-    private static Path digestOf(final Path path) throws IOException {
-        return Path.of(ReceiptDigest.of((Address) path));
     }
 
-    private void busyBout() {
-        notifyObservers(new Busy(this, module, bout, Instant.now()));
-    }
-
-    private void settledBout() {
-        arbiter.resetSettle();
-        notifyObservers(new Settled(this, module, bout, Instant.now()));
-    }
-
-    /**
-     * {@return where {@code path} exists on disk} A scope's folder and the jars its links name, in the repository; a
-     * kept link where it is kept; an archive where it lands and the folder it lands in; a level the folder it holds.
-     * The root exists nowhere, being an observer's path, nor does the catalog: what this JVM reads of it is what it
-     * loaded.
-     */
-    private Set<Path> realPaths(final Path path) throws IOException {
-        final Set<Path> real = new LinkedHashSet<>();
-        switch (path) {
-            case Scope scope -> {
-                scope.entry().ifPresent(real::add);
-                for (final Held link : scope.kept()) {
-                    link.links().ifPresent(real::add);
-                }
-            }
-            case Held held -> held.links().ifPresent(real::add);
-            case Coordinate coordinate -> real.add(coordinate.kept());
-            case Kept kept -> real.add(kept.kept());
-            case Archive archive -> {
-                real.add(root.archives().directory());
-                archive.links().ifPresent(real::add);
-            }
-            case BuildRoot ignored -> { }
-            case Catalog ignored -> { }
-            default -> writtenAt(path).ifPresent(real::add);
-        }
-        return real;
-    }
-
-    /**
-     * {@return where {@code address} is written on disk: a level's directory, where the coordinate is kept, else its
-     * entry}
-     */
-    private Optional<Path> writtenAt(final Path address) {
-        return switch (address) {
-            case Level level -> Optional.of(level.directory());
-            case Coordinate coordinate -> Optional.of(coordinate.kept());
-            default -> ((Address) address).entry();
-        };
     }
 
     /**
@@ -279,39 +160,8 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
         return new BuildFileSystem(provider, module, derived, environment);
     }
 
-    private void assertHalt(final String reason) {
-        try {
-            Files.createDirectories(root.scratch().entry().orElseThrow());
-            try (OutputStream keeping = Files.newOutputStream(halt())) {
-                keeping.write(reason.getBytes(StandardCharsets.UTF_8));
-            }
-        } catch (final IOException unwritable) {
-            throw new UncheckedIOException("cannot halt " + module, unwritable);
-        }
-    }
-
-    private void clearHalt() {
-        try {
-            Files.deleteIfExists(halt());
-        } catch (final IOException unremovable) {
-            throw new UncheckedIOException("cannot let " + module + " go", unremovable);
-        }
-    }
-
-    /** {@return what {@code /control/HALT} says, asked of the checkout so every process over it agrees; empty while nothing is there} */
-    private Optional<String> haltReason() {
-        if (!Files.exists(halt())) {
-            return Optional.empty();
-        }
-        try (InputStream chain = Files.newInputStream(halt())) {
-            return Optional.of(new String(chain.readAllBytes(), StandardCharsets.UTF_8));
-        } catch (final IOException unreadable) {
-            return Optional.of("the chain could not be read");
-        }
-    }
-
     /** Where {@code /control/HALT} stands on the checkout: a place, so it always stands somewhere. */
-    private Path halt() {
         return root.control().halt().entry().orElseThrow();
     }
 
+++ b/src/main/java/software/visionary/reboot/fs/BuildFileSystem.java
@@ -9,25 +9,15 @@ import software.visionary.events.MutableObservable;
 import software.visionary.events.Observer;
 import software.visionary.pylon.PylonFileSystem;
 import software.visionary.pylon.access.ReadWriteAccessGuard;
 import software.visionary.pylon.additional.Relative;
 import software.visionary.reboot.fs.arbiter.BlackboardArbiter;
 
 import java.nio.file.Path;
 import java.nio.file.spi.FileSystemProvider;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Executor;
@@ -53,7 +43,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     private final Map<String, ?> environment;
     private final Path module;
     private final Coordination coordination = new Coordination();
+    private final Visits bout;
     private final List<Observer<? super BuildEvent>> observers = new CopyOnWriteArrayList<>();
     private final Subscriptions subscriptions;
 
@@ -81,6 +71,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
         this.provider = Objects.requireNonNull(provider, "provider");
         this.environment = Map.copyOf(environment);
         this.module = module.toAbsolutePath();
+        this.bout = new Visits(this);
         this.root = new BuildRoot(this, derived.toAbsolutePath(), named -> variable(named).orElse(null),
                 environment.containsKey(BuildFileSystemProvider.CATALOG)
                         ? Optional.of((Path) environment.get(BuildFileSystemProvider.CATALOG)) : Catalog.launched());
@@ -100,7 +91,7 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     }
 
     /** {@return what the variable {@code named} says on this build: as it was created with, else as this process's} */
+    Optional<String> variable(final String named) {
         return Optional.ofNullable(environment.containsKey(named)
                 ? environment.get(named).toString() : System.getenv(named));
     }
@@ -122,8 +113,8 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
     }
 
     /**
+     * The one entry point: the arbiter and the mediator are told inside the lock, and an {@link Answered} is answered
+     * there, every other observer after the outermost unlock on this thread, so nothing else that does IO runs under
      * the lock.
      */
     @Override
@@ -132,142 +123,32 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
             arbiter.update(event);
             subscriptions.update(event);
             observers.forEach(observer -> coordination.defer(() -> observer.update(event)));
+            if (event instanceof Answered answered) {
+                answered.answer();
+            }
         });
     }
 
+    /** The build owes one visit fewer. */
+    void paid() {
+        bout.paid();
     }
 
+    /** The build's own observer: an event it must see to outside the lock does so here. */
     private void lookedAt(final BuildEvent event) {
+        if (event instanceof LookedAt seen) {
+            seen.lookedAt(this);
         }
     }
 
+    /** {@return the digest last kept at {@code path}, {@code now} kept in its place; {@code null} for a path none was kept at} */
+    Path digested(final Path path, final Path now) {
+        return digested.put(path, now);
     }
 
+    /** {@return the checkout this build addresses} */
+    Path checkout() {
+        return module;
     }
 
     /**
@@ -279,39 +160,8 @@ public final class BuildFileSystem extends PylonFileSystem implements MutableObs
         return new BuildFileSystem(provider, module, derived, environment);
     }
 
     /** Where {@code /control/HALT} stands on the checkout: a place, so it always stands somewhere. */
+    Path halt() {
         return root.control().halt().entry().orElseThrow();
     }
 

The method that looked like it should stay

The first pass of this kata stopped here, and it was wrong. This method seemed to belong to the build:

private void looked(final Modified modified) {
    try {
        attempted(modified.path(), () -> compared(modified));
    } finally {
        coordination.within(bout::paid);
    }
}

It uses attempted, coordination and bout, all the build's own, and only the path from Modified. The count says it stays, and moving it appears to mean handing Modified three collaborators it has no use for. compared looked the same, with its hand on the arbiter.

But a bus was already right there. The arbiter didn't need to be passed; it needed to hear ResetSettle. The bout needed to hear Looked. Once those were messages, nothing tied the methods to the build, and the private Look interface turned out to be a kind of event. The count finds the envy. It can't see a collaborator that could be told instead of held.

The arm that disappeared

After the methods moved, the build still held this:

switch (event) {
    case Realize asked -> asked.realized();
    case Find asked -> asked.found();
    case Halt asked -> asked.halted(haltReason());
    case AssertHalt asserted -> assertHalt(asserted.reason());
    case DeleteHalt deleted -> clearHalt();
    case Looked looked -> bout.paid();
    default -> { }
}

Every arm is now one call, and that last line is a claim made at runtime: everything else needs no answer. A sealed Answered interface turns the claim into a fact about types. An event that needs no answer simply doesn't implement it:

if (event instanceof Answered answered) {
    answered.answer();
}

LookedAt did the same to the second switch, and had a harder job, because its arms took values from the host. Passing the digest Map through a shared signature would have made a host collaborator permanent. Instead the event sends a message, host.digested(path, now), and the map stays private.

realPaths taught the same lesson by going wrong first. Moved onto every path, it demanded an empty override from each path it didn't apply to. An empty override is a default arm in another file. The fix was a narrower interface, Real, and BuildRoot needed no override at all, since it had always answered empty.

The state machine in the host

Three methods remained: busyBout, settledBout and paid. They weren't envying anyone. They were transitions, and a counter and two ifs were choosing between them. That is STATE, as GoF describes it: the state a context depends on, written as data.

So a Bout now has a state. Visits is the Context and delegates to its current BoutState. Busy, Settled and a new Paid are the concrete states, taking the build as a parameter and choosing their own successor, the form GoF calls generally more flexible. Head First's gumball machine does the same when the next state depends on a runtime count, and the count here is the visits still owed.

This departs from both books in one way. Their states are hidden from clients, and these are events on a bus. Observers can see them but can't drive them, because owe and paid need the package-private Visits.

What is still not clean

  • Each dispatch still has one instanceof. The default arms are gone, but that check still happens at runtime, and Realize keeps one more.
  • Digest.lookedAt ignores its parameter, the price of one signature across four events.
  • BoutState constructs Settled and Paid directly. GoF names this as the cost of letting states pick their successors.
  • Each partial payment now posts a Paid event, which nothing subscribes to yet.

The count found the first methods and the rest came from reading what was left. A tool that counts members can't see a collaborator that could be told, or a state machine written as an int.

Train your LLM

The sequence is available as a JSONL file, one record per move. Every record carries the last commit's sha, and before and after hold the single file that step changes, as it stood before the first commit and after the last.

Each record has six fields: step (ordinal 1–28), sha, move (the named move), before (file contents before), after (file contents after), diff (unified diff).

TRAIN YOUR LLM HERE.