Skip to content

Research Status

rajatrv-fullstack edited this page Sep 8, 2026 · 1 revision

Research Status

This page tracks every claim in RESEARCH.md, what evidence stands behind it, and which open issue would settle the ones that are still unproven.

RESEARCH.md is the reason most of the project's rules exist, and it grades itself honestly: every claim in its verification table is labelled verified, source-derived (read from Chromium source and adopted as a constraint) or untested. This page repeats those labels exactly, adds the reasoning behind each grade, and maps each finding to the roadmap board. If you want to move a claim from one grade to another, Contributing and Roadmap explains how to run and report a reproduction.

The environment behind every result

Unless a section says otherwise, everything was established on one machine over three days:

OS macOS 26 (Darwin 25.5.0)
Browser Brave 152.1.94.121 (Chromium 152)
Fixture a copied profile with roughly 120 bookmarks and 16 folders on the bar, version: 1, Brave Sync off
Raindrop free plan, REST v1
Dates 2026-09-05 through 2026-09-07

Several of these behaviours are Chromium implementation details with no compatibility promise. A major browser upgrade can change any of them, which is why the table below records the version a claim was checked on rather than treating it as permanent.

Summary

# Finding Grade Board column Issue
1 Checksum hashes titles as UTF-16LE Verified (1a source-consistent, 1b from source only) Verified none; #6 asks whether it reproduces elsewhere
2 Native-messaging manifests are read from Chrome's directory Verified Verified #5 for the untested sub-case
3 A packed CRX permanently poisons the extension id Verified Verified none
4 Bookmarks file is read once at startup, re-serialised from memory Verified Verified none
5 Bookmarks.bak is not a safety net From source, adopted as a constraint Needs reproduction #1
6 A malformed file silently produces a fresh profile From source, designed against Needs reproduction #2
7 lastUpdate bumps on your own writes Design constraint (7a verified, 7b and 7c untested) Needs reproduction #3, #4
8 MV3 lifecycle races create duplicates Verified Verified none; #8 asks about Firefox
9 chrome.bookmarks has no custom metadata field Verified (9a verified, 7 of 7) no card none
10 Safari is not possible Verified across seven avenues Verified #10 is the planned escape hatch
11 Smaller findings not graded in the table no card #9 touches the alarm floor

The "Grade" column is a shorthand. Each section below quotes the verification table's wording exactly, because the precise phrasing is the claim.


1. Chromium's bookmark checksum hashes titles as UTF-16LE

Claim. The checksum field in Chromium's Bookmarks file is an MD5 over a pre-order walk of the node tree, with no separators or field names, in which the node title is hashed as UTF-16LE (with unpaired surrogates passed through) while the id, the type literal and the url are hashed as UTF-8.

Status. Verified both directions - our implementation reproduces Brave's digest, and Brave accepts ours.

How it was verified. Two independent experiments, which is what makes the result trustworthy rather than merely consistent:

  1. Read direction. The digest was computed over a file that Brave itself had just written and compared with the checksum stored in that same file. Exact match, including non-ASCII titles.
  2. Write direction. A file was written with an independently computed checksum, Brave was allowed to load it and then rewrite it from memory, and the digest was recomputed. Match again, and no node was renumbered.

A torture set (emoji, an astral-plane character, CJK and Devanagari) was round-tripped through both the project's writer and Brave's own, which confirmed the encoding rule rather than just failing to contradict it. The implementation that passed is the checksum() function in src/apply_brave.py, and the same function is used as a preflight tripwire: a stored checksum that does not recompute means something else is editing the file, and the writer refuses to proceed (see Safety and Ownership Model).

Sub-claims.

  • 1a. Brave 152 does not read the checksum. Status: From the Chromium CL that removed the read side; consistent with observed behaviour. This is why a wrong checksum is currently harmless on M152, and also why the project writes it correctly anyway: the read side was removed in a way the commit describes as easy to revert, and other tools read the file too.
  • 1b. Pre-M152, a mismatch triggers required_recovery and renumbers every node. Status: From source only - cannot be triggered on M152, which does not read the checksum. It cannot be reproduced on the build the project was tested on. It is the reason the design anchors identity on GUIDs, which survive recovery, and treats id as a per-file allocation detail; see Ledger and File Formats.

