The player screen grows the same three-state control the desktop's cards have — Save for offline / Saving… / Remove download — and episode rows and queue rows carry the `⬇ Saved` mark beside the listen label. The control is on the player screen rather than on every row, which is where the desktop puts it: a phone row is ~350dp and already ends in the queue control, so a second button would leave the title two words. This is the same width decision as the two-column/stacked split on detail screens, not a difference of taste — the rows still say whether an episode is saved. Playback opens the saved copy through `local_audio_path`, which decrypts to a file under the app cache named after the blob's own address: a second play finds it already there, and an episode re-saved from a publisher who changed the file lands under a different name rather than silently serving the old audio. Decrypting streams a chunk at a time, so a 200 MB episode does not have to fit in memory twice on its way to ExoPlayer — which `getBlobBytes` would have required. A failed decrypt falls back to streaming: the enclosure is still there, and refusing to play something because a cache write went wrong would be the worse answer.
48 KiB
TODO
Unfinished work this project intends to get to. Work deliberately not being
done now — sound idea, decision already taken to defer it — lives in
docs/long-term-todo.md instead, with the trigger that would bring it back.
Concepts
Key Management & Identity
Consider a human-input secret derivation path using Argon2id
- Description: Node and ensemble key derivation uses HKDF-SHA256, which is correct
because the inputs are already high-entropy (128-bit random seeds or validated diceware
material). The
argon2crate remains a dependency in case we want a separate mechanism for deriving keys from genuinely low-entropy human-chosen inputs — an encryption passphrase for at-rest node secrets, or a PIN guarding a delegation cert. - Details: Argon2id is the right primitive for that job. Design it as a separate module
rather than re-introducing it to
Secret::to_keypair. Pick parameters appropriate for mobile, and store a per-secret random salt with the ciphertext. - No caller yet, on purpose. The one that was written down — encrypting the node
secret at rest — is deferred to
docs/long-term-todo.md, which carries the reasoning and the triggers that would reopen it. Build the derivation when something needs it, not before.
Act on the PDDB comparison for Vaults
- Description: Xous' Plausibly Deniable DataBase has been mined for ideas and
pitfalls; the findings and the reasoning are in
docs/vault-pddb-research.md. Its headline conclusion is that a locked vault and a hidden one are different products, and that sync makes the hidden one close to unreachable — so vaults should be locked-but-visible, said so on purpose. What remains is the work that conclusion implies, all of it cheapest now, before a second vault exists. - Decide and write down (
docs/vault-pddb-research.md§7): that vaults conceal content and not existence, indocs/data-model.md; which vault a new document lands in, sinceDocument::newcurrently assignsDEFAULT_VAULT_IDitself and has no parameter; and whether aPassphrasevault stores its own salt, which is the choice that forecloses deniability for that variant. - Build:
- A locked vault as a state, alongside
DocumentAvailability's three, reached before render rather than as aVaultKeyNotFoundat decrypt time — otherwise a sealed document renders as fully present and then blanks, which is the bug "Absence is not an error" exists to prevent. Words in acopy::vaultmodule. - Vault-awareness in
eviction.rs,content_ledger.rsand the last-copy guard. This one is data loss, not disclosure: both are vault-blind today, so eviction would happily delete the only copy of a blob nobody present can read. - Pin system-written documents (agent settings, ADR 0018, and anything else the system writes rather than the user) to the default vault at the type level. The PDDB shipped the bug where a setting written while a secret vault was open silently reverts when it is locked.
- Property and Artifact
valueencryption under the vault key, which is the only part of the boundarydocs/document-sync.mdclaims and the code does not have.
- A locked vault as a state, alongside
- Also worth having: an offline store verifier that walks documents → artifacts
→ blob files and opens every sealed chunk, on the model of the PDDB's
backalyzer.py. Useful before vaults, and the only way to answer "what in this store can I still open?" once blobs live under more than one key.
Design a robust manifold key rotation scheme
- Description: No mechanism exists for rotating the manifold (master) keypair if it is
compromised — a deliberate omission for the bootstrap phase. Also needs to apply to the
PkarrKeypair. - Details: Evaluate PKI / chain-of-trust anchoring, social recovery via threshold
signatures (M-of-N across trusted nodes), timestamped revocation records published to the
DHT under the old key, and whether
irohprovides useful primitives. - See
docs/KEY_MANAGEMENT.mdfor the current design;docs/ENSEMBLE_RESEED.mdfor a narrower first step, now implemented (clone a store into a brand-new ensemble, without solving revocation or unlinkability).
Revocation phase 3: lost or stolen devices
- Description: Phases 1, 2, 3a and 3b are built. Phases 1-2 are a signed revocation
record, a deny-list beside the cert check, delivery over gossip and doc-sync, and
surfaces on every modality. 3a bounds what a node that was hostile before it was
revoked leaves behind: peer-supplied timestamps are rejected past a day's skew at one
decode point (
p2p::wire_time), and nothing decides withorigin_nodeany more. 3b is membership epochs (ensemble::epoch,stores::epoch) — a cert carries the epoch it was minted at,p2p::admissionrefuses one below the ensemble's current epoch in both directions, the epoch moves only on a signedEpochBumprelayed over gossip, andretire::recertifyis the way back for a node that missed one. So silence now fails closed, and the deny-list is no longer the only thing standing between a revoked node and the ensemble. - What remains (
docs/NODE_REVOCATION.md§3):- A bump and a recertify affordance outside the CLI.
synhas both, through the daemon; the gpui desktop, the TUI and Android have neither, and none of the three rendersEnsembleStatus'scurrent_epoch/this_node_epoch_is_stale— so a node locked out by a bump says nothing about why on the modalities most likely to be holding it.copy::revocation::{epoch_bumped, BUMP_KEYPHRASE_WARNING}already own the words. - 3c, vault key rotation. The revoked device keeps its copy of the vault key, so
nothing already on disk is protected until that key changes.
VaultKeySpec::EnsembleDerivedhas aversionandreseed::reencrypt_blobsis the re-encryption pass; the gaps aredefault_vault()hard-codingversion: 1andvault_keyshaving no version column. Long-running and resumable, so it is a Job. - 3d, how that relates to reseed.
- A bump and a recertify affordance outside the CLI.
- A narrow gap in phase 2 worth knowing about: a revocation names a cert, and a peer seen only in the pkarr registry has never presented one — so it cannot be revoked until it has announced itself once. The error says so.
- Consider ideas from FOKS, the Federated Open Key Service: https://foks.pub/#roadmap
Media library
The shared browser component is docs/adr/0025-media-library-shared-browser.md.
- Agents that browse a tree but haven't adopted it:
pictures.rson desktop; Pictures on Android; the TUI and CLI, which have no equivalent at all (the CLI version will unavoidably be stripped down). - Grid-aware keyboard navigation: arrow keys move linearly through the item list in both layouts rather than by row/column, and the cursor doesn't scroll itself into view.
- Thumbnail caching: covers are decoded per session with no on-disk cache, so they redecode on every launch.
- Re-importing the same series folder twice creates duplicates. No dedup mechanism exists for any import path, not specific to comics.
- Mixed folders: a directory holding loose page images and subdirectories
imports the loose images as a chapter named after the parent folder, colliding
cosmetically with the series
Collection's own name. Rare; disclosed. - PDF secondary line shows "Added Xh/d ago", not a page count. The count is
now read at import time (
agents::pdf::read_metadata, ADR 0035); what is left is persisting it as a property and rendering it on both platforms.
Music metadata-driven library organization
Remaining steps are in docs/music-library.md's status section, which supersedes what
was sketched here.
Search
Everything that answers "find me the thing" lives here, even though the pieces sit at
different layers (a UI shell and a whole subsystem not yet started). Design for how
search should surface in the UI: docs/command-palette-and-search.md (Proposed,
2026-08-05) — not all search routes through the palette, but a large well-defined part
does, and the parts that stay per-agent should share machinery. Implementation not
started — the palette (components/command_palette.rs) is still a shell whose
run_selected only dismisses, and exists only in gui-app.
The document title filter's query performance (a leading-wildcard LIKE, unindexable)
is a known cost but not active work — an FTS5 trigram index is recorded as a deferred
option in ADR 0034 rather than filed here,
since it isn't worth taking before it shows up in a profile.
Full-text search across document bodies
- Description: Titles are the only thing filtered today — nothing indexes document content, so there's no way to search across all document types by what they contain.
- Extended framing (2026-07-15, via /office-hours): the compelling version is P2P-native — offline full-text search across the user's whole synced PDF corpus, zero cloud indexer and zero account, paired with a cross-device "teleport" to jump straight to a synced highlight/reading position. Deliberately post-MVP: full-text search across many local PDFs implies a real local search-index subsystem.
- Notes has its own narrower version of this gap — see "Take cues from Obsidian's search" under Agents ▸ Notes.
CLI document search UX
- The CLI document search experience, especially by type, needs better UX — ideas: a fuzzy
finder for
docs list, a better search language.
Build & Development
Finish harmonizing the Android visual style with ADR 0032
- Description: The Android pass (ADR 0032 §10) landed the foundations, the whole
structural half, and all but one of the five gaps against the desktop
(
bgMediaCanvas, the reading-surface story, the live-state slot's two wired publishers, and selection icon/text colors). One remains, deliberately deferred: - A
Presencetab (§7). Still the one to hold longest. A nodes × documents grid is a desktop-shaped affordance thatdesign/visual-design-review/PRESENCE-AFFORDANCE.mdtreats as unsettled even on the desktop, and a phone may want a different answer rather than a narrower grid. Decide the affordance before building anything.
Run a visual design review of the Android app, like ADR 0032's for the GUI
- Description: ADR 0032 was produced by critiquing the gpui desktop app against real
screen captures (
design/visual-design-review/) — that process hasn't been run for Android on its own terms, only checked against the desktop's conclusions (the item above). Android has platform-specific visual surface the desktop review never looked at: Material component defaults bleeding through, system chrome (status bar, gesture nav) interacting with the app's own palette, touch target sizing, and anywhere Compose's own idioms pull against the shared token structure. - Details:
design/visual-design-review-android/is scaffolded (README +screenshots/) with the capture workflow: a physical device over USB +adb exec-out screencap, no emulator, no Android Studio. Do the same five-step process as the original review — capture → critique → direction → look at references → decide — and land findings either as an ADR 0032 amendment or a new ADR if they're big enough to need their own record. - One finding to start from, and the shape of what to look for. Sync policy → a remote
node writes directly to that node over the ensemble connection, and Android confirms
nothing about that connection. The desktop shows all three states in a status line above
the section — "Editing laptop — connecting…", "— connected" in green, "— unreachable:
{reason}". Android has only "Loading…" and the failure, both inside the section, and
never names the node it is editing or says the connection succeeded. On the one screen
whose edits leave the device, silence on success is the wrong default.
- It is worth starting from because of how it was found: not by looking at Android, but by noticing that the two modalities' unreachable messages differed and asking why. The desktop's says "see the status above" because there is one. An absence leaves no mark on the screen it is missing from, which is exactly what a capture-and-critique pass is bad at catching and what comparing two modalities is good at. Worth running that comparison deliberately as part of the review rather than hoping the screenshots show it.
copy::settings::remote_config_unreachabletakes the reason as anOptionso both modalities stay correct meanwhile; Android passingNoneis what closing this looks like.
Give the TUI the shared content-fetch progress treatment
- Description:
lib::content_fetchnow counts bytes, reports them as aContentStreamjob, and takes a cancel; the gpui desktop draws that withcomponents::remote_fetchand Android withui/RemoteFetch.kt. The TUI has no equivalent, so opening something that lives on another node there is still a wait with nothing on screen. - Details: nothing needs deciding or wording.
copy::content_fetchowns the state machine (display) and every string (copy), and both existing frontends are thin renderers over it — the TUI calls the same two functions and draws the result. What it has to supply is the same three inputs: whether aContentStreamjob row has been seen, its byte counters, and how long the counter has been still. - Constraints: wedged takes the warning style, never the error style, and
does not abandon the transfer — "absence is not an error" (ADR 0033) holds
for a slow peer too. A terminal has room for a counter and a rule; it does
not need a bar. Do not write any of the strings locally: if a word is
missing, add it to
copy::content_fetchso all three surfaces get it.
Documentation
- Description: Add comprehensive documentation for the FFI layer and type sharing
- Details: Document the interface between Rust and Kotlin, especially around serialization formats. ADRs 0006 (Result convention) and 0023 (error taxonomy) cover the error half; the data half is undocumented.
- Consider lessons from https://docs.divio.com/documentation-system/
Testing
Android repository unit tests
- Description: every test in
android/app/src/testcovers a pure function — the decision rules (IncomingBodyTest,IncomingEpisodesTest,PodcastEqualityTest), the tree and list projections, the time and adjacency helpers. No repository has a test. - The five repositories (
NotebookRepository,ComicRepository,MusicRepository,PodcastRepository,NotebookActiveStateRepository) each hard-codeDispatchers.IO; testing them means taking aCoroutineDispatcheras a constructor parameter so a test can substitute one. Do that as part of writing the tests, not as a lint burndown — detekt'sInjectDispatcherstays off either way, because it also demands the same of ~59@Composablesites where it means nothing. Seedetekt.ymland ADR 0022's 2026-08-22 update.
Integration test: cold-start-after-total-outage bootstrap
- Description: Exercise the scenario where all nodes go offline long enough for the DHT entry to expire, then come back online one by one.
- Details:
- Needs a fake/mock DHT whose state the test harness can control (inject, clear, simulate expiry)
- Verify: first node back detects no DHT record and publishes its own; second discovers the first via DHT and both join gossip; subsequent nodes bootstrap via gossip alone
- See
docs/BOOTSTRAPPING.mdfor the full design
Reliability
- The app needs to not show unclear user-facing errors under network unreliability or a deliberately severed network — see Android/Kotlin Improvements ▸ "Android error surfacing, and the general sync-status signal it's part of" for the design side.
- There should be tests for the above
Code Design
Redesign keyboard navigation across the TUI and GUI
- Description:
gui-appandtui-appgrew keyboard navigation ad hoc, screen by screen, with no shared model for the two concerns that recur everywhere: (1) drilling down within one agent's screen, and (2) switching between top-level agents. Every screen invents its own scheme (b/Leftfor back,j/kvs arrows, an ad hocModeenum per screen for modal text input), with no documented convention for a new screen to follow. - Immediate stopgap: the Documents detail screen's Overview/Sync split uses
Shift+Tabto cycle sub-tabs in the TUI, chosen only because plainTabis claimed at the top level. A placeholder, not a considered design. - Questions to resolve:
- One documented key-scheme convention (drill down / back / sub-tab / top-level task) that every TUI screen and GUI agent view follows.
- Whether "switch top-level task" and "switch sub-tab" should use mnemonically related key families or deliberately distinct ones (to avoid fat-fingering a task switch).
- Whether the GUI needs a keyboard-driven top-level agent switcher at all today — the rail is mouse-only. If so, design it alongside the TUI scheme, not after.
- A shared abstraction for "list screen with an optional detail sub-view" and "screen
with sub-tabs", so new agents get consistent navigation instead of copy-pasting a
Modeenum.
Android/Kotlin Improvements
Investigate whether mainline DHT lookups are silently blocked on the Android emulator
- More urgent, and probably broader than DNS (logcat from an emulated node, 2026-08-25,
during a sync that took minutes for ~20 tiny documents):
avc: denied { nlmsg_readpriv } ... tclass=netlink_route_socketandavc: denied { search } for name="net" dev="sysfs", repeating every ~21s. That is interface enumeration being refused by the app sandbox — iroh cannot see its own addresses. Not emulator-specific in principle:untrusted_appis the SELinux domain of any sideloaded or Play-installed app, so a physical device may deny the same.sendmsg error: Os { code: 5 ... "I/O error" }to a global IPv6 destination fromsrc_ip: fec0::…— deprecated site-local. Something keeps retrying a path that cannot work, minutes apart, across exactly the slow window.- The two fit together: blind to its interfaces, the node picks an unusable local address, and every send on it fails. That would make Android-initiated syncs slow while desktop-initiated ones stay fast (measured at 0.5s), which is the asymmetry observed.
- So the question is wider than the DHT: whether an Android node can discover a usable local address at all, and what it should do when it cannot. Confirming on a physical device is still the fastest way to separate "emulator artifact" from "every Android node", and now decides more than DHT bootstrap.
- Description:
P2PNode::start(rust/lib/src/p2p/mod.rs) already works around one emulator networking quirk — hickory's UDP DNS queries todns.iroh.linkget dropped, so Android uses DNS-over-HTTPS to8.8.8.8:443/8.8.4.4:443instead (port 443 bypasses the interception). The endpoint also registersDhtAddressLookup(BEP44/Kademlia over UDP) for both platforms, untouched by that workaround. - Working hypothesis: the mainline DHT hits the same UDP-drop behavior, because the
emulator intercepts arbitrary outbound UDP, not just port-53 DNS. If so an Android node
can never publish or resolve peer addresses via the DHT, leaving it dependent on
relay-URL exchange through gossip — which itself needs a connection to already exist.
That matches the symptom observed 2026-07-10: a desktop node logging
iroh_gossip::net - dial failed: No addressing information availablefor an Android peer it knows about via the ensemble's pkarr registry. - How to confirm: check whether Android ever logs successful DHT publish/resolve
activity from
iroh_mainline_address_lookup, or only silence — with "Show iroh logs" + Verbose on in Settings,adb logcat -s synchronicity | grep mainline(every record forwarded from Rust carries that one tag, with the module path at the head of the message). Quicker still:adb shellinto the emulator and see whether an arbitrary raw UDP send/receive succeeds at all. - If confirmed: the DoH trick doesn't transfer (DHT isn't HTTP), so likely a TCP-based
DHT bootstrap/proxy, or disabling
DhtAddressLookupon Android in favour of the working DoH path plus persisted/gossip relay-URL caching, or emulator network config. Testing on a physical device is the fastest way to rule this in or out — if a real phone bootstraps via DHT, the bug is emulator-specific and low priority.
Android error surfacing, and the general sync-status signal it's part of
- The general gap: there is no user-facing "a sync is happening / just failed / just succeeded" signal anywhere. (There used to be per-peer Connected/Degraded badges on the Ensemble screen; they claimed a reachability the gossip mesh cannot report, and were removed rather than corrected — ADR 0032's amendment.) The whole premise is that sync won't silently lose or desync data; a silent failure undermines that regardless of how reliable the protocol is. Needs to hook every sync-triggering codepath — manual "sync now", background periodic sync, document import/receive — not one narrow case. This is per-operation presence/success/failure, not per-peer connectivity. This is tier 2 of the taxonomy below.
- Android-specific taxonomy:
docs/adr/0021-android-error-surfacing-tiers.md. Follow-ups it left open:- gpui's Notes agent (which has its own shipped banner) and the TUI (no error-surfacing story at all) need their own design pass — not assumed to converge on Snackbar, since neither platform has that concept.
- Toast is still used for success confirmations (PDF/comic import) in
DocumentsScreen.kt/PdfLibraryScreen.kt/ComicLibraryScreen.kt— a separate, lower-stakes question the ADR didn't resolve.
Look into Android Auto support
- See how hard this would be. The lock-screen
MediaSessionServicefoundation (docs/adr/0007-*) is in place; still missing on top of it: playback resumption after process death (onPlaybackResumption) and aMediaButtonReceiverfor restarting playback from a Bluetooth/headset button when the app isn't running.
Core Functionality
Keeping track of long-running jobs
Model and dedup design: docs/adr/0011-jobs-first-class-model.md; product design:
docs/sync-management.md's "Jobs" section.
- Cancellation is still coarse for a single blocking P2P call. Every surface is
runner-backed now, so a manual pull/push/eviction has a live token — but nothing reads it
mid-transfer:
asset_sync's functions take no cancellation signal, so the work runs to the end and the row is only recorded asCanceled. The imports and scheduler passes, which checkis_canceledbetween units, do stop. Threading a signal intoasset_sync(probably a&dyn ProgressReporter, since that is what the byte counters would want anyway) is the fix, and it would improve desktop as much as the FFI/daemon. - The TUI has no import affordance at all. When it gains one it should start a job from the outset rather than being retrofitted the way the GUI's library screens were.
- A blob's plaintext is read whole before any of it is sealed.
BlobStore::storetakes a&[u8], so importing a 2 GB file means 2 GB resident — the binding constraint on a phone, now that the ciphertext no longer doubles it. Taking a reader instead is a wide change: every importer reads its file up front, and Android hands bytes across the FFI as oneByteArray. - The Jobs screen should be aware of when a long-running sync was initiated by another node, or syncs that were safely/temporarily interrupted because a device went off wifi etc.
- Purge is the one long-running action that is not a Job at all. Every other control on the
document detail screen starts one; "Purge from ensemble…" writes tombstones and broadcasts a
delete intent behind a plain
PurgeState::Pendingboolean, on both modalities.JobKind::Purgeexists and nothing constructs it. Until it does there is nothing for the button to bind to, no Jobs-screen row for a purge that is still propagating, and no dedup against a second click on another screen — so this is arun_as_jobwrap first, and a widget binding after. - Android's media library screens still show nothing while an import runs, where the
desktop's now carry
components::imports_in_progress. Comics/PDF/Pictures/Music import one file at a time and do report progress into a job row, so there is something to bind to, but those screens show only a Toast at the end. Binding them needs theJobSubject::Path(label)Rust derives to be a label Kotlin can name before it calls, which it currently can't — or anImportsInProgressthat filters by kind alone, the way the desktop's does, which sidesteps the naming problem entirely and is probably the right first move. - No widget offers to cancel the job it shows. Every surface is runner-backed, so the token
exists — but the transfer paths don't read it mid-flight (see the cancellation bullet above),
so a Cancel on a bound widget would promise more than it delivers for exactly the widgets
that have one. Worth doing once
asset_synctakes a cancellation signal: a bound widget is the natural place to stop the work it started, rather than sending the user to Jobs to find the row.
The peer walk is serial, and the peer list is never pruned
- Description:
scheduler::ensemble_peers_freshest_firstreturns every node gossip has ever heard from for this ensemble — rows are never pruned, so a machine retired months ago is still in the list. Every pass walks it in series with a per-peer budget (PEER_CATALOG_TIMEOUTis 180s), so an ensemble carrying a couple of dead nodes spends most of a pass dialling them. Symptom: a trivially small sync — one podcast and twenty episode documents — takes many minutes. - What's already done: the list is ordered most-recently-seen first, so the live peer
is reached before a dead one's budget is spent, and
EnsembleEndpoint::connectnow bounds a dial at 15s (it was unbounded fordoc_sync, which is what let a single dead peer hold a pass for the full three minutes). - What's left: the walk is still serial, so the worst case is still
peers × budget. Options, not exclusive —- Dial peers concurrently and work with whoever answers (see the asset-sync item below, same trade-offs).
- Skip peers gossip currently reports as absent, rather than only ordering by it. Needs care: "asleep" and "retired" look identical from here, and silently skipping a sleeping laptop turns a delay into a node that never syncs.
- Prune or archive peer rows that have not been seen in a long time, with a way to see and undo it. This is really "decommission a node", which is the revocation work.
Track per-node artifact content presence across the network
Design in docs/adr/0013-blob-possession-evidence-last-copy-guard.md and
docs/adr/0029-content-possession-evidence-and-release-propagation.md.
- A dedicated gossip "I hold these" announcement is deferred. The gossip frame limit
(
DEFAULT_MAX_MESSAGE_SIZE, 4 KiB) is too small for a delegation cert plus a useful batch of 32-byte hashes without windowing across ticks. Revisit if gossip framing allows larger or chunked payloads.
Single-process store access: remaining hardening
How it works: rust/lib/src/stores/lock.rs and
docs/adr/0004-daemon-first-cli-tui-store-access.md.
syn docs importagainst a running daemon reports per-file results only after the whole batch completes — events are collected daemon-side and replayed. Consider streaming progress over the socket if large imports feel unresponsive;DaemonCommand::AwaitChangeis the precedent for pushing over that socket.
Agents
-
Pins: a generic cross-agent way to mark a Document as something to go through next (could be implemented with Edges)
-
Make agent sidebar order user-configurable. Activity agents sort alphabetically at the top of the sidebar, control agents alphabetically at the bottom. Allow drag-reordering within each group, or a settings UI. The preference should be persisted per-node and synced across nodes — the same shape of problem
docs/adr/0018-agent-settings-documents.mdsolved for Notes.
Documents
- Concurrent moves can still disagree about which one survived.
parent_modified_atmerges a reparent last-write-wins, and bothset_parentandsync_receive_parentrefuse to store a cycle, so every tree walk terminates. What is not converged is the case where two nodes each make a locally legal move that is only a cycle together: each keeps its own and drops the other's, until someone makes a newer move. Converging it wants a move log with undo/redo by timestamp (Kleppmann et al., A highly-available move operation for replicated trees). See ADR 0049. - Look at the Papra open-source project for document UI design ideas; also PdfDing
- Some documents get misleading titles when imported naively (e.g. comics) — importers should produce sensible titles, since those are what the Documents view shows
- Consider a bulk storage backend using S3
Queue a sync directive for a node that is asleep
-
Description:
sync_with_peertells a peer to reconcile vianode_admin::trigger_sync, which is a live QUIC round trip. A peer that is asleep, backgrounded, or NAT-stranded at that moment never hears it, and the instruction evaporates — that node then converges on its own clock, up to an hour later on Android. A phone asleep when you press the button is the common case, not the exotic one, so "make that node hold this" is currently best-effort in exactly the situation it most needs not to be. -
Decided: a decision to mirror must not be lost. Queue the directive so pressing the button is durable. This is
docs/sync-management.md's "push-assisted delivery", moved up; seedocs/content-placement-redesign.md§5. -
Questions the queue exists to let us answer (experiment, not argument — the right behavior against a real sleeping phone isn't knowable from here):
- How long does a queued directive stay valid? A
Mirrorpressed a week ago may not be what the user still wants. - What happens if the target's own policy changes before it wakes — does the directive still apply, or has it been superseded by a more recent local decision?
- Should a pending directive be visible, and cancelable, or is that clutter for something that usually lands within seconds?
- Delivery: gossip carries small frames (4 KiB, see the presence-announcement item) and reaches nodes as they appear; store-and-forward through a reachable third node is the other shape. Neither is obviously right.
- How long does a queued directive stay valid? A
-
Related: this is the same durability question as node revocation's "deliver a decision to a node that isn't listening", and may want the same mechanism.
-
Content placement UX: what's left. Design and the finished steps 1-7 are in
docs/content-placement-redesign.md. Remaining:- Ensemble member rows don't show a peer's deficit. The Sync policy tab
does now, but only for the one node selected in its picker. A badge per member
row would need a
GetSyncConfiground trip each, on a screen that currently does no network work per row — decide whether that's a lazy per-row fetch, a batched one, or something gossip already carries. - Presence rollups beyond the Documents list.
content_ledger::subtree_contentbacks the desktop Documents list now. The media library screens (music, comics, pictures) still use their own per-item availability, and Android's lists show nothing of the kind — a phone gets no answer to "how much of this album is here" without opening the album. - Per-node default policy:
KeepOnOpenon desktop,Streamon mobile.SyncPolicy::default()is one value for every platform today; the default belongs to the node, set once at initialization from what kind of node it is.
- Ensemble member rows don't show a peer's deficit. The Sync policy tab
does now, but only for the one node selected in its picker. A badge per member
row would need a
-
Per-artifact sync controls on the document detail page. Pulling one artifact works on every surface (
asset_sync::sync_document_asset).Nothing asks peers whether a newer version exists.Closed: the whole-document pull (asset_sync::sync_document_assets_recursive) now runs a scoped catalog refresh against every ensemble peer before the content walk, so "up to date" is an answer about the ensemble rather than about this node's last scheduler pass. Seedocs/sync-management.md's "On-demand catalog refresh for one document".- The per-row "Sync" control still doesn't.
sync_document_asset— the narrow per-artifact affordance in the Artifacts section — deliberately skips the catalog phase: it fetches the oneContentKeythe caller named, against whatever the local catalog says. That's right for "fetch this blob I can see," and wrong if what the user means is "get me the current version of this note." Decide whether the row control should refresh that one artifact's row first, or whether the whole-document button is the only place that question belongs. - Feeds into automatic sync policy eventually.
-
Document search (title, then body) lives in its own top-level section — see ## Search.
Canonical URIs for ADR 0050's captured posts
- Description: ADR 0050 needs a post's id derived from
(platform, canonical_uri)so two nodes saving the same post converge, and so a future Feeds agent ingesting the same post reaches the same document rather than a second one. The derivation itself is done —documents::derived_idis the one place a UUIDv5 identity is computed, and a new post id is aDomainvariant. What is missing is the canonicalization, which is part of the helper and not a caller's preparation: two derivations that agree on UUIDv5 and disagree on whetherbsky.app/profile/<handle>/post/<rkey>normalizes to itsat://uri produce two ids just as surely as two different algorithms would. - Needs a per-platform rule (at minimum Bluesky, Mastodon, Twitter/X) and a decision about
what a failed canonicalization does — the raw uri is a defensible key as long as it is
never silently mixed with canonicalized ones under the same
Domain. podcast::episode_document_idis the worked example of the derived-id half:(feed document, publisher's episode id),Provenance::Syndicated, no dedup scan.
Service credentials: what's left after the store
The store is built (ADR 0051): a ServiceCredential document per service,
sealed with the vault key, synced on the catalog plane. Podcast Index reads
from it; a Settings ▸ Credentials tab on the desktop, the same section on
Android, and syn settings credentials{,-set,-clear} enter and remove keys;
and the tests provision the published test key themselves. Each service's
blocks are built from ServiceId::fields, so a second service is a Rust
variant and no frontend work.
Open:
- Rotate the Podcast Index key. The one this repo shipped is in its public
history, so removing it from the source did not un-publish it — it needs a
human with the account to rotate it at the provider. Nothing in the tree
carries it any more:
credentials::import_from_envreadsSYN_CREDENTIAL_<SERVICE>_<FIELD>,syn settings credential-import podcast_indexprovisions a store from those variables, and the one live-API test calls the same function and says what to set when the environment is empty rather than failing. - No rotation, expiry or OAuth refresh. A refresh token rewrites itself on a schedule, which needs a writer that is not the user and an answer for two nodes refreshing at once. Nothing forecloses it; nothing implements it.
Clippings: what's left after the first capture path
Clipping works end to end (ADR 0039): a context menu in the extension, syn clippings capture, a Clipping document under an anchored root, and a
Clippings screen on the desktop and on Android, both reading their words from
copy::clippings. Both page as they are scrolled, over
DocumentStore::list_children_page's keyset cursor — the two open items ADR
0039 left ("Load more, and what it forces" and an unpaginated list_children)
were one problem and are closed together. Both also edit a clipping in place,
behind the alert-don't-apply flow (ADR 0031) the last open item was waiting on.
Open:
- No linkages on Android. The Clippings screen landed; a phone still cannot show or revoke what it has granted to a program on its machine. See the browser-extension section below, which carries the same gap.
- A second clippings tree. The pointer indirection is built
(
ClippingsDefaultRoot); what isn't is any UI to create a second root or move the pointer. When it lands,DocumentType::Notebook's doctrine applies — a container an agent enumerates gets its own type rather than a role property.
Browser extension: what's left after linkages
Pairing landed on desktop (ADR 0017's amendment): a linkage is a revocable bearer
token a node grants to one program on its machine, mintable from Settings ▸
Linkages or syn linkage pair, and the loopback listener serves /pair and
/command behind it. The extension popup links, unlinks, and re-links.
Open:
- Mobile pairing. The Android deep-link flow (
syn://vs. a verified App Link) is untouched, and so is any Linkages UI on Android — a phone can't currently show or revoke what it has granted. /ws. Deferred deliberately: the popup does everything today, so there is no suspended service worker to keep a connection alive for, and nothing to push yet. It becomes worth building when a node needs to tell the extension something.- Capability scoping. A paired client gets the whole
DaemonCommandsurface. Fine while the only client is one the user installed on purpose; revisit before anything less trusted holds a token. - The extension is loaded unpacked today.
just extension buildpackages a loadable zip and the store icons are in place; what is left is Firefox signing, without which there is no permanent install there.
Music
- See ideas in https://unformeddelta.wiki/ihe0bEPXK5ip/metadata-patterns-not-accounted-for-by-music-apps
- Also see UI ideas from Empeg, via the Cathode Ray Dude video (e.g. waveform seek tool)
- Consider https://www.symfonium.app/ as an example
- Scanning tags for a track whose audio only exists on another node. The music library
build works fine on a node holding no audio — tag
Propertys sync eagerly, so any node can derive the Artist/Album graph from a peer's scan. The one case it can't cover: a track no node has scanned, whose audio lives on a node that hasn't run a build.incorporate_trackreturnsTrackOutcome::Unknownand leaves it out — correctly, since inventing an "Unknown Artist" entry would be a claim we'd have to retract.- Rare: import scans, so properties exist from the moment a file enters the ensemble. It needs a track that arrived some other way — imported before tag extraction, or whose blob was replaced.
- Self-healing whenever the node holding the audio runs a build, so the failure mode is a temporarily missing track, not permanent loss.
- The general fix is a way to ask another node to run a job — "you have the bytes,
please scan these". A node-admin capability (
docs/sync-management.mdphase 5'sJobOrigin::RemoteAdminanticipates it), much larger than this symptom warrants alone. - Cheaper partial answer that already works: fetch the audio here (
syn docs sync <id>, or a content policy that pulls music eagerly) and the next build scans it locally.
Video
- No decoder yet: import and library browsing (
agents::video,MediaLibrary) work on desktop and Android, but activating a clip is a placeholder — no player. Metadata-only for now (re_mp4, no frame decode). Seedocs/data-model.md's "VideoClip" for the current shape. - Desktop decode spike: try
re_video(rerun-io) for GPU-texture frame decode first;ffmpeg-next(feature-gated, likemultimedia) oropenh264as fallbacks if it doesn't fit. Android should skip Rust decode entirely and drive Media3/ExoPlayer'sPlayerViewdirectly. - No poster-frame thumbnails — needs a decoded frame, so it's blocked on the
above;
MediaLibraryshows the placeholder glyph for every clip meanwhile. - Only MP4/MOV/M4V are recognized (
re_mp4); MKV/WebM would need a second container parser. - Android has no folder import for video (desktop does, via
import_directory_tree) — single-file picker only. - TUI ascii-art video playback (render decoded frames as an ASCII/ANSI-block
glyph grid in
tui-app). Gimmicky; do only once the gpui/Android video agent is basically working.
Notes
- Note-body CRDT design and follow-ups:
docs/adr/0030-note-body-crdt-loro.md. Still open from it:- Version-vector incremental transfer.
artifact_syncships whole snapshots today (510 KB vs an 868-byte incremental payload on a note with 1000 revisions). The upgrade is a vv exchange — requester sendsoplog_vv(), responder repliesexport(ExportMode::updates(&vv))— and needs another ALPN bump. Worth doing when note sizes or sync frequency make it hurt, not before. - Shallow-snapshot history truncation, the pressure valve if full-history retention (~12x plaintext at 300 revisions) stops being affordable. Peers then cannot import updates from before the shallow start, so it is an ensemble-wide decision no node may take unilaterally. Nothing implements it.
- Extend
CrdttoHumanNotesand free-formFact/ListFactvalues — deliberately left out of the first pass so the mechanism got exercised on one kind before spreading. - An editor that emits incremental ops. Merges are currently reconstructed
from whole-buffer saves, which both libraries warn merges less well than
capturing the user's actual edits. Nothing forecloses it: such an editor
writes to the same
LoroText. - History UI. The data is there from day one (snapshots retain the op log); "scroll back through this note" needs only the interface.
- Version-vector incremental transfer.
- An unsaved note doesn't survive the process.
NotesEditorSessionkeeps the open note across navigation and rotation, but it is a ViewModel: killed in the background, the buffer and any unanswered incoming body go with it, and only the note id comes back (persisted byNotebookActiveStateRepository). The desktop loses the same thing on quit, so this is parity rather than a regression — but a phone is killed far more casually than a desktop app is quit. Wants the session written through to the store, or toSavedStateHandle, which is the shape Android has for exactly this. - A save that doesn't have to lose either side. The choice both editors offer is only forced because a save is a whole-buffer write, applied as a diff against whatever the store holds now — so a stale buffer deletes the other node's edit. An editor emitting incremental ops (the ADR 0030 follow-up above) makes both sides' edits merge instead, and this whole conflict UI becomes a rarity rather than the normal path.
- Multiple simultaneously open notes needs a
Collection-arityArtifactKind—docs/adr/0018-agent-settings-documents.md's deferred growth path, intentionally not built until Notes grows tabs. - Take cues from Obsidian's search. Notes search filters the tree by
title on both modalities (
visibleNoteRows,HierarchicalSidebar ::set_filter), which is the documents grammar and answers "which note is this". It does not answer "where did I write that" — searching note bodies, and showing the whole line each hit sits on the way Obsidian's search pane does, so a result is readable without opening it. Deliberately not built yet: a body-hit result is a different row from a tree row (it has a preview, a count per note, and possibly several per note), and where those rows live relative to the tree is real design work, not a filter change. Decide the affordance first, for both modalities, then build. See ## Search for the document-wide version of this same gap. - The notes app should start up immediately
- Consider https://github.com/gpanakkal/incremental-reading-obsidian
- Use https://github.com/kumarUjjawal/aster as a model; also https://docs.rs/gpui-component/latest/src/gpui_component/text/text_view.rs.html#91-100
- Consider UI elements from https://openknowledge.ai/docs/features/editor
- Also the good ideas from https://v1.silverbullet.md/Manual as well as the workflows described in https://unattributed.cc/2026/07/21/how-i-write-blog-posts/
- consider something like this query language: https://docs.kiwifs.com/concepts/dql (Obsidian has a similar concept)
Podcast
- Rich episode experience: a Rust data structure representing transcript, sections, chapters, and UI to display them. Linux Unplugged is a good model.
- A saved episode is not governed by content policy. Saving one writes a
PrimaryContentblob like any other, but nothing has taught the scheduler's content pass or the Sync policy tab that aMirrorpolicy onPodcastEpisodeshould fetch saved episodes onto other nodes, or that aStreampolicy should reclaim them. Today saving and removing are per-node acts the user makes by hand on each device. Deciding this needs an answer to a question the other types don't raise: the enclosure has a public source, so "the ensemble should hold this" and "each node that wants it can fetch it from the publisher" are both defensible. - The desktop has no "load more" paging, where Android raises its request cap as the user scrolls. Both now fetch the episode list from the API on every open — a subscribed feed no longer serves a stale copy from disk — so what is missing is only the second page.
- Lightning boost support: figure out how hard this would be.
Comics
- Re-importing an archive always mints a new
Comic. Whether a second look at the same file updates the document already standing for it or creates another one is the agent's call — it is the only thing that knows two archives are the same issue — and this one has no answer yet. Deciding it is what a metadata rescan would hang off, so that a correctedComicInfo.xmlreaches an existing issue at all. The store side is ready: ADR 0043 gives properties removal, so a rescan can clear a field the corrected file dropped as well as change one, andmusic'stags_are_current→write_tag_propertiespair is the shape to copy. - consider ideas from https://github.com/Animetailapp/Animetail
- Deep zoom softens once the render hits its pixel cap. Both viewers re-render the
page at the zoom level in use rather than upscaling a fit-resolution bitmap — Android
debounces a pinch and re-renders through
MuPdfDocument.renderPageAtScale, the desktop caches pages atRenderState::active_scale— but each clamps the raster to a 4096px longest edge (MAX_ZOOM_RENDER_DIMENSION_PX,MAX_RENDER_EDGE_PX) so a deep zoom cannot OOM, and past that it is upscaling again. Tile-based rendering of the visible region is what lifts the cap; a whole-page render cannot.
Code Editor
- Description: Code editing and version control integration
- Details: Radicle DVCS for decentralized version control; support both Git and Jujutsu; syntax highlighting; diff viewing and conflict resolution; collaborative editing
Other Agents to Implement
- Something like https://github.com/RackulaLives/Rackula/
- Ebook reader
- Some kind of low-security bitcoin / other cryptocurrency functionality
- password/secrets manager
- rich dictionary functionality, like rikaichain/yomitan - maybe this should be part of an even more general app-wide text analysis tool that makes it possible to perform arbitrary analysis (incl. sending to a LLM) for any text in any document in synchronicity - it should also be capable of doing OCR for text in images