Lesson 19 — When something breaks, and when it just repeats: the problem filters
Goal: know where to look when a run fails, when an edit doesn't type-check, and when a composition already exists somewhere else — and know why those are three different kinds of problem.
Concepts: the Explorer's ✕ failed, ⚠ type errors and ⚐ lint filters (with counts on the chip, the namespace and the fn), the Inspector's Runs / Bindings / Lint details, runtime failure vs static diagnostic vs lint finding, error-tolerant writes, marking a finding as not an issue.
Prefer to be shown? This lesson exists as a guided in-editor tour: open the demo with the tour running (no sign-up), or pick “Interactive tutorial” in the editor's account menu.
Three kinds of wrong
Graphden separates them on purpose, and each has its own filter chip:
| ✕ failed | ⚠ type errors | ⚐ lint | |
|---|---|---|---|
| What it marks | fns with runs that FAILED and are still unresolved | fns with edits that don't type-check | fn-defs that duplicate one another, private fn-defs nothing uses |
| When a mark appears | at run time | at write time | whenever the graph changes |
| Scope | the branch you are on (plus its ancestors), last 7 days | the branch you are on, right now | the branch you are on, right now |
| Cleared by | fixing the fn, a clean re-run, or ✕ (dismiss) | fixing the binding | removing the copy, or Not an issue |
| Detail in the Inspector | Runs — message and ✕ | Bindings — the message under the argument | Lint — the finding, Not an issue / Restore |
A type error is not a failed run: the write succeeded, the fn is in the graph, and the diagnostic rides alongside it (lesson 08 — "a diagnostic, not a wall"). A failed run means execution was attempted and threw. It may already have produced side effects; failure does not promise a rollback of everything the graph called.
A lint finding is neither: nothing failed and nothing is mistyped — the graph simply already contains what you just built, or holds a private helper that no longer earns its place. It is advice, so it is the one list where you get to disagree.
All three are filter chips in the Explorer (Lesson 20): click a chip and the tree narrows to the fns involved, each namespace row counting what is inside it and each fn row carrying its own mark — so the tree answers where, and the Inspector, on the selected fn, answers what.
✕ failed — what failed and is still your problem
The ✕ failed chip (Space x; Space y and Space w are the other
two) focuses the tree on fns with recent failed runs
that are still unresolved on the branch you are on; the chip
counts the runs, a namespace row shows ✕ 3, the fn row ✕1. Select
the fn and the Inspector's Runs tab opens with its unresolved
failures at the top:
Unresolved failures
✕ Dismiss 2026-08-22 09:49:35 Malformed JSON. ← dismiss · when · the message
7d: 4 runs · 1 failed · avg 12 ms ← the usual history follows
It is a worklist, not a permanent scar — a failure leaves it as soon as any of these happens:
- You fix the fn. A failure is pinned to the exact version that ran. Save a new version (or, on a branch, override the fn) and the old failure is not your code any more — the row disappears on its own.
- A clean re-run. If the same version later runs to success (the failure was transient — a network blip, a bad input), the failure clears without an edit.
- You dismiss it. ✕ Dismiss on the row in the Runs tab (or ✕ Dismiss all, the button that appears under the chips while the ✕ failed chip is on) acknowledges the failure and hides it everywhere; the audit row itself stays for its retention window.
Failures follow branches the way code does: a branch sees its ancestors' failures (it resolves the same broken version they ran), but sibling branches never see each other's, and a parent never sees a run that happened only on a child.
Three more things worth knowing about that list:
It only counts runs you asked to keep. A transient run — the plain ▶ with "Save to history" unticked (lesson 15) — leaves no audit row, so a failure you did not persist never reaches the ✕ failed filter. Effectful runs persist by themselves; pure ones are yours to keep.
The mark is where you work. The ✕1 sits on the fn's row and on
its card title on the canvas; selecting it puts the failure's message
and the bindings that produced it side by side.
The message is already safe to read. Error text and data are redacted and scrubbed when the audit row is written, not when it is displayed — so a secret that flowed into an exception (lesson 16) is not sitting in the Inspector waiting to be shown to a teammate.
⚠ type errors — what the checker recorded
The ⚠ type errors chip focuses on fns with diagnostics recorded for
the branch you are on; the chip counts the diagnostics, the fn row
carries ⚠2, the card its ⚠ badge. Select the fn: in the Inspector's
Bindings tab the message sits right under the argument it objects
to:
port = "oops" :int… ← the binding
Type-check failed in fn-def :tprobe
arg :port ← (literal "oops")
parent :web.http/http-server expects: [:refine :int [:and [:>= 1] [:<= 65535]]]
actual: :text
hint: the literal value classifies as :text
That is the same text lesson 08 showed you on a single card's badge. The ⚠ type errors filter is the branch-wide view of it: every mismatch that survived a write, marked where it lives, including the ones on fns you are not currently looking at.
Why the writes were allowed at all is lesson 08's point, worth restating here: an editor that refuses a half-finished edit forces you to construct changes in an order the type-checker approves of. Graphden takes the diagnostic instead — so this filter is a worklist, and it is expected to be non-empty while you are mid-change.
Marks leave when the mismatch is fixed. Nothing to acknowledge, no state to clear: the next write re-checks the fn and the diagnostic either re-records or does not.
⚐ lint — what the graph already has
The ⚐ lint chip is the graph's own reviewer. It reads the branch
the way the package loader reads fns.edn — every fn-def as parents
plus bindings, with names and descriptions stripped — and marks a few
things; select a marked fn and the Inspector's Lint section shows
the finding with the other fns involved as links:
- duplicate-definition — two or more named fn-defs with the same parent and the same bindings. The same definition, written twice.
- duplicate-after-expansion — the same graph once the private
_-helpers are inlined: you split it into different helpers, or spread it over two namespaces, but it computes the same thing. - unreferenced-private — a
_-private fn-def that nothing references any more. - unreachable-private — a
_-private fn-def that only dead code still references: the rest of the cluster behind an unreferenced one, so deleting the head does not reveal a new mark each round. - shadowed-override — a fn re-binds an argument to exactly what it already inherits. The binding changes nothing and hides where the value really comes from; drop it.
- fan-in-extract-parent — two or more fns inherit the same parent and bind the same values, differing elsewhere. Lesson 03's move: extract a parent that binds the shared part and inherit it.
- deep-hierarchy — a fn eight or more levels of inheritance above its base-fn; each level should name a concept, or be flattened.
Small coincidences are not shown. Two accessors that both read
:id off a row are the normal way to give each code path its own
child (lesson 05); a duplicate is a finding only when the shared
structure carries at least three bound values — a copied graph, not
a habit. Below that line the lint says nothing at all: a mark you
should not act on would be a wrong recommendation.
Lint
duplicate-definition tutorial-row-attrs tutorial-page-attrs ← rule · the fns, as links
2 fn-defs are the same definition (3 bound values)
[ Not an issue ] ← your say
The fix is lesson 03's move: keep one definition and let the other
place inherit or reference it. But sometimes the copy is deliberate —
two branches of an if that must stay separate entities, a
transitional state mid-refactor. Click Not an issue and the mark
goes away, and stays away until the group changes (a third copy
appears, say) or you Restore it — the section keeps the hidden
entries that name the fn.
That decision is not kept in some setting: it is written into the
graph, as a root fn named lint-suppressions — a :const whose
value is the list of findings you dismissed. It is versioned with the
branch, merges with it, and you can open it on the canvas and edit or
delete it like any other fn. Nothing in graphden's diagnostics is
stored state except what you explicitly said.
The filter next door
Tests are the fourth problem-style filter — ✓ tests (lesson 17) — and the trace tree with the «catch next request» trap (lesson 18) live on the Runs tab. Nothing sits under the canvas: every diagnostic is a filter chip in the Explorer and a section in the Inspector.
The ✕ failed filter answers what broke and where, the trace tree why, ⚐ where this already is. In that order, most of the time.
Try it
-
Build something that will fail at run time.
:parse-jsonis the shortest honest example:{:name :tutorial-bad-json :parent :parse-json} -
Run it (⋯ → ▶) with
string=not json at all, and tick “Save to history” before pressing Run. The result pane showsMalformed JSON. -
Click the ✕ failed chip under the Explorer's filter. The tree narrows to
tutorial-bad-json,✕1on its row and on its card, the chip reads1, and ✕ Dismiss all appears next to the chips (the Recent trail steps aside while any filter is on). Select the fn and open the Inspector's Runs tab: the failure is at the top withMalformed JSON.and its own ✕ Dismiss. -
Watch the mark resolve itself: run
tutorial-bad-jsonagain, this time withstring={}(valid JSON), "Save to history" ticked. The chip reads nothing and the Runs tab's failures block is gone: the same version ran to success, so there is nothing left to fix. (The ✕ on a failure does the same by hand, for failures that no re-run will outrun.) -
Now make a static mistake. Extend
:http-serverand bind itsportto the literal"oops"(a string). The write SUCCEEDS, with a warning, and the card grows a red type-error badge. -
Click ⚠ type errors. The tree narrows to your fn,
⚠1on its row; in the Inspector's Bindings tab the message sits underport, with the expected refinement and the actual:text. -
Fix the binding — bind
8080instead. The mark is gone; you did not have to dismiss it. -
Now build the same thing twice: find
assoc,⋯→ Extend →tutorial-page-attrs, bindmapto{:class "page"},keytotitleandvalueto the fn-reftutorial-bad-json; then extendassocagain astutorial-row-attrswith the same three bindings. As fn-defs, the two read:{:name :tutorial-page-attrs :parent :assoc :args {:map {:class "page"} :key "title" :value :tutorial-bad-json}}{:name :tutorial-row-attrs :parent :assoc :args {:map {:class "page"} :key "title" :value :tutorial-bad-json}}Click ⚐ lint. Both fns,
⚐1each, the chip reading1— one finding, two members. Select either: the Inspector's Lint section showsduplicate-definition, both names as links, "2 fn-defs are the same definition (3 bound values)". -
Decide. Delete
tutorial-row-attrsand the mark leaves on its own — or click Not an issue and watch the section swap to its hidden entry, while a new fnlint-suppressionsappears at the root of the Explorer. Open it: the value is your decision, spelled out. Restore puts the finding back.
Report a problem
When what broke is the editor itself, the account menu has a
Report a problem row. It opens a small form — category (Bug /
Idea / Question), a description, an optional email for follow-up —
plus a checklist of what travels with it, each item yours to untick:
build and environment (the frontend and server version hashes, the
browser), the recent console errors, and off by default your current
location (instance URL, branch, open fn) and, when signed in, which
fns failed recently and how often (names and counts, no error text). Nothing is sent until you press Send.
The report goes to the intake URL this instance announces — the
official graphden intake unless the operator pointed it elsewhere or
switched the row off — and lands as a row in that instance's
feedback_reports table, from where the built-in alerter pings the
operator's channel (MONITORING.md § 3b).
Organization → Monitoring
How often things break is not a filter: it is a report. The account
menu's Organization surface has a Monitoring section — your
org's last seven days as headline totals, a per-day trend table and
the busiest fns, every number a usage rollup (run counts and
durations only, never args, results or error bodies; kept for 90
days). It is the same source the 7d: N runs · M failed · avg K ms
strip in the Runs tab reads per fn, and the one lesson 37's plan
ceilings are measured against (MONITORING.md § 1).
Where this shows up next
- Tests (lesson 17) — a failing test is a failed run like any other, so the ✕ failed filter marks it too, with its assertion message in the Runs tab.
- Debugging (lesson 18) — from a failed row, the next question is usually "which node threw?", which is what the trace tree answers.
- Plans (lesson 37) — the same audit rows feed the usage counters; a plan's retention is what decides how far back the ✕ failed filter can look.
- Packages (lesson 31) — the same lint runs over graphden's own
package corpus in CI (
bb graph-lint); the rules and their calibration are in GRAPH_LINT.md.