On the board. Verified column, card "Finding 1: bookmark checksum hashes titles as UTF-16LE". No open issue. Issue #6 Test on other Chromium browsers: Chrome, Edge, Vivaldi, Arc asks whether the digest reproduces on other Chromium browsers, and names this finding as one of the three most likely to differ.


2. Brave reads native-messaging manifests from Chrome's directory

Claim. On macOS, Brave does not override Chromium's stock product path for native-messaging host lookup, so it searches ~/Library/Application Support/Google/Chrome/NativeMessagingHosts/ and silently ignores a manifest placed only in its own BraveSoftware/Brave-Browser/NativeMessagingHosts/ directory.

Status. Verified - single-variable experiment, confirmed by the launch_context.cc log line.

How it was verified. The extension console is effectively unreachable for an MV3 service worker in a launched-with-flags profile, so the diagnosis had to come from Brave's own stderr logging (--enable-logging=stderr --v=0). With the manifest in Brave's directory only, Brave logged launch_context.cc:148 Can't find manifest for native messaging host <host name> and connectNative() failed with a generic "host not found". Copying the identical manifest into Chrome's directory and relaunching with the same flags made the line disappear and the host process spawn. Nothing but the manifest's location changed, which is what makes it a clean single-variable experiment. It took four rounds of debugging a host script that was fine the whole time to get there.

The consequence is baked into scripts/bootstrap.sh, which installs the manifest into both directories: Chrome's because it works today, Brave's because it costs nothing and covers a future Brave that honours its own path.

What remains untested. The experiment ran on a machine where Chrome's directory already existed because other vendors' installers had created it. Whether Brave searches a directory that did not exist when it started is unknown; bootstrap.sh creates the directory, but nobody has yet confirmed the host is reachable on a Mac that never had Chrome or a Chrome-targeting installer.

On the board. Verified column, card "Finding 2: Brave reads native-messaging manifests from Chrome's directory". The untested sub-case is #5 Native-messaging manifest lookup when Chrome's directory does not already exist in Needs reproduction. #6 asks which directory each other Chromium browser actually reads.


3. Packing a CRX permanently poisons the extension id

Claim. Registering a locally signed CRX makes Brave install the extension and then disable it with disable_reasons: [256] (DISABLE_NOT_VERIFIED), and because the flag is keyed on the extension id rather than the installation, loading the same folder unpacked afterwards inherits it; the only escape is a brand-new id.

Status. Verified - observed in Preferences and in the greyed-out toggle; escaped only by minting a new id.

How it was verified. The profile's Preferences file was read directly rather than trusting the UI. The observed sequence was location: 2, disable_reasons: [256] after the CRX registration (location 2 is EXTERNAL_PREF), then location: 4, disable_reasons: [256] after the registration was removed and the identical directory was loaded unpacked (location 4 is UNPACKED). The location changed; the disable reason did not, and the enable toggle at brave://extensions stayed greyed out throughout. A newly keyed id, loaded unpacked, came up enabled and stayed enabled. The first identity minted for this project was burned exactly this way and had to be abandoned.

The mechanism is Chromium's InstallVerifier, which enforces ids against a signed allowlist in Preferences under extensions.install_signature.ids. A locally built CRX is never on that list, so every re-enable is reverted. Unpacked extensions are exempt from the verifier, which is why Load unpacked is the supported route and the only one that works. This is also why scripts/bootstrap.sh pins a signing key into the manifest: the id is a function of key.pem, so a forced id change is a matter of regenerating the key and updating allowed_origins in every host manifest copy rather than a mystery. Extension ids are per-install; the shape is 32 lowercase letters from a to p, and yours will not match anyone else's.

On the board. Verified column, card "Finding 3: a packed CRX permanently poisons the extension id". No open issue. Named in #6 as one of the findings most likely to differ on other browsers.


4. Brave reads the Bookmarks file exactly once, at startup

