Requirements — second-brain mobile app v1
At a glance
- What it is: a Flutter app (iOS first, Android for free) that puts vault retrieval in your pocket — chat/search UI over the v2 retrieval API, degrading gracefully to offline filters-only search against a synced index
- Status: Draft v1 — Parked
- Depends on: second-brain retrieval layer v2 (
search_vaulthybrid contract,read_note,get_vault_stats,--scope), vault-indexer Phase 0- Serves: Principle 5 — “Available where I am”, later O-B
1. Background and problem statement
The second-brain stack is desktop-bound: retrieval works only where Ollama, the index, and the vault coexist. Away from the laptop there is no way to ask “what did I decide about the cluster upgrade?” — even though the retrieval layer (v2) already defines exactly the API such a client needs.
Three observations shape the design:
- The v2 tool contract is the mobile API.
search_vault(query, filters…),read_note(target),get_vault_stats()— a mobile app needs nothing more. The server side is a thin transport wrapper, not new retrieval logic. - The phone doesn’t need Ollama for most value. Hybrid search needs embeddings, but the FR-12 filters-only path (folder + tags + dates, ordered by modified date) requires only SQLite — which runs natively on-device against a synced copy of
vault-index.db. - Privacy tiering already exists. Scoped server instances (v2 FR-4x) mean the phone can be pointed at a structurally restricted endpoint — the mobile attack surface never sees more than its scope.
2. Goals and non-goals
Goals
- G1. Ask the vault questions from a phone: semantic + filtered search with snippets, one tap to full note.
- G2. Offline degradation: filters-only search and cached note reading with no server reachable.
- G3. Reuse, don’t reimplement: server-side retrieval stays in
common.py; the app consumes it over HTTP. - G4. Private by construction: transport via Tailscale (no public endpoint), scope enforced server-side, read-only throughout.
- G5. One codebase (Flutter/Dart), with iOS-specific vault access isolated behind a platform channel from day one.
Non-goals
- NG1. No note editing, creation, or sync-back. Read-only, like the MCP server.
- NG2. No on-device embedding/inference in v1 (Tier 3 — see §10). The architecture must not preclude it.
- NG3. No public internet exposure of the API; no auth system beyond Tailscale identity in v1.
- NG4. No chat-with-LLM in v1 — the app surfaces retrieval results, it does not answer questions. (An “open in Claude” share action is in scope; an embedded LLM conversation is not.)
- NG5. No Obsidian feature emulation (graph view, backlinks UI, plugins).
3. Current state
- Retrieval logic:
common.pypure functions over SQLite + Ollama (v2 Phase 1), no HTTP surface. - MCP server: stdio transport only — not consumable from a phone.
- Index:
vault-index.db(files, tags, embeddings), path-keyed, content-hash aware. - Vault: Obsidian iOS keeps a copy in iCloud Drive (files may be non-materialized placeholders).
- Network: homelab reachable via Tailscale from all personal devices.
4. Functional requirements
FR-1x — API service (server side)
- FR-10 — New
api_server.py(FastAPI) exposing the v2 contract over HTTP:POST /search(mirrorssearch_vaultparams exactly),GET /note?target=,GET /stats,GET /schema(the FR-2x schema block as JSON),GET /health(Ollama reachability + index freshness).- FR-11 — The service imports the same
common.pyquery core as the MCP server. No retrieval logic in the transport layer; a behavior difference between MCP and HTTP results is a bug.- FR-12 —
--scopeand--instructionswork identically to the MCP server; a scoped API instance obeys v2 NFR-5 (no leakage via results, stats, schema, or errors).- FR-13 — Binds to the Tailscale interface (or localhost) by default; refuses to start bound to
0.0.0.0without an explicit--i-know-what-im-doingstyle flag.- FR-14 — Responses are JSON envelopes with a stable shape:
{results: [...], warnings: [...], degraded: bool}—degraded: true+ warning when Ollama is down and the server fell back to filters-only (v2 NFR-3), so the client can tell the user why ranking is absent.- FR-15 —
register.pylearns nothing new; the API server is systemd/launchd-managed on the homelab host, documented in the README.
FR-2x — Search experience (app)
- FR-20 — Single search screen: free-text query field plus filter chips (folder, tags, modified range) populated from
GET /schema— the app’s filter UI is generated from the live schema, mirroring how the schema block teaches Claude.- FR-21 — Results render wikilink title, folder, tags, modified date, score (when semantic), and snippet — the v2 Result payload, nothing bespoke.
- FR-22 — Query + filters are one request; filters-only (empty query) is valid and hits the FR-12 fast path.
- FR-23 — Tapping a result fetches
GET /noteand renders markdown (read-only), including wikilink display (as styled text; navigation to a linked note is a stretch goal, not required).- FR-24 — Recent searches and result payloads are cached locally (see FR-3x) so a re-run of a recent query works offline.
- FR-25 — Share sheet on any note/snippet (export text to Claude app, Messages, etc.). This is the NG4-compatible “ask an LLM about it” path.
FR-3x — Offline mode (app)
- FR-30 — The app maintains a local copy of the index:
files+tagstables (not embeddings) pulled from the API (GET /index-delta?since=) or synced as a whole file in v1.0 if delta sync slips (R-3).- FR-31 — Offline search = filters-only against local SQLite, same semantics as server FR-12 (AND tags, folder prefix, content_mtime dates, modified-desc order). Feature parity is testable: identical fixture, identical results.
- FR-32 — Offline mode is visibly labeled (“filters only — server unreachable”), never silent. Semantic query attempts offline get a clear “needs server” state, not a spinner.
- FR-33 — Notes read while online are cached (LRU, size-capped, default 50 MB) and readable offline with a “cached copy, may be stale” marker and cache date.
- FR-34 — (iOS, platform channel) Optional direct vault access: user picks the Obsidian vault folder once (security-scoped bookmark); note reads prefer the live iCloud file over the cache, with download-on-demand materialization and a timeout fallback to cache. This is the one Swift component; it ships behind a feature flag and the app is fully functional without it.
FR-4x — Configuration & multi-instance (app)
- FR-40 — Server profiles: name + base URL (+ optional scope description shown in UI). Multiple profiles supported, mirroring multi-vault/scoped MCP instances (e.g.
workscoped,personalunscoped).- FR-41 — Per-profile schema is fetched and cached; switching profiles switches filter chips, cached index, and note cache namespaces. No cross-profile bleed (the app-side analog of NFR-5).
- FR-42 — A profile can be marked “cellular-allowed” or “Wi-Fi/VPN only”; default is VPN-only, matching the private-by-construction stance.
5. Non-functional requirements
Non-functional requirements
- NFR-1 Latency: online search round-trip p95 < 2.5 s on Tailscale (server budget from v2 NFR-1 + transport); offline filters-only < 200 ms.
- NFR-2 Footprint: app + synced index + default cache < 150 MB for a 5k-file vault (embeddings never sync to the phone in v1).
- NFR-3 Robustness: every server-dependent screen has a defined offline/degraded state; no dead-end spinners. Airplane mode is a first-class test scenario, not an error path.
- NFR-4 Portability: Flutter stable channel, no packages requiring forks; iOS-specific code confined to the FR-34 platform channel. Android must build and pass tests even if unpolished in v1.
- NFR-5 Privacy: no third-party analytics/crash SDKs; no data leaves the device except requests to the configured profile URLs. Cache encrypted at rest via platform storage protections (iOS Data Protection default class is sufficient — no custom crypto).
- NFR-6 Testability: retrieval-adjacent Dart code (query building, offline search, cache, delta merge) is pure and unit-tested without a device; API service tests run without Ollama, per house style.
6. Interface specification
POST /search body: {query?, top_k?, folder?, tags?, modified_after?, modified_before?}
→ {results: [Result], warnings: [str], degraded: bool}
GET /note ?target=<wikilink-or-path> → {path, content, modified}
GET /stats → v2 stats + scope, as JSON
GET /schema → {folders: [...], tags: [{tag, count}], file_count, date_range}
GET /health → {ok, ollama, index_age_seconds}
GET /index-delta ?since=<indexed_at> → {upserts: [...], deletes: [...]} (v1.1, see R-3)
Result = v2 Result unchanged:
{wikilink, score: float|null, folder, tags: [str], modified, snippet}
App storage:
| Store | Contents | Notes |
|---|---|---|
local.db (drift/sqflite) | files, tags mirror + profile metadata | per-profile namespace |
| note cache | full note bodies | LRU, 50 MB default, per-profile |
| secure prefs | profile URLs | platform keychain/keystore |
7. Implementation phases
Waterfall-friendly; each phase is releasable (TestFlight from Phase 2).
- Phase 1 — API service: FastAPI wrapper over
common.py, scope support, degraded-mode envelope, systemd unit + README. Exit:curlfulfills AC-1 end-to-end. - Phase 2 — Online app: profiles, search screen with schema-driven chips, results, note view, share sheet (FR-2x, FR-40–42). Exit: daily-usable on TestFlight over Tailscale.
- Phase 3 — Offline: index mirror (whole-file sync), offline filters-only search, note cache with staleness markers (FR-30–33). Exit: airplane-mode walkthrough passes.
- Phase 4 — Vault access (iOS): FR-34 platform channel behind a feature flag; delta sync (
/index-delta) if not landed in Phase 3. - Phase 5 — Polish & Android pass: Android smoke QA, docs (README: architecture, hook-vs-MCP-vs-API table), CHANGELOG.
8. Testing requirements
Testing requirements
- T-1 API parity: same fixture DB → MCP tool output and HTTP output agree field-for-field (transport-only guarantee, FR-11).
- T-2 Scope enforcement over HTTP: search/note/stats/schema/errors leak nothing outside scope (v2 T-7 rerun against the API).
- T-3 Degraded envelope: stubbed-dead Ollama →
degraded: true, filters-only results, warning present.- T-4 Bind-address guard: default start refuses non-loopback/non-Tailscale binding.
- T-5 Offline parity: fixture index → Dart filters-only results identical to server FR-12 results (golden-file test shared between suites).
- T-6 Cache behavior: LRU eviction at cap, staleness markers, per-profile isolation (no cross-profile reads even with identical paths).
- T-7 Schema-driven UI: chips regenerate on profile switch; unknown/extra schema fields are ignored, not fatal.
- T-8 Airplane-mode walkthrough (manual, scripted steps): recent query re-run, cached note open, semantic query shows “needs server”, nothing spins forever.
- T-9 FR-34 channel (device-only): folder re-pick after bookmark invalidation, non-materialized iCloud file triggers download, timeout falls back to cache.
9. Acceptance criteria
Acceptance criteria
- AC-1. From a phone on cellular (Tailscale up): “kubernetes upgrade rollback, homelab, last 3 months” → ranked results with snippets in one round trip; tap opens the full note.
- AC-2. Airplane mode: the same filters (no query) still return correct results from the local mirror, clearly labeled offline.
- AC-3. A profile pointed at a
--scope 01-Projects/**API instance can never display, cache, or suggest (via chips/schema) anything outside the scope.- AC-4. Kill the API server mid-session: the app degrades to offline mode without a crash or dead spinner.
- AC-5.
flutter testand the API test suite pass on a machine with neither Ollama nor a device attached.
10. Risks and open questions
Risks and open questions
- R-1 iCloud vault access (FR-34) is the fiddliest component (bookmarks invalidate, materialization stalls). Mitigation: feature-flagged, cache-first fallback, shipped last. The app must be complete without it.
- R-2 Tailscale-on-cellular adds variable latency; if p95 blows the NFR-1 budget, snippet size (server
SECOND_BRAIN_RESULT_BUDGET) is the first knob, not app-side pagination.- R-3 Delta sync (
/index-delta) has real edge cases (moves, deletes,indexed_atclock skew). v1.0 ships whole-file index sync (a 5k-file mirror is small); delta is a v1.1 optimization with T-5 as its regression net.- R-4 Flutter markdown renderers vary in wikilink/callout fidelity. v1 renders wikilinks as styled text only; full link navigation depends on renderer choice (spike in Phase 2).
- Q-1 Should
/searchaccept aninstructions-style per-request hint? Deferred — profiles + server--instructionscover the known cases.- Q-2 Auth beyond Tailscale (shared token) for a future non-Tailscale deployment? Out of scope for v1; NG3 stands until a real need appears.
- Q-3 Tier 3 (on-device embeddings via llama.cpp FFI or ONNX): revisit only after v1 usage shows semantic-offline is actually missed. The FR-30 mirror deliberately excludes embedding vectors so adding them later is additive, not a migration.