From 17bd1e828e075ec4cce9490beb9278d3141acce2 Mon Sep 17 00:00:00 2001
From: woksin
Date: Tue, 28 Jul 2026 14:31:21 +0200
Subject: [PATCH 1/3] Give the demo fixture domain identifiers and connected
serve modes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two changes, both aimed at what the recordings show.
Event source ids are now the domain's own — an ISBN keys a book, a handle keys a
member — instead of generated GUIDs. Chronicle takes any string, and it is the
difference between a read model table of readable rows and two columns of
wrapped hex.
The serve and serve-failing modes keep the client connected. A seeder that has
exited leaves every observer Disconnected, which looks like a broken system when
it is only a stopped process.
serve-failing arms the fault before connecting rather than after: Chronicle
retries a pending failed partition the moment a client reconnects, so a late arm
lets that first retry succeed and clears the failure the clip is about.
Co-Authored-By: Claude Opus 5
---
assets/demo-store/Domain.cs | 34 ++++++++-------
assets/demo-store/Program.cs | 84 +++++++++++++++++++++++-------------
assets/demo-store/reset.sh | 27 ++++++++----
3 files changed, 91 insertions(+), 54 deletions(-)
diff --git a/assets/demo-store/Domain.cs b/assets/demo-store/Domain.cs
index 7a84c1d..97ce0da 100644
--- a/assets/demo-store/Domain.cs
+++ b/assets/demo-store/Domain.cs
@@ -5,14 +5,18 @@
namespace Bookshop;
+// Event source ids here are the domain's own identifiers — an ISBN for a book, a handle for a
+// member — rather than generated GUIDs. Chronicle takes any string, and it keeps every column
+// of CLI output readable instead of 36 characters of hex.
+
[EventType]
public record MemberRegistered(string Name, string Email);
[EventType]
-public record BookAddedToInventory(string Title, string Author, string Isbn);
+public record BookAddedToInventory(string Title, string Author);
[EventType]
-public record BookBorrowed(Guid MemberId, DateTimeOffset DueBy);
+public record BookBorrowed(string MemberId, DateTimeOffset DueBy);
[EventType]
public record BookReturned();
@@ -21,45 +25,43 @@ public record BookReturned();
public record BookMarkedOverdue(int DaysLate);
[EventType]
-public record BookReservationPlaced(Guid MemberId);
+public record BookReservationPlaced(string MemberId);
-public record Book(Guid Id, string Title, string Author, string Isbn);
+public record Book(string Id, string Title, string Author);
-public record Member(Guid Id, string Name, string Email);
+public record Member(string Id, string Name, string Email);
-public record BorrowedBook(Guid Id)
+public record BorrowedBook(string Id)
{
public string Title { get; set; } = string.Empty;
- public string Member { get; set; } = string.Empty;
- public DateTimeOffset Borrowed { get; set; }
+ public string Borrower { get; set; } = string.Empty;
public DateTimeOffset DueBy { get; set; }
}
-public record OverdueBook(Guid Id)
+public record OverdueBook(string Id)
{
public string Title { get; set; } = string.Empty;
- public string Member { get; set; } = string.Empty;
public int DaysLate { get; set; }
}
public class Books : IReducerFor
{
public Task Added(BookAddedToInventory @event, Book? initialState, EventContext context) =>
- Task.FromResult(new Book(Guid.Parse(context.EventSourceId), @event.Title, @event.Author, @event.Isbn));
+ Task.FromResult(new Book(context.EventSourceId.ToString(), @event.Title, @event.Author));
}
public class Members : IReducerFor
{
public Task Registered(MemberRegistered @event, Member? initialState, EventContext context) =>
- Task.FromResult(new Member(Guid.Parse(context.EventSourceId), @event.Name, @event.Email));
+ Task.FromResult(new Member(context.EventSourceId.ToString(), @event.Name, @event.Email));
}
public class BorrowedBooks : IProjectionFor
{
public void Define(IProjectionBuilderFor builder) => builder
.From(from => from
- .Set(m => m.DueBy).To(e => e.DueBy)
- .Set(m => m.Borrowed).ToEventContextProperty(c => c.Occurred))
+ .Set(m => m.Borrower).To(e => e.MemberId)
+ .Set(m => m.DueBy).To(e => e.DueBy))
.Join(join => join
.On(m => m.Id)
.Set(m => m.Title).To(e => e.Title))
@@ -78,8 +80,8 @@ public void Define(IProjectionBuilderFor builder) => builder
}
///
-/// Sends the overdue notice. Deliberately fails for one book so the demo server has a
-/// failed partition to inspect and retry.
+/// Sends the overdue notice. Fails for one book on demand, so the triage clip has a real
+/// exception to follow rather than a contrived one.
///
public class OverdueNotices : IReactor
{
diff --git a/assets/demo-store/Program.cs b/assets/demo-store/Program.cs
index 0953efa..416960a 100644
--- a/assets/demo-store/Program.cs
+++ b/assets/demo-store/Program.cs
@@ -3,13 +3,30 @@
using Cratis.Chronicle.Connections;
// Modes:
-// seed — register artifacts, append the baseline story, leave one failing partition behind
-// drip — stay connected and append a trickle of events so a live dashboard visibly moves
+// seed — register artifacts and append the baseline story, then exit
+// serve — stay connected and healthy, so observers report Active
+// serve-failing — stay connected with the overdue notice still failing, so one partition
+// stays failed while everything around it keeps working
+// drip — stay connected and append a trickle so a live dashboard visibly moves
+//
+// A client that has exited leaves every observer Disconnected, which looks alarming in a
+// recording and is not what anyone's system looks like while it is running. The serve modes
+// exist so the demos show a live system.
var server = args.Length > 0 ? args[0] : "chronicle://chronicle-dev-client:chronicle-dev-secret@localhost:35100/";
var storeName = args.Length > 1 ? args[1] : "Bookshop";
var seconds = args.Length > 2 ? int.Parse(args[2]) : 25;
var mode = args.Length > 3 ? args[3] : "seed";
+// The book whose overdue notice cannot be sent. Armed BEFORE connecting: Chronicle retries a
+// pending failed partition the moment a client reconnects, so arming it afterwards lets that
+// first retry succeed and clears the very failure the triage clip is about.
+const string FailingBook = "978-0131177055";
+
+if (string.Equals(mode, "serve-failing", StringComparison.Ordinal))
+{
+ OverdueNotices.FailForEventSourceId = FailingBook;
+}
+
var options = new ChronicleOptions(new ChronicleConnectionString(server));
using var client = new ChronicleClient(options);
var eventStore = await client.GetEventStore(storeName);
@@ -17,28 +34,35 @@
// Give the client time to register artifacts with the server.
await Task.Delay(TimeSpan.FromSeconds(5));
-// Deterministic ids so every re-render of the tapes shows the same identifiers.
-static Guid Id(int n) => new($"0000{n:0000}-1111-4222-8333-444444444444");
-
+// Domain identifiers, not GUIDs — an ISBN keys a book and a handle keys a member. They are
+// stable across re-renders and readable in every column the CLI prints.
var members = new[]
{
- (Id(1), "Ada Wong", "ada@example.com"),
- (Id(2), "Grace Miller", "grace@example.com"),
- (Id(3), "Kaito Mori", "kaito@example.com"),
+ ("ada.wong", "Ada Wong", "ada@example.com"),
+ ("grace.miller", "Grace Miller", "grace@example.com"),
+ ("kaito.mori", "Kaito Mori", "kaito@example.com"),
};
var books = new[]
{
- (Id(10), "The Pragmatic Programmer", "Hunt & Thomas", "978-0135957059"),
- (Id(11), "Domain-Driven Design", "Eric Evans", "978-0321125217"),
- (Id(12), "Designing Data-Intensive Applications", "Martin Kleppmann", "978-1449373320"),
- (Id(13), "Refactoring", "Martin Fowler", "978-0134757599"),
- (Id(14), "Working Effectively with Legacy Code", "Michael Feathers", "978-0131177055"),
- (Id(15), "Release It!", "Michael Nygard", "978-1680502398"),
- (Id(16), "Accelerate", "Forsgren, Humble & Kim", "978-1942788331"),
- (Id(17), "The Mythical Man-Month", "Fred Brooks", "978-0201835953"),
+ ("978-0135957059", "The Pragmatic Programmer", "Hunt & Thomas"),
+ ("978-0321125217", "Domain-Driven Design", "Eric Evans"),
+ ("978-1449373320", "Designing Data-Intensive Applications", "Martin Kleppmann"),
+ ("978-0134757599", "Refactoring", "Martin Fowler"),
+ ("978-0131177055", "Working Effectively with Legacy Code", "Michael Feathers"),
+ ("978-1680502398", "Release It!", "Michael Nygard"),
+ ("978-1942788331", "Accelerate", "Forsgren, Humble & Kim"),
+ ("978-0201835953", "The Mythical Man-Month", "Fred Brooks"),
};
+if (mode is "serve" or "serve-failing")
+{
+ Console.WriteLine($"{mode}: connected for {seconds}s");
+ await Task.Delay(TimeSpan.FromSeconds(seconds));
+ Console.WriteLine("done");
+ return;
+}
+
if (string.Equals(mode, "drip", StringComparison.Ordinal))
{
// The overdue notice succeeds here — this mode exists to make a live dashboard move,
@@ -50,9 +74,9 @@
{
var book = books[(n + 6) % books.Length];
var member = members[n % members.Length];
- await eventStore.EventLog.Append(book.Item1.ToString(), new BookReservationPlaced(member.Item1));
+ await eventStore.EventLog.Append(book.Item1, new BookReservationPlaced(member.Item1));
await Task.Delay(TimeSpan.FromMilliseconds(1500));
- await eventStore.EventLog.Append(book.Item1.ToString(), new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14)));
+ await eventStore.EventLog.Append(book.Item1, new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(14)));
await Task.Delay(TimeSpan.FromMilliseconds(1500));
n++;
}
@@ -63,12 +87,12 @@
foreach (var (id, name, email) in members)
{
- await eventStore.EventLog.Append(id.ToString(), new MemberRegistered(name, email));
+ await eventStore.EventLog.Append(id, new MemberRegistered(name, email));
}
-foreach (var (id, title, author, isbn) in books)
+foreach (var (id, title, author) in books)
{
- await eventStore.EventLog.Append(id.ToString(), new BookAddedToInventory(title, author, isbn));
+ await eventStore.EventLog.Append(id, new BookAddedToInventory(title, author));
}
var dueIn = new[] { 9, 12, 6, -3, -11, 4 };
@@ -76,23 +100,23 @@
{
var member = members[i % members.Length];
await eventStore.EventLog.Append(
- books[i].Item1.ToString(),
+ books[i].Item1,
new BookBorrowed(member.Item1, DateTimeOffset.UtcNow.AddDays(dueIn[i])));
}
-await eventStore.EventLog.Append(books[0].Item1.ToString(), new BookReturned());
-await eventStore.EventLog.Append(books[1].Item1.ToString(), new BookReturned());
+await eventStore.EventLog.Append(books[0].Item1, new BookReturned());
+await eventStore.EventLog.Append(books[1].Item1, new BookReturned());
-await eventStore.EventLog.Append(books[2].Item1.ToString(), new BookReservationPlaced(members[0].Item1));
-await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookReservationPlaced(members[2].Item1));
+await eventStore.EventLog.Append(books[2].Item1, new BookReservationPlaced(members[0].Item1));
+await eventStore.EventLog.Append(books[3].Item1, new BookReservationPlaced(members[2].Item1));
// Two books go overdue. The notice for the second one cannot be sent, which leaves a
// failed partition behind for the CLI to find.
-OverdueNotices.FailForEventSourceId = books[4].Item1.ToString();
-await eventStore.EventLog.Append(books[3].Item1.ToString(), new BookMarkedOverdue(3));
-await eventStore.EventLog.Append(books[4].Item1.ToString(), new BookMarkedOverdue(11));
+OverdueNotices.FailForEventSourceId = FailingBook;
+await eventStore.EventLog.Append(books[3].Item1, new BookMarkedOverdue(3));
+await eventStore.EventLog.Append(books[4].Item1, new BookMarkedOverdue(11));
-Console.WriteLine($"seeded. failing partition = {books[4].Item1} ({books[4].Item2})");
+Console.WriteLine($"seeded. failing partition = {FailingBook} ({books[4].Item2})");
await Task.Delay(TimeSpan.FromSeconds(seconds));
Console.WriteLine("done");
diff --git a/assets/demo-store/reset.sh b/assets/demo-store/reset.sh
index 435e3e7..b6a2efd 100755
--- a/assets/demo-store/reset.sh
+++ b/assets/demo-store/reset.sh
@@ -1,12 +1,21 @@
#!/bin/bash
# Rebuild the event store the README GIFs are recorded against.
#
-# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed
-# story that ends with one reactor partition failing. Identifiers are deterministic, so
-# every re-render of the tapes shows the same ids as the committed GIFs.
+# Starts a throwaway Chronicle server, connects the Bookshop client, and appends a fixed story.
+# Identifiers are deterministic, so every re-render shows the same ids as the committed GIFs.
#
-# ./reset.sh # fresh server, seed, exit
-# ./reset.sh drip 40 # append a trickle for 40s against the running server
+# ./reset.sh # fresh server, seed, exit
+# ./reset.sh serve 90 # stay connected and healthy for 90s
+# ./reset.sh serve-failing 90 # stay connected with one partition still failing
+# ./reset.sh drip 40 # append a trickle against the running server
+#
+# The serve modes matter for recording. A client that has exited leaves every observer
+# Disconnected, which looks like a broken system when it is only a stopped process.
+#
+# Deliberately NOT Chronicle's default 35000. Anything else on this machine — an Aspire
+# AppHost's control plane, a compose stack — that has claimed 127.0.0.1:35000 wins the name
+# `localhost`, and this script would then health-check and seed somebody else's server.
+# Override with CHRONICLE_PORT only if you are sure 35000 is yours.
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
@@ -18,9 +27,11 @@ BOOKSHOP="$HERE/bin/Release/net10.0/Bookshop"
[ -x "$BOOKSHOP" ] || dotnet build "$HERE/DemoStore.csproj" -c Release
-if [ "$1" = "drip" ]; then
- exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-40}" drip
-fi
+case "$1" in
+ serve|serve-failing|drip)
+ exec "$BOOKSHOP" "$CONNECTION" Bookshop "${2:-60}" "$1"
+ ;;
+esac
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$CONTAINER" -p "$PORT":35000 -p 30100:30000 -p 11100:11111 "$IMAGE" >/dev/null
From 64d9888804d3f321b7d852bcb4c599638cdffce8 Mon Sep 17 00:00:00 2001
From: woksin
Date: Tue, 28 Jul 2026 14:31:44 +0200
Subject: [PATCH 2/3] Rebuild the README clips around normal usage rather than
failure
The set was built entirely around a broken store. The hero opened on red crosses
and a four-deep stack trace, three observers showed Disconnected because the
seeder had exited, and the palette clip led with a failure row. It read as a tool
for when everything is on fire rather than a tool for looking at an event store.
Three of the four clips now run against a healthy connected system:
- demo is it healthy, what happened, what state did that produce
- workbench filtering a view, then searching every artifact kind at once
- completions tab completion asking the server, using a read-only command
- triage the one failure clip: one stopped partition in a healthy store
completions no longer types `observers replay` to demonstrate completion. It was
never run, but a stray keystroke in a tape stood between a demo and a replay, and
completing a read model name shows the same thing without that.
record.sh renders the set and asserts the store is in the state a clip needs
before rolling. A clip recorded against the wrong state still produces a
perfectly good-looking GIF of the wrong thing, which is how two broken clips
nearly shipped.
Co-Authored-By: Claude Opus 5
---
assets/completions.tape | 24 +++++------
assets/demo.tape | 41 ++++++-------------
assets/palette.tape | 31 --------------
assets/record.sh | 91 +++++++++++++++++++++++++++++++++++++++++
assets/triage.tape | 28 +++++++++++++
assets/workbench.tape | 43 +++++++++++++++++++
6 files changed, 184 insertions(+), 74 deletions(-)
delete mode 100644 assets/palette.tape
create mode 100755 assets/record.sh
create mode 100644 assets/triage.tape
create mode 100644 assets/workbench.tape
diff --git a/assets/completions.tape b/assets/completions.tape
index edd61f1..ac6fb12 100644
--- a/assets/completions.tape
+++ b/assets/completions.tape
@@ -1,11 +1,10 @@
# Tab completion that goes and asks the server.
#
-# assets/demo-store/reset.sh
-# vhs assets/completions.tape
+# assets/record.sh
#
-# The observer ids offered here are not a static word list baked into the completion script.
-# The script shells back into `cratis _complete observers`, which opens a connection and
-# returns what that server has registered at that moment.
+# The read model names offered here are not a static word list baked into the completion
+# script. The script shells back into `cratis _complete read-models`, which opens a connection
+# and returns what that server has registered at that moment.
#
# Recorded under zsh rather than bash: the generated bash script calls `_init_completion`,
# which comes from the bash-completion package, and the macOS system bash is 3.2 without it.
@@ -23,19 +22,16 @@ Type "clear" Enter
Sleep 1s
Show
-# `replay` reprocesses an observer from sequence zero. This tape never sends Enter — it ends
-# on Ctrl+C, which abandons the line. Audit before rendering: grep -n 'Enter' completions.tape
-Type "cratis chronicle observers replay "
+Type "cratis chronicle read-models instances "
Sleep 1500ms
Tab
Sleep 4s
-Type "Bookshop.O"
+# The first Tab inserted the common prefix `Bookshop.` shared by all four. `Bor` is the
+# shortest string that picks one of them, so the second Tab can complete it in full.
+Type "Bor"
Sleep 1s
Tab
-Sleep 1500ms
-Tab
-Sleep 4s
-
-Ctrl+C
Sleep 2s
+Enter
+Sleep 6s
diff --git a/assets/demo.tape b/assets/demo.tape
index 928d1ab..53c3021 100644
--- a/assets/demo.tape
+++ b/assets/demo.tape
@@ -1,11 +1,11 @@
-# Hero. The arc this tool exists for: a verdict, the event that caused it, then the live view.
+# Hero. The three questions you actually open this tool to answer: is it healthy, what
+# happened, and what state did that produce.
#
-# assets/demo-store/reset.sh # seed the store and leave one partition failing
-# vhs assets/demo.tape
+# assets/record.sh # sets up the store and renders every tape
#
-# Nothing here is driven by arrow keys. vhs cannot deliver them to the application on the
-# recording machine — bash's own history does not respond to Up either — so the workbench
-# segment uses only keys that verifiably arrive: F, printable characters, Escape and q.
+# Recorded against a live, connected client so observers report Active. A client that has
+# exited leaves everything Disconnected, which looks like a broken system when it is only a
+# stopped process.
Output assets/demo.gif
@@ -20,29 +20,12 @@ Show
Type "cratis chronicle diagnose" Enter
Sleep 6s
-# A partition id is 36 characters of nothing happening at reading speed, so this one line
-# is typed fast. Everything the viewer is meant to read is at the default speed.
-Set TypingSpeed 18ms
-Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 00000014-1111-4222-8333-444444444444" Enter
-Set TypingSpeed 55ms
+Type "clear" Enter
+Sleep 300ms
+Type "cratis chronicle events get --from 11" Enter
Sleep 7s
Type "clear" Enter
-Sleep 500ms
-
-Type "cratis chronicle workbench" Enter
-Sleep 9s
-
-# F opens the filter on whichever view is showing. Typed slowly here because the narrowing
-# is the content.
-Type "F"
-Sleep 1500ms
-Set TypingSpeed 140ms
-Type "Bookshop"
-Set TypingSpeed 55ms
-Sleep 4s
-
-Escape
-Sleep 1500ms
-Type "q"
-Sleep 2s
+Sleep 300ms
+Type "cratis chronicle read-models instances Bookshop.BorrowedBook" Enter
+Sleep 8s
diff --git a/assets/palette.tape b/assets/palette.tape
deleted file mode 100644
index 2eb8a45..0000000
--- a/assets/palette.tape
+++ /dev/null
@@ -1,31 +0,0 @@
-# One query, every kind of artifact that touches it.
-#
-# assets/demo-store/reset.sh
-# vhs assets/palette.tape
-
-Output assets/palette.gif
-
-Set Shell bash
-Source assets/_style.tape
-
-Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
-Type "clear" Enter
-Sleep 1s
-Show
-
-Type "cratis chronicle workbench" Enter
-Sleep 9s
-
-Ctrl+P
-Sleep 2s
-
-# Typed slowly: the results resolving as the query narrows is the whole point of the clip.
-Set TypingSpeed 160ms
-Type "Overdue"
-Set TypingSpeed 55ms
-Sleep 5s
-
-Escape
-Sleep 1s
-Type "q"
-Sleep 1500ms
diff --git a/assets/record.sh b/assets/record.sh
new file mode 100755
index 0000000..aa8bbff
--- /dev/null
+++ b/assets/record.sh
@@ -0,0 +1,91 @@
+#!/bin/bash
+# Renders every README GIF from scratch.
+#
+# assets/record.sh # all of them
+# assets/record.sh demo # just one
+#
+# Each tape needs a particular server state, and the difference matters: three of the four are
+# recorded against a live, connected client so observers report Active, and only triage is
+# recorded with a fault armed. Getting that wrong produces a set where everything looks broken.
+set -e
+
+HERE="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$HERE/.." && pwd)"
+cd "$REPO"
+
+[ -x Source/Cli/bin/Release/net10.0/Cratis.Cli ] || dotnet build -c Release
+
+stop_client() { pkill -f 'bin/Release/net10.0/Bookshop' 2>/dev/null || true; }
+trap stop_client EXIT
+
+# Waits for the client's observers to report Active before rolling, otherwise the first frames
+# catch a store that is still connecting.
+start_client() {
+ stop_client
+ nohup "$HERE/demo-store/reset.sh" "$1" 600 >/dev/null 2>&1 &
+ printf 'connecting'
+ for _ in $(seq 1 40); do
+ if HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 \
+ "$HERE/.recording-bin/cratis" chronicle observers list -o plain 2>/dev/null \
+ | grep -q 'Bookshop.*Active'; then
+ echo; return
+ fi
+ printf '.'; sleep 2
+ done
+ echo; echo "warning: client never reported Active" >&2
+}
+
+cli() {
+ HOME="$HERE/.recording-home" CRATIS_NO_UPDATE_CHECK=1 "$HERE/.recording-bin/cratis" "$@"
+}
+
+# Renders are expensive and their failures are silent — a clip against the wrong state still
+# produces a perfectly good-looking GIF of the wrong thing. So the state is asserted first.
+require_state() {
+ local want=$1 n=0
+ printf 'waiting for %s state' "$want"
+ while [ $n -lt 30 ]; do
+ local failures
+ failures=$(cli chronicle failed-partitions list -q 2>/dev/null | grep -c . || true)
+ case "$want" in
+ healthy) [ "$failures" = "0" ] && { echo; return; } ;;
+ failing) [ "$failures" != "0" ] && { echo; return; } ;;
+ esac
+ printf '.'; sleep 2; n=$((n + 1))
+ done
+ echo
+ echo "error: store never reached '$want' state — refusing to record it" >&2
+ exit 1
+}
+
+render() {
+ echo "── $1"
+ vhs "$HERE/$1.tape"
+}
+
+"$HERE/demo-store/reset.sh"
+HOME="$HERE/.recording-home" bash "$HERE/prepare-env.sh"
+
+want() { [ $# -eq 0 ] || [ -z "$1" ] || [ "$1" = "$2" ]; }
+
+if want "$1" demo || want "$1" workbench || want "$1" completions; then
+ # A healthy client clears the seeded failure through Chronicle's own retry, which is
+ # exactly the state these three want.
+ start_client serve
+ require_state healthy
+ want "$1" demo && render demo
+ want "$1" workbench && render workbench
+ want "$1" completions && render completions
+fi
+
+if want "$1" triage; then
+ # A fresh store, then the fault armed before the client connects — Chronicle retries a
+ # pending failed partition the moment a client reconnects, so a late arm lets that first
+ # retry succeed and clears the very failure this clip is about.
+ "$HERE/demo-store/reset.sh"
+ start_client serve-failing
+ require_state failing
+ render triage
+fi
+
+echo "done"
diff --git a/assets/triage.tape b/assets/triage.tape
new file mode 100644
index 0000000..2dd4936
--- /dev/null
+++ b/assets/triage.tape
@@ -0,0 +1,28 @@
+# The one clip about something being wrong: a single failing partition in an otherwise
+# healthy store, and the trail from the verdict to the exception that caused it.
+#
+# assets/record.sh
+#
+# Recorded against `reset.sh serve-failing`, so the client is connected and every other
+# observer is Active. The only red thing on screen is the actual problem.
+
+Output assets/triage.gif
+
+Set Shell bash
+Source assets/_style.tape
+
+Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
+Type "clear" Enter
+Sleep 1s
+Show
+
+Type "cratis chronicle diagnose" Enter
+Sleep 6s
+
+Type "cratis chronicle failed-partitions list" Enter
+Sleep 5s
+
+# -o table is explicit because the pipe makes the CLI think a machine is reading and switch
+# to JSON — correct behaviour, wrong for a demo. head keeps the first attempt on screen.
+Type "cratis chronicle failed-partitions show Bookshop.OverdueNotices 978-0131177055 -o table | head -16" Enter
+Sleep 8s
diff --git a/assets/workbench.tape b/assets/workbench.tape
new file mode 100644
index 0000000..e4722c4
--- /dev/null
+++ b/assets/workbench.tape
@@ -0,0 +1,43 @@
+# The workbench: filtering a view, then searching every artifact kind at once.
+#
+# assets/record.sh
+#
+# Not driven by arrow keys — vhs cannot deliver them to the application on the recording
+# machine, and bash's own history does not respond to Up either. Only keys that verifiably
+# arrive are used: F, printable characters, Escape and q.
+
+Output assets/workbench.gif
+
+Set Shell bash
+Source assets/_style.tape
+
+Type `export PS1='\[\033[38;2;100;180;255m\]❯\[\033[0m\] '` Enter
+Type "clear" Enter
+Sleep 1s
+Show
+
+Type "cratis chronicle workbench" Enter
+Sleep 9s
+
+# F filters whichever view is showing. Typed slowly because the narrowing is the content.
+Type "F"
+Sleep 1200ms
+Set TypingSpeed 140ms
+Type "Bookshop"
+Set TypingSpeed 55ms
+Sleep 3500ms
+Escape
+Sleep 1500ms
+
+# Ctrl+P searches across every kind at once.
+Ctrl+P
+Sleep 1500ms
+Set TypingSpeed 150ms
+Type "Overdue"
+Set TypingSpeed 55ms
+Sleep 4500ms
+
+Escape
+Sleep 1s
+Type "q"
+Sleep 1500ms
From 819b4d59ee96ba6206b9ee14c50b56640a00cc8f Mon Sep 17 00:00:00 2001
From: woksin
Date: Tue, 28 Jul 2026 14:31:44 +0200
Subject: [PATCH 3/3] Re-record the GIFs and correct the README figures they
contradicted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rendering against a live client exposed two things the README had wrong, both
artifacts of a fixture whose seeder had exited rather than facts about the tool.
Next# does not sit behind the log tail for observers that skip event types — with
a connected client every observer reaches tail+1. It is LastHandled# that
differs, because it records the last event each one actually cared about. The
note explaining the columns said the opposite.
Output-format byte counts are re-measured; shorter identifiers moved them.
Co-Authored-By: Claude Opus 5
---
README.md | 108 +++++++++++++++++++++++------------------
assets/RECORDING.md | 80 ++++++++++++++++++------------
assets/completions.gif | Bin 90557 -> 89773 bytes
assets/demo.gif | Bin 503473 -> 289430 bytes
assets/palette.gif | Bin 291567 -> 0 bytes
assets/triage.gif | Bin 0 -> 231224 bytes
assets/workbench.gif | Bin 0 -> 357056 bytes
7 files changed, 110 insertions(+), 78 deletions(-)
delete mode 100644 assets/palette.gif
create mode 100644 assets/triage.gif
create mode 100644 assets/workbench.gif
diff --git a/README.md b/README.md
index 0c37ed8..aeef082 100644
--- a/README.md
+++ b/README.md
@@ -15,10 +15,10 @@
-
+
- One command for the verdict, one for the exception behind it, then the live view.
- The reactor could not reach its mail server; the CLI names the observer, the partition and the event.
+ Is it healthy, what happened, and what state did that produce —
+ the three questions, in the order you actually ask them.
---
@@ -43,14 +43,14 @@ the server's auth, and you are on a box you reached over SSH.
```
❯ cratis chronicle diagnose
-── Chronicle Diagnostics 10:15:16 ─────────────────────────────────────────────
+── Chronicle Diagnostics 14:18:21 ─────────────────────────────────────────────
server: chronicle://chronicle-dev-client:***@localhost:35100/
event store: Bookshop / Default
✓ Connection connected
✓ Server version 16.7.0
✓ Event stores 2 stores: System, Bookshop
- ✓ Observers 6 active 3 disconnected
+ ✓ Observers 9 active
✗ Failed partitions 1 need attention → cratis chronicle failed-partitions list
✓ Recommendations none
✓ Event sequence tail: 22
@@ -161,13 +161,14 @@ Measured on one store of 23 events and 9 observers:
| | `events get` | `observers list` | `event-types list` |
|---|---|---|---|
-| `-o plain` | 2,133 B | 794 B | 561 B |
-| `-o json-compact` | 8,250 B | 1,701 B | 1,656 B |
-| `-o json` | 9,966 B | 2,110 B | 2,505 B |
+| `-o plain` | 1,615 B | 777 B | 561 B |
+| `-o json-compact` | 7,329 B | 1,684 B | 1,656 B |
+| `-o json` | 8,981 B | 2,093 B | 2,505 B |
| `-q` | 59 B | 403 B | 310 B |
-`plain` is tab-separated and about 4.5× smaller than `json` here — the gap widens with row
-count, because JSON repeats every field name on every row. `-q` exists to be piped:
+`plain` is tab-separated and 2.7× to 5.6× smaller than `json` on this store — widest on
+`events get`, where JSON repeats four field names across every one of 23 rows. `-q` exists to
+be piped:
```bash
cratis chronicle observers list -q | xargs -I {} cratis chronicle observers replay {} -y
@@ -179,30 +180,33 @@ This is the table you will spend the most time in, and two of its columns are ea
misread:
```
-Id Type State Quarantined Next# LastHandled# Subscribed
-Bookshop.Members Reducer Disconnected False 3 2 False
-Bookshop.Books Reducer Disconnected False 11 10 False
-Bookshop.OverdueNotices Reactor Disconnected False 22 21 False
-Bookshop.BorrowedBooks Projection Active False 19 18 False
-Bookshop.OverdueBooks Projection Active False 23 22 False
+Id Type State Quarantined Next# LastHandled# Subscribed
+Bookshop.Members Reducer Active False 23 2 False
+Bookshop.Books Reducer Active False 23 10 False
+Bookshop.BorrowedBooks Projection Active False 23 18 False
+Bookshop.OverdueBooks Projection Active False 23 22 False
+Bookshop.OverdueNotices Reactor Active False 23 22 False
```
| Column | What it means |
|---|---|
-| `LastHandled#` | the last event this observer finished processing |
-| `Next#` | the next sequence number it will look at |
+| `Next#` | the next sequence number this observer will look at |
+| `LastHandled#` | the last event it actually processed |
| `State` | `Active`, `Replaying`, `Suspended`, `Disconnected`, `Quarantined` or `Unknown` |
| `Subscribed` | whether a client is currently attached to it |
> [!NOTE]
-> **A `Next#` behind the log tail is not by itself a problem.** An observer only advances over
-> events it subscribes to. Above, the tail is 22 and `BorrowedBooks` sits at 19 — events 19
-> through 22 are reservations and overdue markings, none of which it observes. It is fully
-> caught up. This is why `diagnose` reports failed partitions rather than sequence lag: lag is
-> ambiguous, a failed partition is not.
-
-`Disconnected` means no client is attached — usually the application is not running.
-It is the normal state for a store whose application is stopped, and it is not an error.
+> **`LastHandled#` lagging the tail is normal, and is not the same thing as being behind.**
+> Every observer above has `Next#` 23 against a tail of 22 — all of them are caught up. But
+> `Members` last handled sequence 2, because no member has registered since; nothing between 3
+> and 22 was addressed to it. The two columns answer different questions: `Next#` is how far it
+> has read, `LastHandled#` is the last thing it cared about.
+>
+> This is why `diagnose` reports failed partitions rather than sequence lag. Lag is ambiguous.
+> A failed partition is not.
+
+`Disconnected` means no client is attached — usually the application is not running. It is the
+normal state for a store whose application is stopped, and it is not an error.
## Following a failure to the event that caused it
@@ -210,26 +214,36 @@ A partition is one event source's slice of an observer. When processing throws,
stops **that partition** and leaves the rest of the observer running, so one bad entity does
not halt everything. `failed-partitions show` prints what happened, per attempt:
+
+

+
+
+Nine observers running, one partition stopped. The verdict names the next command, the
+list names the observer and the partition, and the partition turns out to be a book —
+`978-0131177055` — whose overdue notice could not be sent.
+
+The partition is the ISBN because that is the event source id this application uses. Whatever
+your entities are keyed by is what you will see here, which is what makes the failure
+addressable rather than merely reported:
+
```
-FailedPartition: 3447a2eb-5dc8-41d5-ab80-30948951dd44
+FailedPartition: caadc869-1251-41d0-9063-6947eaf74043
Observer: Bookshop.OverdueNotices
-Partition: 00000014-1111-4222-8333-444444444444
-Attempts: 4
+Partition: 978-0131177055
+Attempts: 5
- --- Attempt at 2026-07-27T23:46:06.9840000+00:00 (Seq# 22) ---
+ --- Attempt at 2026-07-28T12:17:56.6680000+00:00 (Seq# 22) ---
Exception has been thrown by the target of an invocation.
smtp.bookshop.local: connection refused
- ...
- --- Attempt at 2026-07-27T23:46:21.4580000+00:00 (Seq# 22) ---
```
-Four attempts, and the gaps between them widen — 2 seconds, then 4, then 8. Chronicle backs
-off and keeps retrying on its own, so a partition that failed on something transient
-recovers without you. `retry-partition` exists for the other case: you have fixed the cause
-and do not want to wait for the next attempt.
+The gaps between attempts widen — 2 seconds, then 4, then 8. Chronicle backs off and keeps
+retrying on its own, so a partition that failed on something transient recovers without you.
+`retry-partition` exists for the other case: you have fixed the cause and do not want to wait
+for the next attempt.
```bash
-cratis chronicle observers retry-partition Bookshop.OverdueNotices 00000014-…-444444444444 -y
+cratis chronicle observers retry-partition Bookshop.OverdueNotices 978-0131177055 -y
```
> [!WARNING]
@@ -249,12 +263,13 @@ selected observer, `T` retries a failed partition, `S` and `U` stop and resume j
`Ctrl+P` is the part worth knowing about. It searches **every kind of artifact at once**:
-

+
-One word, six matches, five kinds — the reactor and the projection's observer, the event
-type they both read, the projection declaration, the read model it writes, and the failure the
-reactor left behind. Picking one jumps to its view with the filter already applied.
+`F` narrows the view to one application. `Ctrl+P` then matches a single word across five
+kinds at once — the reactor, the projection's observer, the event type they both read, the
+projection declaration and the read model it writes. Picking one jumps to its view with the
+filter already applied.
That breadth is the reason it is a palette and not a search box. "Overdue" is not a name you
look up in one list; it is a thread running through five of them, and following it is what
@@ -331,11 +346,11 @@ can work out that a stuck observer means `failed-partitions show` without being
It is not a static word list:
-

+
-Completing an observer id shells back into the CLI, which connects and returns what that
-server has registered right now. Typing narrows to the two that match.
+Completing a read model name shells back into the CLI, which connects and returns what that
+server has registered right now — then the completed command runs against it.
Observers, event stores, event types, projections, read models, jobs, recommendations,
subscriptions, applications and users all complete this way — and context names, which come
@@ -487,9 +502,8 @@ observes one thing, so a failure reads as a sentence.
They are scripted, not screen-captured, and re-render from a clean checkout:
```bash
-dotnet build -c Release
-assets/demo-store/reset.sh # a throwaway Chronicle server with a story seeded into it
-vhs assets/demo.tape # or palette, completions
+assets/record.sh # every clip: sets up the store, waits for it, renders
+assets/record.sh workbench # or just one
```
[`assets/RECORDING.md`](assets/RECORDING.md) covers how they were made, what the fixture
diff --git a/assets/RECORDING.md b/assets/RECORDING.md
index abb1aab..f8b5d3d 100644
--- a/assets/RECORDING.md
+++ b/assets/RECORDING.md
@@ -4,9 +4,8 @@ Everything in this directory that ends in `.tape` is a script. Nothing was perfo
nothing was screen-captured, and any of it re-renders from a clean checkout:
```bash
-dotnet build -c Release
-assets/demo-store/reset.sh
-vhs assets/demo.tape # or palette, completions
+assets/record.sh # all four
+assets/record.sh workbench # or one
```
That is the point. A hand-recorded screencast is a one-off you cannot fix a typo in; a tape is
@@ -34,8 +33,9 @@ customer system — so [`demo-store/`](demo-store/) builds a third option: a thr
in Docker with a small bookshop seeded into it.
```bash
-assets/demo-store/reset.sh # fresh server, seed, exit
-assets/demo-store/reset.sh drip 40 # append a trickle against a running one
+assets/demo-store/reset.sh # fresh server, seed, exit
+assets/demo-store/reset.sh serve 90 # stay connected and healthy
+assets/demo-store/reset.sh serve-failing 90 # stay connected with one partition failing
```
It registers eight books, three members, two projections, two reducers and one reactor, then
@@ -43,11 +43,16 @@ appends a story: borrowings, two returns, two reservations, two books going over
reactor that sends overdue notices **throws for exactly one book**, which is what leaves a
failed partition behind for the CLI to find.
-Two properties matter:
+Three properties matter:
-- **Deterministic identifiers.** Event source ids are generated from a fixed pattern rather
- than `Guid.NewGuid()`, so `00000014-1111-4222-8333-444444444444` is the same book on every
- re-render and the tapes can name it directly.
+- **Domain identifiers, not GUIDs.** A book is keyed by its ISBN and a member by a handle, so
+ `978-0131177055` is the same book on every re-render, the tapes can name it directly, and
+ every column the CLI prints stays readable. The first version of this fixture used
+ `Guid.NewGuid()` and the read model table came out as two columns of wrapped hex.
+- **A connected client.** Three of the four clips run against `serve`, so observers report
+ Active. A client that has exited leaves everything `Disconnected`, which looks like a broken
+ system when it is only a stopped process — the first cut of this set had three red
+ `Disconnected` rows in the hero for no reason other than that the seeder had finished.
- **The failure survives.** Chronicle retries a failed partition on its own with a widening
backoff, and it *succeeds* the moment the client reconnects without the fault. So the seeder
exits after seeding and the partition stays failed. An earlier version of the hero tape ran a
@@ -151,8 +156,8 @@ Two consequences worth knowing before editing a tape:
## The settings, and what each is for
All of them live in [`_style.tape`](_style.tape), which every other tape pulls in with
-`Source assets/_style.tape`. Three recordings that drift apart in font size or theme read as
-three screencasts; one shared file makes them read as one set.
+`Source assets/_style.tape`. Four recordings that drift apart in font size or theme read as
+four screencasts; one shared file makes them read as one set.
| Setting | Why |
|---|---|
@@ -179,40 +184,53 @@ are not written the same way — and `completions.tape` has to be zsh.
The most useful discipline here was editorial, not technical.
-**One hero, then short single-purpose clips.** The top of the README gets the full arc — the
-verdict, the exception behind it, the live view — at about 35 seconds. Everything after is
-15-20 seconds and shows exactly one thing, sitting next to the prose that explains it.
+**Show the tool working, not the system failing.** The first version of this set was built
+entirely around a broken store: the hero opened on red ✗ marks and a four-deep stack trace,
+and three observers showed `Disconnected` because the seeder had exited. It read as "this is
+for when everything is on fire" rather than "this is what the tool does". Three of the four
+clips now run against a healthy, connected system, and exactly one is about a failure.
+
+**One hero, then short single-purpose clips.** The top of the README gets the three questions
+you actually open the tool to answer — is it healthy, what happened, what state did that
+produce — at about 29 seconds. Everything after is 15-25 seconds and shows one thing, sitting
+next to the prose that explains it.
**A GIF has to show something text cannot.** That is the whole test.
-- ✅ `demo.gif` — `diagnose` printing a verdict and naming the next command, then a full-screen
- dashboard painting itself over the same terminal and narrowing live as a filter is typed.
-- ✅ `palette.gif` — one word matching an observer, an event type, a projection, a read model
- and a failure *at the same time*. A screenshot shows six rows; it cannot show them arriving
- as the query resolves.
+- ✅ `demo.gif` — the verdict, then the raw log, then the state those events produced. Three
+ commands, one question each, in the order they get asked.
+- ✅ `workbench.gif` — a full-screen dashboard narrowing live as a filter is typed, then one
+ word matching five kinds of artifact *at the same time*. A screenshot shows five rows; it
+ cannot show them arriving as the query resolves.
- ✅ `completions.gif` — a tab press that makes a network call. There is no way to convey that
the list came from the server rather than from the script, except by watching it happen.
-- ❌ **The failed-partition trail on its own.** Four attempts of the same stack trace is a wall
- of output. It is in the hero, where it lasts seven seconds and carries the narrative, and the
- README quotes it as text where it is searchable and loads instantly.
+- ✅ `triage.gif` — one failed partition in a healthy store, and the trail from the verdict to
+ the exception. Kept short and kept to one clip: four attempts of the same stack trace is a
+ wall of output, and the README quotes the interesting part as text where it is searchable.
- ❌ **The output formats.** The same command four ways is a table with byte counts, not a
recording.
-**Watch the total weight.** Three GIFs, 1.1 MB.
+**Watch the total weight.** Four GIFs, under 1 MB.
+
+## Nothing destructive is typed on camera
-## Recording something destructive
+An earlier cut of `completions.tape` used `cratis chronicle observers replay ` to demonstrate
+completion, because an observer id is a good thing to complete. That command reprocesses an
+observer from sequence zero. It never ran — the tape stopped short of `Enter` and ended on
+`Ctrl+C` — but it meant a single stray keystroke in a tape stood between a demo and a replay.
-`completions.tape` types `cratis chronicle observers replay ` on camera — a command that
-reprocesses an observer from sequence zero. It exists in the clip because an observer id is the
-best demonstration of completion hitting the server, and it is safe because **the tape never
-sends `Enter`**. It ends on `Ctrl+C`, which abandons the line.
+It now completes `cratis chronicle read-models instances ` instead, which is read-only, and the
+clip is better for it: the completion list has no `$system` entries in it, so there is no
+backslash-escaped noise on screen either.
-Audit before rendering:
+Audit before rendering, and again whenever a tape changes:
```bash
-awk '/^Show/{s=1} s' assets/completions.tape | grep -n "Enter"
+grep -n "replay\|retry\|remove\|delete" assets/*.tape
```
-That should return nothing but comment lines. Then verify the server afterwards, as above.
+That should return nothing. If a clip ever does need a destructive command, keep it off `Enter`
+and verify the server before and after — capture the tail sequence number, the failed-partition
+count and every observer's position, and diff them.
No README asset justifies a tape typo replaying somebody's observer.
diff --git a/assets/completions.gif b/assets/completions.gif
index ab029a8500197512e698517d789f1a3f4272fbe5..15244bb4ca49c706a14f0ab74b5a442530807143 100644
GIT binary patch
literal 89773
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