Claim. Brave reads Bookmarks once during profile load into an in-memory model that is thereafter authoritative; there is no file watcher, and every later write to the file is Brave serialising its whole in-memory tree over the top, roughly 2.5 seconds after any bookmark change and again at quit.

Status. Verified for load, survival and Brave's own rewrite (probe extension). The exact debounce constant is a Chromium internal - do not depend on the number.

How it was verified. The throwaway-profile method, in two passes:

  1. File survives load. Nodes were injected into a copied profile's Bookmarks, Brave was launched with --user-data-dir on the copy, quit from the menu, and the file diffed. It was byte-identical after Brave ran: 0 GUIDs vanished, 0 ids changed, 0 names changed, and required_recovery never fired. That proves Brave accepted an externally authored file, but not that the nodes reached the model rather than being passed through.
  2. Nodes are in the model and survive Brave's own rewrite. The run was repeated with a small unpacked probe extension holding the bookmarks permission, which called chrome.bookmarks.search({}) and chrome.bookmarks.create(). The create() forces a full serialise from memory. The probe logged total=141 ours=6, meaning the six injected URL bookmarks were in the in-memory model (the seventh injected node is their folder, which search({}) does not return), and all seven survived the rewrite Brave performed itself.

The second pass is the one that matters. The 2.5 second debounce is quoted from Chromium internals rather than measured as a guarantee, which is why the applier's design in Architecture does not time anything against it: src/apply_brave.py writes regardless of Brave's state and re-verifies on the next tick, converging whenever Brave has a quiet moment and always once Brave restarts and reads the file.

On the board. Verified column, card "Finding 4: Brave reads Bookmarks once at startup and re-serialises from memory". No open issue.


5. Bookmarks.bak is not a safety net

Claim. Bookmarks.bak is a copy of whatever was on disk at the first save of the current browser session, taken once per session behind a latch, never validated, and never read back by anything in the product; so one session with a single bookmark change replaces the last good copy with your own most recent output.

Status. From source, adopted as a constraint - not deliberately reproduced.

Why it is not yet verified. Reproducing it means deliberately destroying a backup, which is precisely the experiment nobody wants to run on a profile they care about. RESEARCH.md is explicit about the provenance: the once-per-session backup latch (backup_triggered_) and the absence of any read of the .bak path are read from Chromium's bookmark storage source. The project responded by making its own timestamped backups of both Bookmarks and Bookmarks.bak before every write, keeping the last ten, and re-opening each backup to confirm it parses before proceeding. See Operations for where those backups live.

How to settle it. On a throwaway profile: note the mtime and hash of Bookmarks.bak, launch Brave against the copy, add one bookmark, quit, and compare. The expectation is that .bak now matches the pre-change Bookmarks file rather than the older .bak.

On the board. Needs reproduction column, as #1 Reproduce finding 5: Bookmarks.bak is overwritten on the first save of the next session. The issue asks for the Brave version, the macOS version and the two hashes. A negative result updates the verification table too.


6. A malformed Bookmarks file silently produces a fresh profile

Claim. On a parse failure, on version != 1, or on any of the three permanent roots (bookmark_bar, other, synced) being missing, Chromium's loader records a UMA metric and nothing else, constructs three empty permanent nodes, and runs like a fresh profile with no dialog, no error page and no restore path; the first save of that session then copies the broken file over Bookmarks.bak.

Status. From source, designed against - deliberately not reproduced on a populated profile.

Why it is not yet verified. This is the worst outcome in the whole system and it has no error surface at all, which is exactly why it was designed against rather than exercised. The claim is derived from model_loader.cc. The project's answer is the guard list in src/apply_brave.py: preflight aborts on a missing file, a wrong version, a missing root, a checksum mismatch, account or encrypted bookmark stores, or top-level sync_metadata; the write is atomic; and postflight re-reads the file from disk to confirm it parses, the checksum recomputes, and ids and GUIDs are unique, restoring the backup on any failure. Because the failure is silent, the writer's own verification is the only error surface that exists. Safety and Ownership Model walks through each guard.

