Part 3 of 3 · The bugs the real browser teaches
What Safari taught us
Part 2 ended on a silence: a clicked link that produced no fact, and no error to explain it. That silence was a real bug hiding in the bridge between the two worlds — and chasing it down is the most instructive thing in the whole project. Then a second, quieter lesson about a fact Safari simply won’t let us build.
The clicked link left no trace. Page visits landed in the log; the click did not. Crucially there was no error — not in the page, not from the network, nowhere. When software fails loudly you have a thread to pull. When it fails silently, you first have to prove to yourself that something is even wrong, then find where a thing that should exist quietly stopped existing.
The first instinct — “the automated click didn’t really
click” — was worth ruling out, and a second clue did rule it out: a
hand-click, by a real person, also produced no
:link/clicked fact, even though the page did navigate
(so the click plainly happened). The click was real. The capture was not.
Something between the click and the log was eating the fact.
Working backwards along the chain from Part 1 — click → content script → message → background → build fact → queue → save — the question became: where could a fact vanish without complaint? And there is exactly one such place by design. The buffer that holds facts validates every fact before keeping it, and a fact that fails validation is simply dropped — on purpose, so malformed data never pollutes the record. Append-or-drop: a fact that fails the shared schema check never enters the buffer. buffer.cljs:32 · facts.cljc:7 So the fact was being built — but built wrong, and dropped as junk. The bug was upstream, in how the click became a fact. Which means it was in the bridge.
The bridge that mangles its cargo
Recall the two-worlds rule: the content script (inside the page) sees the click and messages the background (outside). That message is the bridge. And here is the thing about bridges between worlds — the cargo has to be repackaged to cross, and repackaging can quietly damage it.
Our code is ClojureScript, whose natural data includes namespaced
keys like :from/url and :to/url — the slash
separates a namespace (from) from a name (url).
They are different keys; the namespace is the whole point. To send them
across the bridge they must be converted to a plain JavaScript object,
with a built-in converter, clj->js.
And clj->js has a rule almost nobody remembers until it
bites: when it turns a key into a JavaScript string, it keeps only
the name and throws the namespace away.
It uses the equivalent of (name k) for each key. (name :from/url) is "url"; the from is gone. So is the to.
So :from/url becomes "url" — and so does
:to/url. Two distinct keys collapse onto the same
string and collide; one silently overwrites the other.
The background unpacks the message and finds a single :url
where it expected a :from/url and a :to/url. Both
come back empty. It dutifully builds a :link/clicked fact with
no from and no to — which is exactly the kind of
malformed fact the buffer is built to drop.
This one isn’t Safari’s fault; it would bite in any browser. But it could only be discovered by running the real content→background message in a real browser — because the bug lives precisely in the step a unit test is most tempted to skip. The real environment didn’t introduce the bug; it revealed one that had been there all along, invisible.
See it happen
Toggle between what we sent at first and what we send now; watch the keys cross the bridge.
Below is the exact transformation, computed live. The content script sends
a map; clj->js turns each key into a string by dropping its
namespace; the background reads it back. Watch which keys survive the
crossing and which collide into one.
The fix, once you can see it, is almost insultingly small: send keys with
no namespace — plain :from and :to —
which survive clj->js intact, and have the background map
them back to the proper :from/url / :to/url on
arrival. The fact shape never changes; only the few keys that ride across
the bridge do.
The receiving side now reads :from/:to and re-attaches the namespace. events.cljs:38
The fix, and the test that should have caught it
With the cause understood, the fix was confirmed at a REPL in seconds,
applied, and then re-verified the same way we found it was broken:
reload the extension in Safari, hand-click a link on a fresh nonce page,
and look in the log. This time the :link/clicked fact was
there — correct from and to, an empty modifier
set, saved end to end.
/step/f566bc3d…/article produced :page/visited (article) → :link/clicked → :page/visited (dest) in order, the click fact carrying both URLs and :modifiers #{}. Verified live in Safari. m0-s1-verdict.md
But the sharpest lesson is why the bug existed at all. There
was a test for the fact-builder — and it passed. It passed because
it handed the builder a clean, hand-written map with the keys already
correct. It tested the builder’s logic in isolation and never sent
anything across the real bridge. The bug lived in the one step the test
stepped over.
The old test (events_test.cljs:34) fed a hand-built map; the new one (events_test.cljs:45) round-trips through the real clj->js/js->clj and asserts the fact actually survives the buffer.
When two components talk across a boundary that transforms the data — serialization, a network hop, a language bridge — test the data through the real transform, not by hand-feeding the receiver what you assume arrives. A test that skips the boundary gives confidence exactly where you have none. The new regression test pushes a message through the genuine conversion and checks the fact is still whole.
The fact Safari won’t let us build
The third fact type from Part 1 was :tab/opened, and its value
is almost entirely in one field: the opener — which tab or page
spawned the new one. “You opened this tab from that article” is
reading provenance; “a tab appeared” is barely worth recording,
since the new tab’s own page visit already says as much.
To test it honestly we used a compile-time switch: the
:tab/opened feature ships off by default, but can be
built on without editing any code, so the spike runs the exact
configuration that would ship.
A goog-define flag, default false. The gated-on build literally retains the tab-opened code path (we confirmed it in the compiled output) so the test is faithful. background.cljs:31
We built it on, opened a new tab from a real link, and read the result.
:tab/opened fact with a valid tab id. But across four separate tab-creations — including a genuine link-opened child — Safari never supplied the opener. The provenance field came back empty every time. m0-s2-verdict.md
So the feature works and is useless at the same time: the
one field that justified it is unavailable from Safari’s event. The
honest call was to leave the switch off — ship the two fact types
that carry real provenance (:page/visited and
:link/clicked, both proven), and defer :tab/opened
until a later milestone can recover the opener some other way (correlating
the active tab at creation time, say). The compile-time switch means that
day is a one-line change, not a rewrite.
| The tab-opened event | Chrome (the norm) | Safari 26.5 |
|---|---|---|
| Fires on a new tab | yes | yes |
| Gives a valid tab id | yes | yes |
| Includes the opener | yes | no — never |
| Useful for provenance | yes | not as-is |
What the real browser teaches
Step back and the three discoveries rhyme. The localhost grant, the silent dropped click, the opener-less tab event — none could be found by reading a specification or running an off-browser test. Each surfaced only when the code met the real machine. That is the whole argument for proving interop in the actual target, not in a convenient fake:
- △ The spec is not the browser.
- Safari treats a declared permission as a request, not a grant — a difference invisible until a real flush is silently blocked.
- △ Test through the boundary, not around it.
- The clicked-link fact was built wrong because the bridge mangled its keys; only a test that crosses the real bridge could have caught it.
- △ A feature can fire and still be empty.
- Safari’s tab event works yet omits the one field that gave it meaning — “it runs” is not “it works.”
What shipped, then, is the part that is real: an extension that durably captures the pages you visit and the links you follow, flushes them to a local record, and survives the browser dozing its worker off — every link in that chain verified inside Safari, not assumed. Two of three provenance facts ship and are Safari-proven; the third waits for an opener it can trust. The reading trail from Part 1 is, finally, being kept. The trail that used to vanish now lands, one honest fact at a time. And the bugs that tried to stay silent are written down — so the next person who builds across these boundaries already knows where they hide.