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.

Safari peculiarity? No — a cross-boundary trap

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.

Link capture restored.
Post-fix, a real click on /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.

The lesson worth keeping

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.

The event fires — but blind.
Safari does fire the “tab created” event, and our extension does emit a :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 eventChrome (the norm)Safari 26.5
Fires on a new tabyesyes
Gives a valid tab idyesyes
Includes the openeryesno — never
Useful for provenanceyesnot 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.

Part 3 of 3 · The bug fix and verdicts are real commits and records in the organon repo, June 2026. Build: Safari 26.5.

Citation colour marks the source — extension the extension’s own code, instrument/ the test rig, verdict a spike result.

content.cljs:42 events.cljs:38 events_test.cljs:45 buffer.cljs:32 background.cljs:31 m0-s2-verdict.md