How to settle it. Throwaway profile only, never a real one: truncate the last few dozen bytes of Bookmarks, launch with --user-data-dir on the copy and --enable-logging=stderr --v=0, record what the bookmarks bar shows, grep the log for model_loader, then add one bookmark, quit, and inspect Bookmarks.bak.

On the board. Needs reproduction column, as #2 Reproduce finding 6: a malformed Bookmarks file silently produces a fresh profile. The issue calls it the single most important claim in the document, because it is the failure mode every guard exists for.


7. Raindrop bumps lastUpdate on every write, including yours

Claim. Raindrop advances an item's lastUpdate on every write, including writes made by your own sync, so a timestamp watermark re-selects everything the previous run touched and never settles; and no server-side search operator rescues it, because the documented operator table supports < and > on created: but not on lastUpdate:, and date operators are day-granular at best.

Status. Established as a design constraint; the content-hash ledger has run cleanly against it.

How it was verified. Not by a controlled experiment on lastUpdate itself. The claim was adopted at design time and the alternative, a ledger keyed on the source id that stores sha256(link|title|excerpt|note|type) over the raw source values and reclassifies only when an id is new, its hash changed, or taxonomy_version moved, has run cleanly in its place. The ordering rule matters too: the hash is written only after the classification result is committed downstream, so a crash costs one redundant reclassification rather than a silent, unrecoverable skip. Taxonomy and Classification and Ledger and File Formats cover the ledger; the rule as the classifier sees it is in skill/raindrop-sync/SKILL.md.

Sub-claims.

  • 7a. Hashing curated values re-flags items forever. Status: Verified the hard way - 168 items (~28% of the library) re-flagged on the following run; fixed, then re-validated against 139 known-good rows with 0 mismatches, and a full sweep showing 0 drift and 0 orphans in either direction. The first run hashed cleaned-up titles and links (entities decoded, tracking parameters stripped), so the stored hash could never be reproduced from the source. The ledger now keeps two columns that intentionally disagree: content_hash over verbatim source values for delta detection only, and curated title and link for display. Recomputing one from the other reintroduces the bug.
  • 7b. Whether a tag-only edit advances changedBookmarksDate. Status: UNTESTED - this project does a full read until it is confirmed. The design would gate cheaply on GET /rest/v1/user/stats and exit when meta.changedBookmarksDate is unchanged, with a max-staleness override forcing a full read every seven days. Nothing documents which mutations advance that timestamp. If a tag-only edit does not, the gate would report nothing changed while edits were pending, and the sync would silently do nothing for up to seven days, until the max-staleness override forced the next full read. That is why Phase A currently reads the whole library on every run.
  • 7c. /raindrop/{id}/suggest is Pro-gated. Status: Documented as plan-gated; the confirming request (expect 402/403 on a free plan) is UNTESTED here. Its sibling, semantic search, already hard-errors on a free account, which is consistent but not the same test.

Two related behaviours that RESEARCH.md records without grading: the tags field appends rather than replaces, so tag writes must be read-modify-write; and writes with collectionId 0 are rejected.

On the board. Needs reproduction column holds both untested sub-claims: #3 Verify that Raindrop's changedBookmarksDate advances on a tag-only edit and #4 Confirm that /raindrop/{id}/suggest is Pro-only. Both need a Raindrop token; neither report should contain it. The planned #11 Widen the content hash to include excerpt, note and type, as a versioned migration follows from the corollary that widening the hash invalidates every stored hash at once and must therefore ship as an explicit, versioned migration, never a silent upgrade.


8. MV3 lifecycle races create duplicate bookmarks

Claim. In an MV3 service worker, top-level load and chrome.runtime.onInstalled can fire in the same millisecond, so two copies of an existence check can both see a bookmark absent and both call chrome.bookmarks.create(), producing duplicates; chrome.bookmarks offers no uniqueness constraint, upsert or transaction to prevent it.

Status. Verified - real duplicates observed, then eliminated by the promise chain.

