Building leonsheet — the engineering retrospective
Outwork #11 · Leonard Sibelius · May 19, 2026
leonsheet v1.0 running locally. Every visible cell came from the formula bar.
This is the technical retrospective on the leonsheet v1.0 build I described in Outwork #10. If you read that issue, you saw the positioning argument — the three-part entity, the new way I work. This issue is for engineers who want to look under the hood. How was a working spreadsheet — formula language, dependency graph, persistence, 245 tests — actually built in three hours with predictable correctness?
Source code at github.com/LeonardSibelius/leonsheet. Twelve commits, conventional-commits scoped, every commit with an Acceptance-Criterion or Smoke-Test trailer documenting which v1.0 spec line it satisfied. Read the commit log first if you want the chronological build narrative — it’s deliberately structured to be readable as documentation.
What follows is the workflow, the architecture decisions, the bug-prediction ledger that drove the test suite, and the parts I’d do differently next time.
How the three hours actually decomposed
“Three hours” is the headline. Here is the honest decomposition.
~30 minutes: the predict-bugs-then-pause preflight. Before the first line of code, Cowork and I sat down and listed the bugs we expected. The discipline: name the bug, name the layer it would live in, name the test that would prove the guard works. The ledger had seventeen named tests defined before the implementation existed.
~5 minutes: the spec. Cowork drafted leonsheet-backlog.md covering the v1.0 → v3.0 roadmap, the formula language grammar, the seven supported functions, the persistence schema, the ten acceptance criteria. Forty kilobytes of spec; Claude Code would build against it.
~2 hours: Claude Code implementation, in five checkpointed phases. Project scaffold and dependency wiring → SQLite schema definition → formula language (tokenizer, parser, evaluator, refs module) → sheet engine (graph, recalc, cells) → persistence (connection, store) → HTMX UI layer → acceptance run. Each phase ended with a checkpoint: Claude Code paused, I reviewed the architecture and bug-guard coverage, sent “yes” or “yes but change X.” No phase shipped without explicit approval.
~25 minutes: ship sequence. Bulk rebase to fix git author identity on all twelve commits. SSH push fail, switch to HTTPS, push succeed. Tag v1.0.0. Push the tag. Acceptance script run against the live server: 10/10 pass. Manual browser smoke at localhost:8000 to verify the JS focus-restoration that TestClient couldn’t cover.
That’s the three hours. Half of it was strategic work — preflight, spec, checkpoint review — which Cowork won’t do on its own without an experienced engineer at the console. The other half was Claude Code executing fast against decisions already made.
The seventeen-bug preflight
This is the artifact that explains why the test suite was 245 cases instead of 245 cases that didn’t catch anything important. Every entry below was predicted before implementation, named tests written before code, and proven absent by those tests passing.
#
Owner
Layer
Bug guarded against
Test that proves the guard
1
Walt
UI (htmx:afterSwap listener)
Focused cell loses selection during dependent-cell swap
Manual verification (JS focus needs a browser)
2
Walt
tokenizer
AA10 tokenized as A + A10 instead of one ref
test_greedy_cell_ref_AA10_is_one_token
3
Walt
evaluator (_flat_eval_args)
Range A1:A5 passed as Range object to SUM instead of expanded cell list
test_range_in_sum_expands_to_cell_values
4
Walt
values + per-fn policy
Empty cells counted in AVERAGE denominator or as numeric in COUNT
test_average_skips_empty_in_denominator, test_count_only_numerics
5
Walt
graph (min-heap on (row, col))
Topological sort non-deterministic on ties; intermittent test failures
test_topo_diamond_with_deterministic_tiebreak, test_topo_stable_under_set_iteration_order
6
Walt
DB (WAL via schema.sql)
SQLite write contention on rapid edits
test_wal_mode_set
7
Walt
UI (format_display 6-sig-fig)
0.1+0.2 displays as 0.30000000000000004
test_float_precision_rounded_for_display
8
Walt
graph (Kahn cycle detection)
Self-loop or 100-cell cycle not detected
test_topo_self_loop, test_topo_100_cell_cycle, test_large_cycle_does_not_crash
9
Claude
evaluator (fexpr-style IF)
Both IF branches eagerly evaluated; unchosen side throws
test_if_short_circuits_unchosen_branch, test_if_short_circuit_through_sheet
10
Claude
parser (factor rule)
--3 or unary minus inside expression misparses
test_double_unary_minus, test_unary_minus_in_expression
11
Claude
tokenizer + parser precedence
Multi-char operators (<=, <>) tokenize wrong; comparison precedence inverted
test_comparison_operator (parametrized)
12
Claude
Sheet + DB (DELETE before INSERT)
Replacing a formula leaves stale dep edges in memory or persisted rows
test_formula_replacement_drops_old_dependencies, test_formula_replacement_purges_old_dep_rows
13
Claude
Sheet (_extract_deps expands ranges)
Self-referencing range not flagged circular
test_self_referencing_range_marked_circular, test_persist_and_reload_self_referencing_range_circular
14
Claude
evaluator (_finite_or_error + ErrorValue)
Arithmetic overflow returns inf or NaN, breaks JSON serialization
test_evaluator_results_are_json_safe, test_arithmetic_overflow_returns_num_error
15
Claude
refs (bake-once + 1000-col round trip)
Column letter ↔ index conversion breaks at boundaries (A, Z, AA, AZ, BA, ZZ)
test_round_trip_first_1000_columns + 13 named corner cases
16
Claude
UI (hx-trigger=”change” commit-on-blur)
Stale response overwrites newer user edit
test_edited_cell_is_not_oob (per-cell version counter deferred to v1.1)
17
Claude
Architectural (row, col ints everywhere)
Mixed (row, col) tuple vs (col, row) tuple at layer boundaries
every layer passes a value through; structurally enforced
The point of this table is that the workflow is built around predicting them first. Tests written after the implementation are tests written to make existing code pass. Tests written before the implementation are tests written to prove the bugs you knew were coming. They catch different things.
The full suite, including the seventeen bug-guard tests.
The dependency graph (the engineering centerpiece)
I told Claude Code at commit time: “this is the engineering centerpiece, name it that way in the commit message.” The commit reads feat(sheet): dependency graph algorithms — the engineering centerpiece. The module is app/sheet/graph.py, 150 lines, and the entire correctness of leonsheet’s recalc engine lives in it.
Three data structures, two algorithms.
Forward adjacency: for each cell, the set of cells it depends on. When A3 = =A1+A2, A3 depends on A1 and A2.
Reverse adjacency: for each cell, the set of cells that depend on it. When A1 changes, the reverse adjacency tells us A3 needs to be recalculated.
Topological sort using Kahn’s algorithm with a min-heap tiebreak: when multiple cells could be recalculated in any order (a diamond pattern: A1 → B1, A1 → B2, B1 → C1, B2 → C1, so when A1 changes both B1 and B2 must recalculate before C1), the order is deterministic by (row, col). This determinism is what makes the test suite reproducible. Without it, the test suite occasionally fails for reasons that take hours to diagnose.
Cycle detection by topological-sort failure: if Kahn’s algorithm cannot drain the graph (some cells remain with non-zero in-degree at the end), those cells form a cycle. Every cell in the cycle is marked #CIRCULAR!. No recalculation is attempted on them. The error is contained; no crash, no infinite loop, no silent bad answer.
The recalc orchestration lives in Sheet._recalc_from in app/sheet/sheet.py. When a cell changes:
Tear down the cell’s old forward edges (DELETE in the persistence layer).
Parse the new formula. Extract its dependencies via the AST walk.
Add new forward edges (and update the reverse adjacency).
Compute the transitive dependents of the changed cell via reverse-adjacency walk.
Topologically sort that subgraph.
Mark any cycles #CIRCULAR!.
Evaluate the remaining cells in topological order, one pass.
In code:
def _recalc_from(self, seed: CellId) -> set[CellId]:
“”“Recompute the seed and every cell transitively depending on it.”“”
affected = transitive_dependents({seed}, self.depended_on_by) | {seed}
ordered, cycled = topological_sort(
affected, self.depends_on, self.depended_on_by
)
# Cycled cells: mark #CIRCULAR! and do NOT evaluate. Their depends_on
# edges remain in place so a future cycle-breaking edit can recover them.
for c in cycled:
self.computed[c] = ERR_CIRCULAR
# Non-cycled cells: evaluate in topo order. Each cell’s deps either
# live OUTSIDE `affected` (their stored computed value is current) or
# come earlier in `ordered` (just computed this pass).
for c in ordered:
self.computed[c] = self._evaluate_cell(c)
return affected
The composition transitive_dependents → topological_sort → evaluate is the entirety of the recalc strategy. Two helpers from graph.py, one local evaluator call. Twenty lines of glue. The algorithmic work — the heap-keyed Kahn’s traversal with cycle detection — is in graph.py:
def topological_sort(
nodes: set[CellId],
depends_on: dict[CellId, set[CellId]],
depended_on_by: dict[CellId, set[CellId]],
) -> tuple[list[CellId], set[CellId]]:
“”“Kahn’s algorithm restricted to the subgraph induced by `nodes`.”“”
# In-degree of each node within the induced subgraph.
in_degree: dict[CellId, int] = {}
for node in nodes:
in_degree[node] = sum(
1 for dep in depends_on.get(node, ()) if dep in nodes
)
# Heap of cells with no unresolved subgraph-internal dependencies.
# Tuples compare lexicographically; (row, col) gives deterministic order.
ready: list[CellId] = [n for n, d in in_degree.items() if d == 0]
heapq.heapify(ready)
ordered: list[CellId] = []
while ready:
cell = heapq.heappop(ready)
ordered.append(cell)
for dependent in depended_on_by.get(cell, ()):
if dependent in in_degree: # only cells in the subgraph
in_degree[dependent] -= 1
if in_degree[dependent] == 0:
heapq.heappush(ready, dependent)
cycled = nodes - set(ordered)
return ordered, cycled
The min-heap on (row, col) is the only part of this that isn’t textbook Kahn’s. Without it, the sort order is non-deterministic across runs — when multiple cells have in-degree zero simultaneously (the diamond-DAG case), Python’s set iteration order would pick differently each time. The heap gives reproducible ordering, which is what makes the acceptance smoke tests possible. That’s predicted-bug #5: catch it before the test suite starts flaking intermittently for reasons you don’t understand.
A 50-cell deep dependency chain — A0 depends on A1, A1 on A2, ..., A49 on A50 — recalculates in one millisecond against a 500ms budget. That’s not a benchmark designed to flatter the algorithm; that’s the acceptance criterion. Five hundred times headroom.
Edit one cell; the dependents update without a manual recalc trigger.
The formula language
Hand-written tokenizer (170 lines, 19 token kinds), recursive-descent parser (160 lines), and pure-function evaluator (310 lines with the seven supported functions and the coercion rules). No parser generator, no PEG grammar, no library. The discipline is in the recursive descent.
Grammar:
formula := “=” expression
expression := term ((”+” | “-”) term)*
term := factor ((”*” | “/”) factor)*
factor := number | cell_ref | range | function_call | “(” expression “)” | “-” factor
cell_ref := [A-Z]+ [0-9]+
range := cell_ref “:” cell_ref
function_call := IDENT “(” arg (”,” arg)* “)”
arg := expression | range
Standard math precedence. Unary minus handled at the factor rule (a Claude Code preflight prediction — without that, =-A1+1 would misparse). Parentheses for explicit grouping. Cell references greedy in the tokenizer: AA10 consumes two letters then digits, not A + A10 (a Walt preflight prediction — the classic mistake when implementing column-letter parsing).
Seven functions in the v1.0 dispatch table: SUM, AVERAGE, MIN, MAX, COUNT, IF, CONCAT. Each is a small function in evaluator.py. Adding VLOOKUP or COUNTIF in v2.1 will be ~15 lines per function plus a dispatch-table entry. The expression grammar doesn’t change.
One architectural call worth naming: functions receive their arguments as raw AST nodes, not pre-evaluated values. This is sometimes called the fexpr pattern (named after the Lisp macro form). It enables IF’s short-circuit semantics correctly — IF(A1>5, expensive_function(), other) does not evaluate expensive_function() when A1<=5, which is what every spreadsheet user expects. The naive implementation (evaluate both branches before calling IF) is wrong in two ways: it’s slow, and it triggers errors in the unchosen branch. Claude Code predicted this in the preflight and the design was right from commit one.
In code, IF is twelve lines including the docstring:
def fn_if(raw_args: tuple[ASTNode, ...], ev: Evaluator) -> Value:
“”“Three-arg conditional. Only the chosen branch is evaluated.
Predicted bug #9: =IF(A1<>0, B1/A1, 0) with A1=0 must NOT divide by zero.
Achieved by evaluating the chosen branch lazily, after the condition.
“”“
if len(raw_args) != 3:
return ERR_VALUE
cond = ev.evaluate(raw_args[0])
if isinstance(cond, ErrorValue):
return cond
return ev.evaluate(raw_args[1] if _to_bool(cond) else raw_args[2])
Note raw_args[1] and raw_args[2] are AST nodes — only one is ever passed to ev.evaluate(). The other never runs. That’s the entire point of the fexpr signature.
For comparison, the other functions in the v1.0 dispatch table eagerly evaluate their args through a single helper, _flat_eval_args, which also handles range expansion (predicted bug #3 — handled in exactly one place):
def _flat_eval_args(raw_args: tuple[ASTNode, ...], ev: Evaluator) -> list[Value]:
“”“Evaluate each arg; Range args expand to a flat list of cell values.”“”
out: list[Value] = []
for arg in raw_args:
if isinstance(arg, Range):
for r in range(arg.start_row, arg.end_row + 1):
for c in range(arg.start_col, arg.end_col + 1):
out.append(ev.lookup(r, c))
else:
out.append(ev.evaluate(arg))
return out
SUM(A1:A5) calls _flat_eval_args, which expands the range into five lookup calls, and SUM receives five Value instances. The function itself doesn’t know that ranges exist. The same helper serves AVERAGE, MIN, MAX, COUNT, and CONCAT — adding VLOOKUP in v2.1 means writing a new function that calls _flat_eval_args on its first arg and a single ev.evaluate on its second. Fifteen lines.
Sheet has no I/O
The single biggest architectural decision in v1.0. The Sheet class — the in-memory data structure that holds cells, formulas, dependency edges, and computed values — knows nothing about SQLite, HTTP, or HTMX. It exposes methods like set_cell(row, col, value) and get_dependents(row, col) and that is all.
The persistence layer wraps it from outside (in app/db/store.py). The HTTP layer wraps it from above (in app/main.py). The Sheet itself can be unit-tested without bringing up a database or an HTTP server. The bulk of the 245 tests do exactly that — they construct a Sheet, call set_cell, and assert on the result. No fixtures, no mocks, no setup teardown. That is why the full suite runs in 0.95 seconds.
The persistence layer’s job is also simpler because of this separation. Store.persist_cells is called after the Sheet has finished recalculating; it sees only the final state, doesn’t know about intermediate steps. Store.load is called on application startup; it bulk-loads every persisted cell into the Sheet’s internal dictionaries, then triggers one topological recalc to compute every formula cell. Not N recalculations (one per cell, an N-squared cost). One recalc, total, that respects the dep graph order. The 26×100 grid loads from disk in tens of milliseconds.
This separation is the kind of architectural call senior engineers make automatically because they have seen what happens when an entity tries to do everything. A junior would have written a Sheet class that calls SQLite directly inside set_cell. That works. It also makes the test suite take ten times as long, makes the persistence layer impossible to swap out, and makes the recalc engine impossible to reason about in isolation. The separation pays compound interest forever.
What worked
The predict-bugs-then-pause workflow scaled. Seventeen bugs predicted. All seventeen prevented before the first test ran red. The discipline does not slow the build down — it speeds it up, because every bug you would have hit during testing is a bug that doesn’t appear in your debug-cycle queue. There are no bugs in the v1.0 source code that I haven’t anticipated and addressed.
Per-checkpoint pauses kept Claude Code aligned. Five pause points. At each, Claude Code reported what it had built and what it intended to build next; I reviewed the architecture and gave a yes-with-changes or a yes-as-is. No commits to main without my explicit approval at the checkpoint that produced them. This is the discipline that prevents AI drift on long-running tasks. Without it, an autonomous agent can spend forty-five minutes building the wrong thing.
Conventional-commits scope plus acceptance-criteria trailers made the commit log readable as documentation. Anyone reviewing the repo can read git log --oneline and understand the build narrative in two minutes. Each commit message says what was added and which acceptance criterion it satisfied. Future-me reading this in six months will recover the build state instantly.
The architectural calls held under test load. Sheet-has-no-IO, per-cell HTMX OOB swap, pure-function evaluator with lookup callback, bulk-load-then-one-recalc on boot. Every one of these decisions made the test suite faster, the code easier to reason about, and the performance better than required. None had to be revisited mid-build.
What I’d do differently next time
Test architecture spec belongs in the preflight. Walt’s eight predicted bugs all had named test functions in the preflight ledger. Claude Code’s nine were articulated as “we’ll need a test for X” but without committed names. When Claude Code wrote the tests, the naming was good enough, but the next time I want them in the preflight ledger too so the names are committed before the implementation. This is the difference between anticipating test coverage and promising it.
Per-cell version counter for stale-response detection should be v1.0, not v1.1. I deferred this because v1.0 is single-user-by-assumption with a threading.Lock that closes the actual race window. But the workflow could have included it cheaply and removed the v1.1 deferral. Net cost would have been maybe 50 lines plus three tests; net benefit would have been one less limitation to explain in the README. Future builds: do not defer something that’s cheap if it eliminates a documented limitation.
The acceptance script could run as a CI workflow. Right now the live-HTTP acceptance script lives as documentation; running it against the deployed app is manual. Adding a .github/workflows/acceptance.yml that boots the app and runs the script against it on every push would catch regressions at PR time, not at manual smoke time. Half an hour of work; should have shipped with v1.0.
What’s next (v1.1 → v3.0)
v1.1’s framing has shifted since the build. Originally v1.1 was “multiple sheets + cell formatting.” After the discussion about whether to host leonsheet publicly, v1.1 became per-session multi-tenancy enabling a hosted public demo.
The architecture for v1.1: each browser session gets an in-memory Sheet instance keyed by a session cookie. Sheets evict after thirty minutes idle or on tab close. No persistence across sessions for the public demo (a per-session SQLite file under /tmp is the alternative if persistence-within-session matters). The session middleware is FastAPI-native; the per-session Sheet registry is a dictionary with TTL eviction. Probably another evening’s build. Then a hosted demo at leonsheet.leonardsibelius.com on a Fly.io or Render free-tier deployment.
v1.2 charts, v1.3 selection/copy/paste, v2.0 real-time collaboration via WebSocket, v2.1 power functions (VLOOKUP, COUNTIF, date and text functions), v3.0 pivot tables. The roadmap is in Linear under the SHEET team (SHEET-2 through SHEET-7). Each version is one to two evenings.
The point is not that I am going to build Excel. The point is that the dependency graph foundation supports any of these. Adding a function means adding a dispatch table entry plus a test; adding charts means embedding Chart.js in the OOB swap response when the user selects a chart cell; adding multi-tenancy means wrapping the Sheet registry. The foundation does not need to change.
The hire
Outwork #10 made the hire-me case in broader terms. This issue is the technical artifact for engineers who want to vet the code before making a decision. Read the repo. Read the commit log. Run the tests. Run the live server. Then talk to me.
I am at outpostintel.com/walt-parkman or leonardsibelius.com or directly at wpneural@gmail.com or wparkman@protonmail.com. Available immediately for senior contract work and would consider full-time engineering roles for the right team.
Next issue: depending on what ships first, either the v1.1 multi-tenancy retro and the live-demo announcement, or the next system the three-part entity decides to build. Outwork is published on Substack at wpoutwork.substack.com.




