CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# HOLLOW: Project Instructions for Claude Code
## What Is This
Distributed, encrypted Discord alternative: no central servers, members host it. `HOLLOW_PLAN.md` = architecture, phases, TODO.
## Tech Stack
- **UI:** Flutter (Dart), 6 platforms
- **Backend:** Rust via `flutter_rust_bridge` v2.11.1
- **Networking:** WSS relay (signaling, text/CRDT/MLS) + WebRTC data channels (files) + WebRTC media.
- **E2EE:** vodozemac (Olm) DMs, OpenMLS 0.9 servers, SFrame (AES-128-GCM) media
- **Local DB:** SQLCipher
- **Identity:** Ed25519 via BIP-39 mnemonic
- **Org ID:** com.anonlisten · **Project:** hollow
## Project Structure
- `lib/`: `main.dart` entry; `src/core/` models+providers+services, `src/theme/`, `src/ui/`.
- `rust/hollow_core/src/`: `api/` FFI (frb scans these); `node/` swarm.rs + focused modules (test_harness cfg(test)); `crypto/` Olm+MLS (store.rs = CryptoStore); `identity/`; `storage/` SQLCipher.
- `reports/`: `planned/`|`shipped/`|`reference/` per area; index `reports/README.md`.
- `relay-uws/` production relay (native TLS). `packages/flutter_webrtc/` FORK 1.5.2, libwebrtc m144 = OUR patched build (Win/Linux; Apple = stock WebRTC-SDK pod), `screen_audio_capturer.exe` = SEPARATE target. `legal/` policy+terms.
## Build & Run Commands
```bash
flutter run -d windows
flutter build windows
# build_hooks error? scripts/flutter_win.ps1 <args>
flutter test test/
cd rust/hollow_core && cargo check # + clippy
cargo test --lib test_harness -- --nocapture
cargo nextest run --lib
# FRB codegen after Rust API changes
flutter_rust_bridge_codegen generate --rust-input "crate::api" --rust-root "rust/hollow_core" --dart-output "lib/src/rust"
# Relay deploy: scp src/*.cpp *.h *.c -> ~/relay-uws/src/ + CMakeLists.txt -> ~/relay-uws/, cmake+make, `sudo install` -> /usr/local/bin, restart; NO setcap (`feedback_relay_rules`)
# Relay tests (on the VPS, before every deploy): bash relay-uws/test/run_tests.sh, then SANITIZE=1 (ASan+UBSan)
# Windows release (build->sign->installer->zip; Certum PIN prompt)
pwsh scripts\build_release.ps1 # full pipeline (-SkipBuild to repackage)
pwsh scripts\sign_release.ps1 # sign every .exe/.dll in Release
```
**Releases: load the `release` skill FIRST** (incl. the go-live gate; `reference_release_pipeline`). **Certum signing:** CNG binding self-heals; output `installer\Output\` (`reference_certum_signing_procedure`). **Linux release = BOTH artifacts uploaded + `scripts/publish_flatpak_repo.sh` (VM, own GPG-signed OSTree repo, NEVER Flathub) BEFORE the LAST step `scripts\sign_manifest.ps1`** (key OUTSIDE repo, `project_update_integrity`, `project_linux_auto_update`). **Zips: .NET '/' zipping, NEVER Compress-Archive** (`feedback_compress_archive_backslash_zip`).
## Area Rule Books (READ the matching one BEFORE working in that area)
This file keeps the rules that break things silently ANYWHERE (security, crypto, multi-device, data integrity). Area traps live in wiki rule books, `tools/hollow-memory/wiki/`. **Workflow, every task: first name the areas it touches, read those books whole (plus the search workflow below for the feature), then do the whole job.** This file alone is NOT the full rule set. A new area-only rule goes into its book, never here:
- `rules_media_calls.md`: calls, voice channels, meetings, WebRTC, audio, screen share, forked flutter_webrtc/libwebrtc, ffmpeg.
- `rules_ui_flutter.md` (+ the `hollow-ui` skill): any widget, dialog, menu, list, shell, theme, scale, provider-driven UI.
- `rules_platforms.md`: Windows/Linux/macOS runners, flatpak, updater, restart, single instance, Android build, Skia.
- `rules_mobile_push.md`: mobile UI, local notifications, push taps, UnifiedPush, App Lock.
- `rules_files_assets.md`: file transfer mechanics, storage caps, WebP, avatars/banners/frames, showcase, stickers/GIFs, shop UI.
## Hollow Design System
**Load the `hollow-ui` skill BEFORE widget work:** tokens only, gap ramp, ONE chip/badge, button variant per context. Rules `reports/reference/HOLLOW_DESIGN_LANGUAGE.md`; ratchet `test/design_language_guard_test.dart`.
All UI = Hollow widgets (`src/ui/components/`; icons Lucide, `brand_icons.dart`, `atlas_icons`), no Material: HollowPressable/Button/IconButton/TextField/Dialog (`showHollowDialog()`)/Tooltip/Toast/Toggle. Chat = ONE MessageRow/ChatHeaderBar/ChatComposerRow; names `nameColorFor(MASTER)`.
## Key Architecture Notes
- **Multi-node harness (`node/test_harness.rs`) = PRIMARY testing for distributed logic;** ALWAYS verify ring-1/ring-2 changes there; it does NOT cover media/native/relay C++ or device->master attribution (process-global resolver). `feedback_harness_first_testing`.
- **Node modules:** `swarm.rs` = event-loop dispatcher; domain logic in focused modules as `handle_*()`; types in `types.rs`. No SwarmContext; pass state vars individually (`feedback_swarmcontext_borrow`). New envelope variants -> the owner's `handle_envelope_*()` (`feedback_envelope_dispatch_pattern`).
- **Persistence actors:** `CrdtStore` + `CryptoStore` own long-lived SQLCipher conns in spawn_blocking threads, fire-and-forget mpsc (batched per server). node/ reaches the message store ONLY via `MessageStore::lease` (warm pooled conns, `storage/store_pool.rs`; 3 idle, 2 on phones, 2 MB cache), NEVER `MessageStore::open()` there (floor `store_open_floor.rs` + harness guard). `feedback_sqlcipher_open_hygiene`. An event Dart answers by re-reading CRDT state waits for the commit (`reads_persisted_server_state`, else the UI lags one change).
- **Peer state in swarm.rs:** `ws_room_peers` + `synced_peers`; PeerJoined -> key exchange + sync; 30s keepalive in ws_client.rs.
- **Relay domain:** `relayDomainProvider` + `set_relay_url()`, default `relay.anonlisten.com`; ALL WS/signaling/STUN/TURN URLs derive from it.
- **CRITICAL: Dart traps that fail SILENTLY**; read before debugging layout or a stuck Future. `feedback_dart_patterns`, `feedback_badge_stack_clips_avatar_frame`.
- **Logging:** `hollow_log!` -> stderr + `hollow_debug.log`; `hollow_crash.log` = Flutter errors. **NEVER `eprintln!`/`println!` in hollow_core:** a dead stderr (closed terminal) PANICS, killing the node while the window lives. `feedback_linux_stderr_epipe_panic`.
- **CRITICAL: user-visible text carries ZERO em dashes** (UI, docs, legal, Rust errors, website); comments/logs exempt. NEVER a colon in its place. Settings labels = SENTENCE case; Title Case only for section/doc titles, permission names, proper nouns. **Every body of user-facing copy (changelog, news, store, website, help, legal) goes through the `sepia` skill BEFORE Vitalik sees it; changelog = ONE plain sentence per change.** `feedback_vitalik_writing_voice`.
- **Storage & profiles (#47):** data root `dirs::data_dir()/hollow` (override `set_data_dir()`/`HOLLOW_DATA_DIR`); desktop precedence env > `--portable` > profiles.json PIN > marker > `hollow_data` WITH identity (NEVER `data`); keychain = PER-PROFILE slots. `project_portable_mode`, `project_issue47_sframe_keystore_fix`.
- **CRITICAL: identity at-rest protection:** password (blocks launch) or OS keychain (silent). Keychain = DEFAULT on Win/mac OS data root only, after a typed phrase check (new) or the FULL phrase (existing), verified first; NEVER silent, never Linux/portable/override; a missing key deletes nothing; the legacy slot is read-only. `unlock_identity()` before any identity/DB op. `project_identity_protection`.
- **Deep links:** `hollow://` on all 6 platforms via `DeepLinkService` (init pre-runApp). Invites COPY `webServerInviteLink()` (FRAGMENT url, id never hits server logs); ALL link input bars parse via `inviteIdFromInput`/`classifyHollowLink`. `project_deep_linking`.
- **Self-hosting (`relay-uws/SELF_HOSTING.md` = THE guide, ONE `.env`):** TURN URIs from `--domain`; invites stamp the SENDER's relay (`required relay:` on every builder); a differing relay = ONE dialog `relay_switch_dialog.dart`, NEVER auto-switch; `relayStatusProvider` null = unknown. `project_self_hosting_overhaul_2026_09`.
- **Relay access keys:** SELF-HOSTED opt-in only (`keys.json`, app caches `set_license_key()`); the official relay keeps them OFF, open to all, no alpha/limited-access copy. `project_official_relay_open`.
## Relay
- **Relay:** uWebSockets C++ (`relay-uws/`). NO byte quotas or per-message limits: the breaker (`breaker.h`, SHADOW in 0.13) charges work units, closes 1013, never drops a frame; handlers charge via its write/verify hooks, tables byte-bounded on `fair_share.h`; clients mirror it (`relay_pace.rs` + `breaker_vectors.json`, change both), waiting only when `enforced`. `send_budget.h` bounds HELD bytes, closing via `close_once` (1013, never 1008). NEVER lower `maxPayloadLength` (64MB). Topics `0x07`, broadcast `0x03`. EVERY binary handler gates on SENDER room membership + validates the peer-id shape (`is_peer_id_shape`); pre-auth frames ONLY via `parse_auth_frame` (a throw into uSockets kills the process); client JSON ONLY via `client_json::parse` (depth 32; a recursive dump/copy of client JSON overflows the stack, HOL-SEC-128); no limits before phase G; per-IP RAM = connection caps only via `ip_limit_key()` (v6 /64, unmap v4-MAPPED first). `feedback_relay_rules`, `project_relay_fairshare_turn_lock`.
- **CRITICAL: the relay box: every service runs as its OWN account (never `ubuntu`/sudo) in a systemd sandbox; the repo units ARE the box's units; secrets ONLY in root-only `EnvironmentFile`/`LoadCredential` (`Environment=` is readable by every account via `systemctl show`); nothing to disk (rsyslog drops `hollow-*`, `ufw logging off`); a denied syscall = EPERM, never kill (skips the snapshot). After any box change `sudo bash relay-uws/deploy/check-host.sh` prints only ok.** `project_relay_hardening_h`.
- **CRITICAL: every relay table a stranger can fill is BOUNDED and FAIR (`fair_share.h`):** each entry is charged to the writer's hashed address share (`socket_share`: v4 or v6 /48, hourly key, never persisted); a full table evicts the HEAVIEST share, never by age or identity (identities are free); byte budgets, nothing refuses. A new RAM registry joins it. `project_relay_fair_share`.
- **CRITICAL: relay offline delivery (availability cache):** anything for OFFLINE channel members MUST ride `0x07` topic frames (`0x03`/directs never enter rings); parked joins = the `~join` ring (park 3 s if the room is EMPTY, else 15 s), holding only sealed join boxes. Catch-up gates stamp ONLY once `ws_room_peers` holds the room; public-channel ops send BOTH 0x03 and the topic. `project_relay_availability_cache`, `project_pending_server_joins`, `feedback_once_per_connection_gate_precondition`.
- **CRITICAL: relay restart persistence:** buffers/opt-ins/rings/push tokens/join locks ride systemd's fd store as a memfd on SIGTERM (`snapshot.cpp`; NEVER a file or disk; no swap, no apport, `LimitCORE=0`); snapshot BEFORE `app.close()`; any new RAM registry an OFFLINE peer cannot re-send joins `snapshot_codec.h` (bump VERSION). `project_relay_restart_persistence`.
- **CRITICAL: relay auth v2 + authority (A-D4):** clients log in by `auth_hello` -> nonce -> a signature over the relay's DOMAIN, the nonce and every flag (`auth_frame.h` = `ws_client::auth_v2_message`, pinned); stamps the RELAY judges (login, ring control, nickname claims) use `relay_clock` time, anything a PEER judges (message ts, HLC, seals, roster, kill orders) NEVER does (a relay could move message times); fetch sockets never take a full socket's slot and are never listed; an `inbox:` room is owned ONLY by devices the relay's fold of that identity's rosters counts as members (ID-1R: joins carry `inbox_roster`, a removal or newer recovery shown once sticks, demotion at once; `relay-uws/src/roster.h` mirrors `identity/roster.rs`, change both and regenerate `roster_vectors.json`; mailbox deposits reach owners live); ring control is signed by the join lock's CHANGE key (`ring_auth`), a legacy server's ring topics are `{owner}.{channel}` (`ring_auth::topic`, every send/subscribe/catch-up); nickname claims are master-signed and a lookup sends nothing until the person confirms. Ring control signs `hollow-ring2` naming the relay's domain; `HOLLOW_ACCEPT_RING1_CONTROL` (0.12.1 clients) stays ON until they are gone (`test/run_live.sh` builds it both ways). The four 0.11 switches are DELETED (0.13): v1 auth does not parse, unsigned ring control only refreshes, unsigned claims and `inbox_proof` prove nothing (live refusal tests). `project_relay_a_d4`, `project_id1r_relay_rosters`.
- **CRITICAL: relay topic subscriptions are PER-SOCKET:** ws_client replays them on every reconnect; desktop's subscribe-listener microtask-defers past the selection batch (channel is written BEFORE server). `feedback_channel_topic_subscriptions`.
- **CRITICAL: WS sends: bounded + never silently discarded.** ALL ws_client.rs sink writes go via `bounded_send` (30s timeout): a raw `write.send()` on a wedged socket freezes the select loop incl. the watchdog. `send_command()` false -> break, buffer, reconnect. `feedback_ws_zombie_liveness_timeout`.
- **CRITICAL: `RoomMembers` = authoritative presence:** diff sets -> `PeerDisconnected` for vanished; `PeerLeft` refreshes suspect rooms; OUR OWN leave purges the room (`WsEvent::LeftRoom`); a stale self-left room silently blackholes targeted sends. `feedback_ws_presence_stale_rooms`.
- **CRITICAL: resumable relay sessions count frames on BOTH ends (`RESUMABLE_SESSIONS_PLAN.md` §9, §11):** every relay-to-client stream frame goes through `send_stream`/`send_held` (ringed BEFORE the write), presence and kill signals never; ring mutations only via `session_bounds.h`, never `Ring` directly, never `ring_push` while iterating a ring; a new JSON type is classified in `session_vectors.json` + `relay_type_counts`/`client_type_counts` AND `relay_session.rs` in one change. One unmatched frame silently skips or repeats frames on every resume. `project_resumable_sessions_plan`.
- **CRITICAL: `WsEvent::SessionLost` purges sync-gating state + remote VC participants (`Suspended` keeps all; resumable sessions, `RESUMABLE_SESSIONS_PLAN.md` §9); `is_new` is NOT "a new socket"**: an unclean drop sends no `PeerLeft`, so reconnect work goes OUTSIDE it. `feedback_is_new_is_not_a_new_socket`.
- **CRITICAL: presence = app ON SCREEN (decision 6, plan §11.5):** an `inactive` session is HIDDEN (`PerSocketData::hidden`): every relay presence list, announcement and online query reads the mark; delivery never changes. A phone in a call/VC/meeting/lobby never goes inactive (`relayRealtimeProvider`); a backgrounded or socket-down device takes NO MLS turn (`crypto_handler::MlsTurn`). Bulk sends to one device go through `frame_budget` pacing, and a bucket drop is re-asked, never final. `project_resumable_sessions_plan`.
## What the relay may see (design A, 0.12)
- **CRITICAL: every relay frame is DEVICE-SEALED (`node/frame_auth.rs`):** one sealing stage before the relay; receivers `open` against `from`'s key BEFORE reading (room + route bound, own-device echo refused); a new `HavenMessage`/`MessageEnvelope` variant MUST pick a class in `live_only()`; late frames are judged by the seal time, which strictly rises per process (`seal_stamp_ms`, <= 500 ms ahead; carried `at_ms` too). `project_frame_seal_design_a`.
- **CRITICAL: the LANE rule: `HavenMessage::lane()` (exhaustive Relay | Carried | CallSignal | Join | Share | Recovery | Meeting) decides what the relay may read.** A new variant picks `Relay` ONLY if the relay may read it (routing metadata, never data); `Carried` rides `MessageEnvelope::Carried` in Olm via `olm_lane::carry*()` (a plaintext copy is dropped before any handler; carry WAITS so wire order = program order); server traffic rides MLS + the Olm copy to leafless devices. Test with the `c24_*` wiretap. DM rooms = HMAC of the masters' X25519 (`node/dm_room.rs`), never a hash of public ids. `project_frame_seal_design_a`.
- **CRITICAL: profile AUDIENCE (`social::profile_audience`):** Full to own devices/friends/co-members, a master-signed CARD (name + avatar, `hollow-card1`) to pending friends, admitted meeting peers and the members a joiner asks, NOTHING to strangers or revoked devices; every field signed (`hollow-profile2`). Announces stay LIGHT: hashes only, blobs ride ONLY the `ProfileRequest` answer + `handle_update_profile`; sweeps per-conversation, ONE source, throttled. Co-members meet as strangers while a join is pending: on the server Welcome the joiner asks each room device it cannot place (`after_welcome_joined`). `project_profiles_phase_d`, `feedback_profile_light_announce_bandwidth_leak`.
- **CRITICAL: server joins = the JOIN LOCK (`node/join_lock.rs`, `lock_keeper.rs`, relay `join_lock.h`):** requests sealed to the relay's newest door (all members) AND the invite's static `key=`; a joiner seals only to a chain IT verified from the owner; every answer is sealed FROM the member's newest door and counts only against a lock read asked AFTER it arrived (never a time window); kick/ban/demotion moves the lock at once, a leave when a mod+ is next online; a refusal NEVER ends a join. The relay deploys BEFORE clients (self-hosted too). `MemberAdded` carries the joiner's OWN master-signed `JoinAsk` (re-signed with every nonce), refused without it, and each ask admits once (`MemberSpan.asked_at`, HOL-SEC-092). Every ask passes `join_hold` first: a member back from away judges only after the ring's `topic_catchup_done` mark and a member's nonce-echoed sync (max 10 s), and answers NOTHING while the relay hides it (HOL-SEC-121). `project_join_lock`.
- **CRITICAL: door-proof server rooms (D1, relay `door_room.h`, `node/door_room.rs`):** where a server's lock is on the relay, only a socket proving its newest door (HMAC under X25519 of the door and the relay's `door_key`, bound to the auth nonce) sees roster, presence, broadcasts and rings; directs still reach hidden peers. Public-channel sends = `SendPublic` (0x0A); a send to a peer we cannot SEE uses `send_room_for_peer` (heard routes), never `ws_room_for_peer`; a lock move keeps provers 60 s, a stale member asks (`DoorAsk`); 0x09 only from provers. A `fwd:{X}` room is PAIRWISE: members know and reach only X (`fwd_room.h`, `Audience::shares`, HOL-SEC-126). `project_door_rooms_d1`.
## Crypto, identity, authority
- **CRITICAL: sig verification REJECTS, never logs;** `if sig.is_some()` IS the bypass. A body over 64 KiB (`MAX_MESSAGE_BYTES`) drops whole, NEVER clipped. LIVE enforces, backfill tolerates. `feedback_signature_enforcement_not_logging`.
- **CRITICAL: EVERY remote-reachable store write sits behind a gate (wiki `security_write_gates.md`; a row per new ingest path). A signature proves WHO, never MAY: every remote change (sync or live) touches an EXISTING row only via `change_may_touch_row`.** `project_close_all_known_holes_085`.
- **CRITICAL: message signing = `sign_message_versioned`, v2 or v3 iff `album` present (the payload picks; non-UUID album REJECTS); v1 GONE, NEVER a fallback (downgrade oracle). Every ':'-joined field before the text is shape-checked by signer AND verifier (`SignedExtras::well_formed`), and any new ':'/','-joined signed list needs the same (HOL-SEC-143, -154).** Edit/delete sigs bind the row's FULL extras. New message-row paths MUST persist the SENDER's wire `order_us` + `album`; mint via `chat_clock::next_send_stamp_us()` BEFORE any async hop. `feedback_chat_clock_lamport`.
- **CRITICAL: Olm key exchange is DEVICE-signed + ENFORCED** (`REQUIRE_SIGNED_KEY_EXCHANGE`): verify sig/recipient/freshness/device-in-signed-list BEFORE session creation; glare keeps the LOWER device's session, replaced ones RETIRE (decrypt-only); a decrypt failure sealed at or before that device's saved read mark (`olm_read_marks`, moved by node AND push fetch) drops quietly, never re-keys (AR-19). Relay auth re-derives peer_id from pubkey; KATs in BOTH Rust+C++. Olm frames ONLY via `crypto_handler::olm_frame`: a frame leaves after its session save commits, pickles are max-wins (`sends`/`reads`), else a crash reuses a message key (AR-30). `project_signed_key_exchange_root_of_trust`, `project_olm_glare_session_crossing`.
- **CRITICAL: CRDT ops are Ed25519-SIGNED (`CrdtOp.auth`, MASTER key via `set_signer`), unsigned REJECTED; remote ops enter ONLY via `ServerState::ingest_remote` (the design E fold: state = every retained op in HLC order, each judged by `op_allowed` at its point), ours via `author_checked`.** Anchor = a 40-hex id hashing the founder (no snapshot ever) or the owner's `ServerCheckpoint` (+ `owner=` invite pin); owner FIXED; anchored logs never count-capped; a member's join SNAPSHOT keeps only the owner's role (no roles, bans, mutes, grants, public flags; HOL-SEC-093). Persist exactly `Ingested.admitted` (`persist_admitted_ops`), never by op_log length. HLC actor = master, each device mints in its OWN counter lane (`Hlc::for_device`; `Hlc::new` is test-only). Catch-up = salted range digest (`crdt/digest.rs`, `op_sync`); the state vector serves 0.12.2 peers only (gone in 0.14). `project_crdt_fold_design_e`, `feedback_crdt_sync_persistence`.
- **CRITICAL: CRDT sync batches parse tolerantly:** always `parse_ops_tolerant`, never `from_str::<Vec<CrdtOp>>`: one unknown NEWER variant wedges old clients' sync. An op this build cannot read WHOLE (unknown variant, or a dropped newer field that fails the re-serialized sig) is UNREAD via `operations::read_op`, never a forgery: member-signed ones are KEPT (`crdt_resync`, bounded, fair per author) and folded in by the next build at start; a release sweep or a lost op marks a server for a paged whole-log fetch, cleared by a non-partial responder or TWO identities. `op_log` stays in FOLD order (`restore_op_log`). Wiki `security_write_gates` §40, `project_custom_emotes_ffz`.
- **CRITICAL: new `CrdtPayload` variants emit `ServerUpdated` in BOTH match blocks** (`handle_envelope_crdt_op` + `handle_incoming_request`); never fall into `_ =>` (emits `SyncCompleted`, no provider invalidation).
- **Roles & permissions:** `AdminLwwReg::merge` = pure HLC LWW (authority ONLY in op_allowed/author gates); Dart reads `default_role_permissions()` FFI, never a mirror (`project_roles_design`). **`live_channel_post_refusal`** (member, see, post, trio) at send + EVERY live transport + push, never backfill; mute gates EDIT + ADD-REACTION (`project_moderation_trio`).
- **CRITICAL: channel access (#32):** Dart NEVER re-implements the access ladder; read `ChannelFfi.me_can_see/me_can_post`. ACCESS labels never self-assignable; label gates pair an Admin+ tier stamp; grants = mute-pattern LWW, lazy expiry. `project_channel_access_labels_grants`. **EVERY path that SERVES or REPLICATES stored channel content** (sync responders + MLS/Olm twins, `FileRequest` channel arm, full replication, fallback holder) asks `channel_readable_by` FIRST (= membership-or-public AND `can_see_channel`); the subgroup protects LIVE traffic only. `project_restricted_channel_backfill_gate`.
- **CRITICAL: contact verification:** safety number = 60 digits from the two MASTER peer_ids (symmetric, survives reinstall + device change), NEVER Olm keys/device ids; verified flag + alerts MASTER-keyed. New device = warn, FIRST list = baseline. `project_contact_verification_safety_numbers`.
- **CRITICAL: blocking & Saved messages:** block list = MASTER-keyed, process-global (`node/blocklist.rs`), warmed with links + revocations by `resolver::warm_from_store` in EVERY process (main, push fetch, NSE): DM-surface handlers drop via `is_blocked(sender)` BEFORE store+emit. Saved messages = self-DM. `project_saved_messages_block_report`.
- **CRITICAL: remote filenames via `safe_file_name()`** before any path join (an ABSOLUTE name makes `Path::join` DISCARD the base; manifest hash ≠ trust); zip entries ONLY via `enclosed_name()` + an allowlist + a size budget (link snapshots, backups and archives come from OTHER people, HOL-SEC-129); wire ids allowlisted in `parse_id`/`parseWireTransferId` (they NAME temp files). `feedback_sender_controlled_filename_sanitization`.
- **CRITICAL: duress + destroy (`identity/duress.rs`, `node/destroy.rs`, `api/wipe.rs`):** a typed secret ALWAYS derives BOTH slots (dummy `identity.duress` always present); duress `Err("duress")` only AFTER the wipe, Dart shows NOTHING. ONE wipe routine, no network wait; every key unlink zeroes first (a held key keeps the marker), and kept old data (`<root>-old-*` with this root's origin mark, recovery screen) is erased with the identity. `DestroyIdentity` signed by the recovery PHRASE (or a member device's phrase-signed permission for itself; the master alone only before the first recovery), judged by `judge_own_order` (sig, own master, targets, link time, RAM-only applied stamp); kill list = courier, one slot per issuer, plus a PROVEN slot junk never evicts for an order the held roster's pinned phrase signs (relay `kill_order.h` mirrors `crypto_handler`, `kill_vectors.json`, change both); `KillAck` bare ONLY after a wipe, else issuer + stamp (full AND fetch node). App Lock = cover route + `appLockedProvider`, unlock secret in the OS keystore (`hollow_app_lock_launch_secret`), the ONLY prompt; "Ask before Hollow starts" = advanced opt-in (duress local-only). Locked: no cards/toasts, EVERY raw OverlayEntry host registers with `OverlayHosts`, NOTHING routes above the cover. `project_at_rest_file_encryption_plan`, wiki `security_write_gates` §13.
- **CRITICAL: release feeds are signed (0.12.2):** status.json/news.json only via `fetch_signed_feed` (domain-separated from the manifest; unsigned = nothing shown; `issued` never backwards); manifest `version`/`latest` stay three-part FOREVER (else <=0.12.1 refuses the whole manifest), patches ride `build` + `APP_BUILD`, the full release entry stays FIRST. Wiki `security_write_gates` §45, `project_update_integrity`.
- **Disclosure:** 6 GHSA drafts still private. **Never push a fix before release.** `project_security_disclosure_2026_07`.
## MLS
- **CRITICAL: MLS leaves are BOUND (`node/mls_authority.rs`):** signer = the DEVICE key, credential `hl1:{device}:{master}:{sig}` (`classify_leaf`); leaf ids = devices, but membership/ban/subgroup decisions use the CERTIFIED master (`group_leaves`), never the resolver. Unbound leaves are ignored; ours never encrypts or commits (rebind on the batch tick). Commits + Welcomes STAGED and JUDGED before merge; a current member leaves only WITH its re-add in one commit; while we hold a leaf, KeyPackages only for `may_repair_our_leaf`. An add/Welcome naming a device our roster does not place YET is HELD (60 s, re-judged per tick), never refused (a refusal spends the commit key = permanent fork); removed/revoked/master/meeting leaves refused; a catch-up commit failing SecretReuse (spent unmerged) earns ONE repair from our own responder (wiki §37). `project_mls_leaf_binding`, `feedback_mls_sibling_identity_collision`.
- **CRITICAL: MLS epoch staleness:** sync requests + the CRDT fallback ride Olm (`Carried`) on MLS failure; stale-PRESENT groups heal via commit catch-up; NOTHING that fails drops a group (probe with the `epoch_auth` digest, forks get repaired); frames only at own+1; commits ONLY via `broadcast_mls_commit`. `project_mls_epoch_catchup`, `feedback_mls_patterns` (all 8).
- **CRITICAL: KeyPackages mint ONLY via `mint_key_package()`** (generate + persist; CI-guarded by a source scan, else a restart orphans the private half and every Welcome fails). An eviction commit while still a member = a repair: drop the group, stamp the throttle, arm `mls_welcome_grace` (6 s), NEVER re-ask on it (third KeyPackage = epoch treadmill). Join-time KeyPackage targets `server_bootstrap_target`, stamps only when it reached a device. `feedback_mls_patterns`.
- **CRITICAL: MLS saves are row deltas (`mls_storage`) and an MLS frame leaves only once its save lands (`AfterMlsSave`/`MlsSaveAgain`); OpenMLS keeps NO past epochs: late frames open in `mls_past` copies, so ANY new merge path goes through `merge_pending_commit` or `PastEpochs::capture`+`keep`. Self-update turns + the TALLY epoch cap (`mls_turns`/`mls_rate`/`mls_tally`): the verdict reads only the pre-commit tree + commit; fitness only a master's OWN commit-written history.** wiki `rust_crypto`, `security_write_gates` §48.
- **CRITICAL: MLS stand-in authority = STANDING (`node/standing.rs`):** owner away, every leader (committer, join answerer, catch-up responder, repairer, fork winner, subgroup/vault coordinator) = the first ONLINE leaf holder by role tier, fold position (`MemberSpan.admitted`), id; NEVER the lowest id. Gate per group (all leaves declare the tally, anchor not Legacy), else the exact 0.12.2 rule. Ratchet: an all-0.13 tree admits no tally-less leaf but the anchored owner's. Leafless askers WALK by standing (`walk_leaf_ask`, `MlsKeyPackageAck`); a leafless device answers a KeyPackage request only at/above its walk target (H1b); a repair = the coordinator alone, the asker forwards and names it (`via` = owner or a seated unbanned member, for the package answered). Owner preferred as COMMITTER only; catch-up excludes the peer BEHIND; no per-device commit loops; Olm twin via `broadcast_crdt_op_to_members`. `feedback_owner_coordinator_mls_recovery`, wiki `security_write_gates` §51.
- **CRITICAL: ONE device per identity commits in a group (`we_commit_for_our_identity`: lowest online device of ours holding a leaf there) and ONE founds a group none of ours holds (`may_found_group`, after its online siblings asked; an owner device asks its OWN siblings for its leaf, `send_join_leaf_ask`)**: batch, in-place rebind (after the bootstrap timeout a deferred device rebinds itself), sibling re-add; KeyPackages go to EVERY device of the target identity (`send_key_package_to_identity_of`). Of two forks the authority's lowest online device's stays (presence), probes carry the digest (`epoch_auth_digest_at` covers past epochs). `feedback_mls_patterns`.
- **CRITICAL: server-group MLS recovery:** every CRDT-mutating handler ALSO sends the op as a carried `CrdtOpBroadcast` twin (Olm) UNCONDITIONALLY (an MLS-only op drops at a skewed epoch; `if !mls_sent` measures the WRONG end), fan-out TARGETS computed BEFORE the op mutates state (a delete drains `members`). Encrypted content + ephemeral signals (typing, profile, vault, file headers) = MLS PLUS the Olm copy to `leafless_member_devices()` only. `feedback_mls_first_fallback_dead_targets`.
- **CRITICAL: per-channel MLS subgroups:** restricted channels encrypt under their OWN group `"{server}#{channel}"` (`reconcile_subgroups_for_server`); `mls_envelope_fits_group` on every receive; voice SFrame from its `export_secret`; VC participants (SELF too) = ROUTABLE DEVICE ids; Dart branches on `is_self`, never id compares. `project_per_channel_mls_subgroups`.
## Multi-device (one MASTER = many DEVICEs)
- **CRITICAL: device-vs-master routing.** UI/lookups collapse device->master (`resolver::resolve`/`identityOf`); SENDS target a DEVICE, fan via `devices_for(master)`. Fresh installs ALWAYS have device≠master. `feedback_multidevice_targeting_sweep`.
- **CRITICAL: multi-device audit fixes:** raw master sends DROP; route via `send_raw_to_identity`/`preferred_online_device`/`friend_device_targets`; LEAVE tears down all 3 apply paths; ingest gates on `ServerState::op_allowed`. `project_multidevice_audit_2026_07`.
- **CRITICAL: targeted sends route to their DETERMINISTIC room** (first-match = silent one-way loss): DMs -> `dm_room_code`, forwarder -> `fwd:{id}`, OUR OWN devices -> `roster_book::own_room` else `inbox:{master}` (`ws_room_for_peer` picks those first; a sibling's first match can be a DM room it just left). `feedback_dm_friend_establishment_bugs_2026_07`.
- **CRITICAL: a FriendAccept lands ONLY on our own pending OUTGOING row** (or re-confirms accepted): no row / incoming / declined / removed = refused (HOL-SEC-036). It carries `requested_at` (older than the row = dropped) + the accepter's roster, into `dm_room_code`; siblings learn via `share_friend_with_siblings`. Request/accept stamps come from `social::readd_stamp()` (seal sequence, past a held tombstone, capped), NEVER the wall clock; `FriendRemove.at` names the friendship it ends (a late copy never ends a re-add); `friend_removals()` never re-shares a tombstone a friendship here outlived (§50). `feedback_friend_readd_online_auto_accept`.
- **CRITICAL: multi-device server sync:** lifecycle ops converge the actor's OWN siblings (`fan_to_own_siblings`) AND offline members. Server DELETE = replicable `ServerDeleted` tombstone (owner-author validated at EVERY ingest). RE-ANNOUNCE all servers on a sibling's return, OUTSIDE `is_new`; create/join announce to EVERY roster device (durable, relay buffer) and a held server refreshes quietly (`ServerCreated`, never `ServerJoined`); a join it starts is `from_sibling` (no ask, no row). Blocks and friends' rosters ride the sibling friend list (own devices only; newest stamp wins, local edits ratchet past the stored one); removal ops reach hidden siblings via our own inbox. `feedback_server_lifecycle_sibling_sync`.
- **CRITICAL: multi-device push:** fetch node auths as its DEVICE (DB passphrase MASTER-derived); sender targets `devices_for(master)` ∩ has-session ∩ not-in-room; tap resolves sender->master. wiki `push_notifications`.
- **CRITICAL: identity authority = the ROSTER (design ID-1, `identity/roster.rs`, `node/roster_book.rs`):** every device statement verifies alone (`hollow-id1-*`, the device's own consent); the master key admits NOTHING; the recovery phrase is the root (recovery, phrase admission, remote destroy) and is NEVER stored or exported; removal locks at once (`DeviceRemoved`), erases in 3 days; a restored backup waits 7 quiet days unless vouched (the phrase can switch it off: `no_wait`); a vouch or removal counts only from a signer with STANDING and gives way by its distance from the phrase (a flood pushes out nothing shallower), a removed device keeps only what EVERY removal of it keeps (HOL-SEC-079/080); what contacts refuse to hear rides `RosterNotice` in the clear, into DM rooms (`devices_for` is empty for a single-device friend). Every device joins `roster_book::own_room` (else a contactless pending device hears no answer) and shows the relay its roster (`roster_book::show_relay`) on every connect and after EVERY change, the remover's own included. Roster FFI never creates an identity; the roster lock waits for the app lock. Roster reach (AR-29, `node/roster_reach.rs`): every DM carries `rd` roster digests inside Olm, a difference trades views (`RosterSync`, Carried, jittered); freshness per (master, DEVICE), a 35-day-stale device is asked and the DM waits up to 10 s in `hold_for_stale_roster`, the ONE gate every DM send path passes; push fetch keeps removals (`ingest_stored`) and notes asks (`roster_asks_due`). DM/file fan-out targets only devices CURRENTLY IN A ROOM (`has_session` is NOT liveness). `project_identity_authority_recovery_phrase`, `feedback_ghost_device_fanout`.
- **CRITICAL: the MASTER ID is a device only when the held roster counts it (G1, HOL-SEC-083):** anyone with the master key can log in AS it, so `resolver::is_bare_master` drops its frames (only `RosterNotice` heard), keeps it out of room presence, Olm, MLS and carried attribution; `same_identity(M, M)` is true, so a sibling gate must never meet a bare id. NEVER `seed_self` the master as a device; harness cold nodes use `resolver::forget_for_test`. Wiki `security_write_gates` §23.
- **CRITICAL: device linking (HOL-SEC-002, `node/link_pake.rs`):** SPAKE2 on a 10-char code (6 rendezvous chars the relay sees + 4 secret), ONE handshake per code; the presenter vouches for the key the joiner minted; the snapshot rides a random key inside the channel through the `.hollow` pipeline; receiver stashes `pending_link.*` + RESTARTS; import runs pre-node-start; NEVER import in-place while the node runs; the import lands identity + DB as ONE set (`land_snapshot`, root `.landed` mark), never `let _ = remove_file` an identity file; `land_snapshot` cleans the STAGED db (foreign paths, device secrets) and any failure fails the landing; every landing obeys `refuse_while_not_ready()` and one key-file lock. `feedback_link_import_identity_device`.
- **CRITICAL: channel device->master collapse:** channel messages are SIGNED by + attributed to the sender's MASTER; every Rust receive handler + `fetch.rs` resolves sender->master, Dart via `identityOf`; file-request stays RAW; wedged rows self-heal. `feedback_channel_display_device_master_collapse`.
## Messages, sync, storage integrity
- **CRITICAL: backward-compatible DB schema:** ALWAYS add `#[serde(default)]` to ANY new field on a persisted Rust struct; else old data fails to deserialize and silently vanishes (servers disappear).
- **CRITICAL: message dedup is by message_id, NEVER content:** every insert pre-checks `dm/channel_message_exists(mid)`; in-memory lists (`chat_provider`/`channel_chat_provider`) dedup by `message_id` before append. Receive events ALWAYS emit a `duplicate` flag; never suppress emission on "row exists". `feedback_sync_dedup_watermark_events`, `feedback_ui_dedup_by_message_id`.
- **CRITICAL: catch-up sync = watermark + `GapDigest` (#90):** DM/channel requests build via `dm_sync_anchor`/`channel_sync_request`; bare watermarks lose rows. A NEXT PAGE continues from the page's own end (`dm_next_page_since`, `ChannelPageEnd`), never our newest row or watermark (a live arrival skips rows). `project_issue90_dm_gap_digest`.
- **CRITICAL: public channels:** sends branch on `is_channel_public()` to a plaintext signed `PublicChannelMessage` (files too), kept ONLY if public in the receiver's state; voice and RESTRICTED channels NEVER public (`effective_public()`, restricting clears the flag, HOL-SEC-094); a GUEST's posts live only in `guest_view`'s memory store, author names only from master-signed cards (HOL-SEC-095); FileRequest serving GATED; guest bytes ride `PublicFileHeader` against the receipt cap. `project_public_channels`, wiki `security_write_gates` §7.
- **CRITICAL: conferences are VIRTUAL SERVERS:** `conf:{id}` = WS room = MLS group = server_id (channel `"main"`); the id self-certifies the HOST (`hollow-conf1`), host frames count only from it; host frames, knock, admit, chat and voice join ask the ROSTER (`mls_authority::refused`, HOL-SEC-123), the host commits out a leaf the roster drops and a leafless device leaves the call everywhere (`unseat_refused`, `drop_leafless_from_call`); knock, host frames and the meeting Welcome ride `MeetingSealed` under the link's `key=` (`Lane::Meeting`); admission IS the MLS add; chat = RAM-only, NEVER persisted; guards branch on `is_conference_sid`; Leave = `endOrLeaveConferenceMeeting`, never bare `leaveChannel` (strands the meeting). wiki `conferences`.
- **CRITICAL: link previews (#45):** receivers NEVER fetch to RENDER (inline video = explicit tap); `lp_set` re-signs, never `edited_at`; new `LinkPreviewRef` fields go in boxed `RichCard`; digest RECOMPUTED from the shipped card, never the wire. `project_link_preview_revamp_plan`.
## Files & assets (security)
- **CRITICAL: file ids SELF-CERTIFY (`node/file_commit.rs`: author master, mid, size, SHA-256, name, ext):** every header/sync/public card is checked against the id, unasked headers only from the author's devices, EVERY completion path hashes the bytes first. `project_frame_seal_design_a`.
- **CRITICAL: every FileHeader arm (Olm, MLS, push, guest) runs `file_header_refused` FIRST:** a card we hold takes a key or bytes only from its owner or the holder we asked; bytes on disk never change (`file_bytes_on_disk`). Vault writes/serves/deletes/manifests ONLY via the `vault_ops` gates, a rebuilt file must hash to its content id, restricted-channel files never vaulted. HOL-SEC-022/024/025, wiki `security_write_gates` §14.
- **CRITICAL: file pulls = `node/file_asks.rs` (asset-rail PendingAsk shape):** ONE holder at a time, EVERY dispatch re-stamps the receipt; `file_unavail` answered ONLY behind the bytes' own gate (silence for unknown/not entitled); ingest = asked-set HARD DROP, `expired` verified LOCALLY before `mark_file_expired`. `project_file_card_honest_states`.
- **CRITICAL: content files are AT-REST ENCRYPTED (`node/at_rest.rs`, `HFE1`, per-file key row in `file_keys`, names unchanged):** EVERY read/write/unlink of files/ audio_cache/ vault_cache/ shares/ goes through `at_rest::{read_all,read_range,write_all,Writer,remove}` (row dies with the file); Dart NEVER opens a data-root file with dart:io (`AtRest.*`, `AttachmentImage`, `atRestMediaUrl` loopback for video/voice, Save-as = `exportAttachmentTo`); boot sweep migrates plaintext. `project_at_rest_file_encryption_plan`.
- **CRITICAL: auto-download gate (#41):** pull paths gated in DART; pushes can't be cancelled (discard + `declined` pin); senders pre-negotiate via `auto_dl_pref`; VOICE exempt; RequestFile receipts bypass cap + gate, NEVER remove; `accept_header_thumb` = the ONE thumb filter. `project_autodownload_gate`.
- **CRITICAL: asset rail (emotes/banners/stickers/GIFs/frames/profile animation):** content-addressed WebP; bytes NEVER ride CRDT/envelopes/relay; receipt cap = kind WE recorded, unsolicited DROPPED; FFZ/Klipy proxies authoring-only, key SERVER-side; showcase/IGDB fetchers refuse non-Hollow-CDN URLs; send sites read `expandedText()` NEVER `.text`. `project_asset_rail_epic`, wiki `emotes`.
- **CRITICAL: peer image bytes reach Dart ONLY through `image_gate` (`shown`, `rail_blob_shown`, `read_at_rest_image`): a full pure-Rust decode first (AR-28); never `Image.file`/`FileImage` in lib/ (source guard); the sender only sends what passes.** `rules_files_assets`.
- **`.hollowpack` + support creds:** identity = sha256 of PROCESSED bytes; `import_hollowpack` verifies, NEVER re-encodes. `support_creds`: ONE validator `sanitize_incoming_support_creds` (OFFLINE vs the PINNED root, chain + THIS master, BEFORE spending) + master sig REQUIRED, unsigned = REFUSE+preserve; `support_creds_own` PER-DEVICE (UNION minus tombstones). Twitch = SAME cred: chip t=3, join gate t=4 vs joiner's MASTER; one CREDENTIAL per listing. Shop codes minted by the artist's Ko-fi webhook (`kofi.js`): `redeem_codes` = authority, `burnKey` = ONE lock; live needs `kofi_code`. `project_shop_kofi_code_model`, `project_support_credentials_phase2`, `project_twitch_signed_credential`; wiki `hollowpack`, `support_credentials`.
## Push & notifications (privacy)
- **CRITICAL: FCM/APNs push:** the wake block leaves the sidecar SEALED to the device (RFC 8291, `crypto/push_seal.rs` + `push-sidecar/fcm.js`, AR-26), never message ciphertext; every wake opens via `push_seal::open_wake` first (a keyed phone drops unsealed, stale, replayed); grouping happens ON the phone, never a collapse id; relay buffers offline DMs in RAM, replays on DM-room join; `RustLib.init()` throws on 2nd call (treat as ready); iOS NSE decrypts on-device (`push_enrich.rs`). `project_push_notification_implementation`, wiki `push_notifications`.
- **CRITICAL: channel push:** sender fans one `0x09` frame per OFFLINE member (targets + mention flag from its CRDT, so the relay never learns membership); relay filters `set_push_prefs` + debounce; muted DMs ride the RESERVED `~dm` entry (sender DEVICE ids, never a codec bump). Prefs sync MOBILE-ONLY. `project_channel_push_notifications`.
## Media (privacy & crypto; mechanics in `rules_media_calls.md`)
- **CRITICAL: SFrame:** `setKeyIndexForPeer` after every enable; `rotateKey` (not `setSharedKey`) in `setSframeKey`; cryptors idempotent per (peer,kind); enables SYMMETRIC + self-healing; commit evicting OUR leaf = INACTIVE group. Reneg/new-PC re-enable ladder: `project_sframe_heal_ladder`.
- **CRITICAL: call/VC signal types are WHITELISTED in Rust** (voice_handler.rs); unknown types silently dropped. A new one needs 4 touches: types.rs variant (`#[serde(default)]`), the send match arm, the swarm.rs dispatch arm, and `call_id_of` (else it routes to the wrong device). 1:1 `Call*` ride Olm-encrypted `MessageEnvelope::CallSignal`, plaintext REJECTED. `feedback_mobile_call_audio_route`.
- **CRITICAL: ONE call per IDENTITY (`node/call_book.rs`):** an invite rings EVERY online device, the first accept wins, late accepts get `CallAnsweredElsewhere`; master-addressed signals route by call_id to the device that answered/rang, never `pick_online_device`; presence = Dart `callPresenceSyncProvider`: a sibling in a DM call makes the identity BUSY, a sibling in a VC/meeting rings ALONE (others silent, answering leaves the room). `project_one_call_per_identity`.
- **CRITICAL: "Always relay calls" (force-TURN) lives ONLY in `IceConfigNotifier`:** `ref.listen` the toggle, NEVER `ref.watch` (rebuild wipes TURN creds); fails closed; never add TURN to `shareIceConfigProvider` or forwarder legs. `project_always_relay_calls`, `project_share_data_channel_reuse_turn`.
- **CRITICAL: screen shares are OPT-IN (#38):** a share streams ONLY after the peer's `screen_watch{want:true}` (`vc_screen_watch`/`call_screen_watch`); receivers DROP unsolicited offers; badges never auto-focus/flip. `project_issue38_watch_gate_ptt_grid`.
- **CRITICAL: vc_screen_* carry `origin` (boxed StreamOrigin):** receivers key attribution/consent/SFrame (`'screen:$originator'`) on the ORIGINATOR, transport on the sender; absent = sender. `inbound_origin_ok` DROPS spoofed origins; never weaken.
- **CRITICAL: stream overlay (#97, `node/overlay*.rs`):** consent = the `stream_overlay` field with its OWN master signature (never folded into profile/card sigs); looks ride Carried ask/answer only to own siblings, friends or devices seated with us, accepted only while their field says share; overlay images live ONLY in `overlay_blobs`, never the rail; the 127.0.0.1 server starts LOCKED, Host+Origin+token gated, projects (drops off/blocked/us). Wiki `stream_overlay`, `project_stream_overlay`.
- **CRITICAL: media forwarder lane (`fwd_*`, own `fwd:{id}` room):** CLIENT legs = **ZERO ice servers**, EXEMPT from Always-relay (media never touches a member's machine in ANY role). MEDIA LEGS = truth; assign trust = ORIGINATOR; feeder = SUPPLY NEVER AUTHORITY. `project_media_forwarding_epic`.
## Coding Conventions
- Dart: `flutter_lints`/`analysis_options.yaml`. Rust: `cargo clippy`. snake_case filenames. Flutter only for UI; no Electron/Node/web frameworks.
- **CRITICAL: comments say WHAT and WHY, never HOW** (kernel style): only what the code cannot show (constraint, trap, decision against an alternative, invariant), 1-2 sentences at the narrowest scope, doc comment = one summary line + what the signature omits; never restate code, narrate a mechanism or tell history (memory/wiki/commit). Comment lines well under 10% of a file; if code needs explaining, refactor it. `feedback_code_comments_what_why`.
- **NEVER pass `WidgetRef ref` as a constructor parameter**: use `ConsumerWidget`/`ConsumerStatefulWidget` (passing `ref` causes cascade rebuilds).
- **CRITICAL: fire-and-forget FFI needs `.catchError((_) {})`:** a sync try/catch catches NOTHING (rejections hit the zone handler). State-REGISTERING calls need a retry helper; subscribes ALWAYS via `subscribeChannelTopics()`. `feedback_ffi_fire_and_forget_catcherror`.
- **Widget tests:** `pumpHollowMobile()` mocks FFI. **VERIFY UI BY DRIVING THE APP on THROWAWAY fleet peers:** `scripts/fleet.ps1` (NEVER `ui_probe.ps1`'s default = Vitalik's REAL identity; a missed step goes `-Live`); also iOS Simulators on the mini (mobile default), Android emulators (`FLEET_BACKEND=android`, ADD for platform bugs: notifications, native, permissions), Linux laptop. `feedback_probe_throwaway_identities_only`, `feedback_mobile_parity_always`, wiki `fleet_probe`.
## Search tools = THE dev workflow (hollow-memory MCP + LSP)
- Every task and subagent brief, in order: `memory_search` (what we know) -> `code_search` (where) -> LSP references/callers -> Grep as the completeness floor -> READ the code -> harness/fleet prove it. Tools point, never prove. `feedback_semantic_search_first`.
- `memory_search(query)` = memory+wiki+reports+plan/whitepaper/CLAUDE.md, meaning + keywords; ALWAYS before designing or re-investigating. Both indexes refresh changed files per search (code once git tracks it).
- `code_search(query)` LOCATES code by what it does, never proves completeness: "every call site/arm" sweeps = Grep as the floor PLUS LSP `findReferences`/`incomingCalls` (Rust + Dart; catches aliased calls, misses code that does not resolve mid-edit). `reference_code_search_tooling`.
## Rules
- Never commit secrets, keys or credentials.
- Rust: networking/crypto/CRDTs/storage; Dart: UI/app logic/state.
- All crypto operations must use constant-time implementations.
- **HOLLOW_PLAN.md is authoritative** for phases, checklists, completion (never duplicated here or in memory); ask before architecture it doesn't cover.
- **HARD budget: 50,000 chars.** Entries = 1–3 lines: rule + memory pointer. This file keeps what breaks SILENTLY ANYWHERE (crypto, security, multi-device, data integrity, the relay); an area-only trap goes to its wiki rule book (listed above), never dropped. Buy room by COMPRESSING prose or moving an area rule, NEVER by deleting a rule.
- **SSH hosts (relay VPS, Mac, VM, Hostinger):** `BUILD_GUIDE.md` (gitignored); key-only, free for checks/logs/deploys.
- **Local dev:** checks/tests/codegen free.
- **Agent team (`.claude/agents/`, local):** `builder` (Opus high) / `builder-secure` (Opus high: security, crypto, identity, MLS, CRDT authority, relay, protocols) work in own worktrees (Mac lead `~/dev/wt`, PC `D:\dev\wt` via `scripts/pc_sync.sh`), every long run to a FRESH `runner` (Haiku, verbatim digest, never edits); `Explore` = Haiku scout; `reviewer` = hostile Opus high. NEVER a bare general-purpose agent for building (it inherits the lead's model AND xhigh); ~350k context = HANDOFF to a fresh builder, never a stop; measure waves with `scripts/usage_report.py`. `feedback_agent_team_tiers`.