How it was verified. Observed directly: after installing and loading the extension, the bookmarks bar contained two identical entries for a single staged item, and the native host's log showed the work performed twice. After the fix, the same sequence logged pending: 1 of 1, then already_present, then 0 of 1, with one bookmark, no duplicate and the checksum intact.

The fix is the serialize() function at module scope in extension/sw.js: a single promise chain that every entry point (load, onInstalled, onStartup, onAlarm) funnels through, built with chain.then(fn, fn) so the next task runs even if the previous one rejected, and a separate .catch so a failed sync never wedges the chain. It protects only within one worker instance; MV3 kills idle workers, so cross-instance safety comes from the idempotent exists() check plus the durable extension_applied ledger kept outside the browser by src/native_host.py. Adding a delay would move the race, not close it.

On the board. Verified column, card "Finding 8: MV3 lifecycle race creates duplicate bookmarks". No open issue. #8 Firefox: evaluate a port over the WebExtension bookmarks API asks whether the lock is still needed under Firefox's worker lifecycle.


9. chrome.bookmarks has no custom metadata field

Claim. chrome.bookmarks.BookmarkTreeNode exposes id, parentId, index, url, title, dateAdded, dateGroupModified and unmodifiable and nothing else, so an extension can neither write the file format's per-node meta_info dictionary nor read a marker written into the file by anything else; two appliers therefore cannot share an ownership marker.

Status. Verified by API surface and by consequence (the file writer had to match on URL).

How it was verified. Two ways. The API surface simply has no such field. And the consequence bit: the file writer originally checked ownership by GUID and marker only, did not see nodes the extension had created through the API, and would have re-created them. src/apply_brave.py now also treats any node anywhere in the tree with the same URL as already present. Ownership of extension-created nodes lives outside the browser, in the extension_applied table keyed by (client, raindrop_id); the worker reports which browser it is, detecting Brave with "brave" in navigator because Brave's user agent deliberately claims Chrome. Safety and Ownership Model explains why the two ownership records coexist.

Sub-claim. 9a. meta_info written into the file survives Brave's own round-trip. Status: Verified, 7 of 7 nodes. This is the load-bearing assumption for marker ownership by the file writer, and it was established during the finding 4 probe run. It should be re-verified after major Brave upgrades, because the whole ownership model rests on it.

On the board. Marked verified in the table, but there is no card for it in the Verified column at the time of writing. No open issue.


10. Safari is not possible

Claim. There is no supported or unsupported programmatic route to add a bookmark to Safari on macOS 26: the WebExtension browser.bookmarks API is compiled out of WebKit and absent from the shipped dyld subcaches, AppleScript has no bookmark class (compiler errors -2741 and -2740), Shortcuts has no bookmark action, there is no URL scheme, safaridriver exposes no bookmark endpoint, the declarative device-management path requires MDM enrolment and profiles can no longer install configuration profiles on macOS 26, and direct plist writes are guarded by CloudBookmarkDatabaseLockArbiter against an iCloud-backed store that fans out to every Apple device.

Status. Verified across seven independent avenues, then adversarially refuted and unchanged.

How it was verified. Each avenue was checked rather than assumed: a symbol search across the shipped dyld shared-cache subcaches for the WebExtensions bookmarks API; attempting to compile AppleScript bookmark terminology and recording the errors; enumerating Shortcuts' Safari actions; enumerating safaridriver's endpoints; and checking whether profiles can still install a configuration profile on this OS. Every result was then put through an explicit attempt to find a counter-example, and all survived. The last row is the one to take seriously even if a lock could be bypassed: a write behind Safari's back would propagate to every device on the iCloud account.

The single escape hatch is manual: emit a NETSCAPE-Bookmark-file-1 HTML file and import it by hand through Safari's File menu. Re-import de-duplication behaviour is unknown and was not tested. The Reading List is scriptable but flat, effectively write-only and not bulk-removable, and is not a bookmarks API.

On the board. Verified column, card "Finding 10: Safari has no supported write path". The manual route is Planned as #10 Safari escape hatch: export a NETSCAPE-Bookmark-file-1 for manual import, scoped as a deliberate occasional action, not automation.


11. Smaller findings

The verification table does not grade these individually. RESEARCH.md presents them as things observed while building the project, each worth an hour to anyone who has to rediscover it, so read them as field notes rather than as table-graded claims.

  • chrome.alarms clamps to a one-minute floor. A periodInMinutes below 1 does not poll faster. Genuinely instant delivery would need a long-lived connectNative port so the host can push, which MV3 fights by killing idle workers. The extension polls at the floor. Related: Planned #9 Instant apply through a long-lived connectNative port.
  • Unpacked extensions do not auto-reload on file change. After editing the service worker you must bump manifest.json's version and press reload on the extension card. Running a stale worker is the default experience.
  • --load-extension only applies to a browser you launch yourself. Your everyday profile still needs a one-time manual Load unpacked; no flag installs into an already-running browser.
  • An unattended job must never depend on a fingerprint. Observed: two scheduled runs degraded silently because a secrets CLI blocked on an interactive Touch ID prompt with nobody at the machine. The login keychain reports no-timeout through security show-keychain-info, so it is unlocked at login and never re-locked on a timer, which makes it the right primary source. Non-interactivity was verified by stripping the interactive tool from PATH and running with an empty environment.
  • security add-generic-password -w does not read the value from stdin. Observed: it silently consumed the next flag as the password. security -i reads commands from stdin, which also keeps the secret out of argv where ps can see it.
  • A stored secret with a label line silently doubles your HTTP calls. Observed: a two-line note piped through xargs produced two curl invocations, a malformed one that returned 401 and a correct one that returned 200, so it looked like it worked. Normalise retrieved secrets to exactly the value.
  • Separator drift in a path-keyed column is a landmine. Observed: two runs of the same code wrote Parent / Child and Parent/Child into the same ledger column. Nothing broke, because no reader keys on that column, but an exact-match lookup added later would silently miss most of the library. Normalise on read, and treat the repair as an explicit data migration rather than a silent fixup inside a scheduled job.

How to verify these yourself

Every finding above was established the same way, and none of it required risking a real profile. The method is spelled out in RESEARCH.md section 12 and in CONTRIBUTING.md; the short form is:

BRAVE="/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
SRC="$HOME/Library/Application Support/BraveSoftware/Brave-Browser"
LAB=/tmp/brave-lab

rm -rf "$LAB"; mkdir -p "$LAB"
cp -R "$SRC/Default" "$LAB/Default"
cp "$LAB/Default/Bookmarks" /tmp/Bookmarks.before

# stage one invented promotion first. With no desired.json the writer logs
# "no desired.json; nothing to do" and exits, and the diff below proves nothing.
mkdir -p /tmp/lab-root
cat > /tmp/lab-root/desired.json <<'EOF'
[{"raindrop_id": 1, "name": "Sourdough", "url": "https://example.com/recipes/sourdough", "folder_path": "Recipes"}]
EOF

# run the writer against the copy, never the live file
RAINDROP_SYNC_PROFILE="$LAB/Default" RAINDROP_SYNC_ROOT=/tmp/lab-root \
  python3 src/apply_brave.py

# quit Brave from its menu afterwards: the quit-time save is part of the test
"$BRAVE" --user-data-dir="$LAB" --enable-logging=stderr --v=0 about:blank

diff <(python3 -m json.tool /tmp/Bookmarks.before) \
     <(python3 -m json.tool "$LAB/Default/Bookmarks")

Six rules make the method trustworthy: parameterise every path so the identical code runs against the lab and against production; diff the set of GUIDs, the set of ids, the titles and the checksum separately rather than the raw file; force a serialise from memory with a probe extension so "Brave did not reject my file" becomes "Brave loaded my nodes and rewrote them itself"; use --enable-logging=stderr --v=0 as your window into extension and native-messaging plumbing; read Preferences directly for extension state; and run every experiment twice, once with the browser closed and once with it running, because the answers differ.

When you have a result, positive or negative, Contributing and Roadmap describes exactly what to put in the issue and what to leave out.

Clone this wiki locally