Retention was keyed on comment type, so public and post-key-signed comments
carried a randomized 30-365d expiry inside their signed digest. Under the
ruling neither should ever expire; only the private, non-post-key-signed
(open-slot/greeting) channel gets an automatic TTL, since that is the
throwaway-identity retirement mechanism.
New crates/core/src/comment_ttl.rs is the single authority: CommentClass
(Public / PostKeySigned / OpenSlot / Unverifiable) x CommentTtlRule
(Never | Window | UnknownParent), with draw_expiry (writer) and ttl_ok
(holder). expires_at_ms == 0 is the never-expires sentinel, honored by the
sweep, the ingest gate, and the store_comment upsert.
- Post.comment_ttl: Option<CommentTtlPolicy> — a GENERIC per-post policy, so
an author-set TTL is a future config surface, not a redesign. Registry posts
declare a flat 30d policy that binds EVERY comment on them (registrations,
duplicate reports, anything else), replacing the registration-only rule.
- OpenSlotDecl.max_comments: author-declarable cap on private PK-unsigned
comments, enforced holder-side (clamped to the holder default), replacing
the hardcoded per-bio greeting cap. Refusal remains "declare no slot".
Node::set_greetings_max + `greetings-max` CLI command to write it.
- Holder enforcement rejects TTLs contradicting the parent's policy in both
directions; a comment naming a different post than its envelope is rejected.
- UnknownParent rule: bounded TTLs accepted from unheld parents (self-heal),
never-expires refused — permanence is not granted on unseen evidence.
Also fixed while here: five Post-reconstructing queries silently dropped
comment_ttl AND the pre-existing fof_gating (shipped in v0.8.0-alpha), so any
gated or policy-carrying post failed BLAKE3 verification on sync/export and
was discarded with no diagnostic. All hydration now goes through one
POST_COLUMNS/post_from_row path; export/import round-trips the policy.
Registry frozen bytes regenerated for the policy field; REGISTRY_POST_ID is
now 10a1be3383efb2977607fe45c4a7b3f1b5e626e81d0ac1af9c0f3d7eb9864d32.
design.html section 21 rewritten to the corrected taxonomy.
250 core tests (was 228); a3 integration 12/12 (new step 6 asserts registry
comments hold exactly 30d while greetings randomize); c_topology 33/33.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
Topology rework. The mesh's job is width; depth now comes from knowledge.
Slots:
- Local/Wide collapse into ONE mesh pool: 20 desktop / 15 mobile
- Temp referral slots +4..+10 ABOVE the cap (10 desktop / 4 mobile), carry no
knowledge exchange, graduate into a freed mesh slot or expire (5 min TTL),
never evict an established mesh peer
- Fixes the Wide-never-fills bug: outbound paths hardcoded Local while the
growth gate counted Local only, so growth stopped at 71 and 20 slots were
inbound-only. Also closes register_connection paths that enforced no cap.
Knowledge — two-pool uniques announce (replaces N1/N2/N3 share lists):
- Pool 1 (forwardable): our N0-N2 uniques, deduped. Receiver stores shifted one
bounce deeper as N1-N3, reporter-tagged.
- Pool 2 (terminal): our N3, deduped against pool 1. Receiver stores as N4,
USED for search/resolution but NEVER re-announced. No N5 anywhere.
- Uniques merge mesh peers + social directs + CDN file authors + holder peers,
so receivers cannot tell how we know an ID
- Anchor entries carry addresses; every other entry is a bare ID. The pools
double as the anchor directory.
- Dedup at both store and announce; per-bounce caps; 2 MB payload ceiling
- Device-tiered depth: mobile holds 3 bounces and drops the terminal pool
Anchor convection (register loop retired — 0xC0 AnchorRegister deleted):
- Rolling in-memory caller window (no registration DB): a caller gets the 2
most recent viable prior callers, its address goes to the next 2, then
rotates out; still-connected callers preferred; RelayIntroduce coordinated
when both ends are live
- Request classes: entry (<2 connections) always served; top-up refused
cheaply under load, and the refusal feeds the adaptive weights
- Stochastic per-disconnect action {nothing | anchor intro | mesh intro} with
weights from local anchor density + refusal feedback. No thresholds, no
jitter timer. Recovery (mesh < 2) stays immediate and non-stochastic.
- Anchors mined from the uniques pools incl. retained pools of disconnected
peers; known_anchors demoted to a bootstrap cache
Security fixes from review: remote address poisoning (hearsay anchors could be
dialled — known_anchors/is_anchor now written only from completed handshakes),
N4 leaking back into announcements via the anchor mirror (infinite propagation),
missing payload size caps, unbounded candidate scoring, and a bulk SQLite write
held under the ConnectionManager mutex.
deploy.sh: version regex now captures pre-release suffixes (0.8.0-alpha would
have truncated to 0.8.0 and broken every artifact path), announce channel
derives from the suffix, and the anchor runs --publish-registry at genesis.
228 core tests; a3 integration 9/9; new c_topology_test.sh 33/33 (both
independently re-run). EDM corpse, session-relay gating, Iteration A/B intact.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
A1 — ID-class bug family (v0.6.1 persona split left network-NodeId comparisons
where posting IDs live). ~14 sites fixed with multi-persona semantics ("author is
one of MY posting identities", not == default):
- revoke_post_access / revoke_circle_access (revocation was broken for every post)
- group key create/add-member/rotate (add-member distribution now works)
- circle profile set/delete/get, encrypted-attachment CEK unwrap
- receipt/comment slot authorship, replication cycle, blob eviction own-tier
- orphaned GroupKeyRequest/Response (0xA1/0xA2) deleted (no send side existed)
A2 — Manifest privacy: AuthorManifest.author_addresses removed from struct AND
signature digest; CdnManifest tree-era fields dropped. Startup migrations re-sign
own manifests, purge unverifiable foreign copies, strip persona fields from legacy
network-id profile rows. Posting identities are now location-anonymous.
A3 — Discovery & first contact:
- Open-slot comments: derivable slot V_x (blake3 from author + slot_binder_nonce)
so strangers pass the CDN comment-verification gate; OpenSlotDecl in FoF gating
- Greetings: HPKE-style sealed GreetingBody (return_path + fresh reply_pubkey),
throwaway outer ID, size-bucketed; messaging-first (Reply/Dismiss, no vouch)
- Registry: frozen canonical registry post + REGISTRY_POST_ID, signed registration
comments (self-certifying deletes), newest-wins per persona, --publish-registry
- Comment TTL: expires_at_ms inside signed digest v2 (context bump), rand 30-365d
ordinary / fixed 30d registrations, expiry sweep on the eviction cycle
- Single accept_incoming_comment() gate wired into all three ingest sites, closing
a pre-existing hole where both pull paths stored comments with no verification
- UI: consent panel (visible/listed/greetings, active choice at first publish),
registry search in Discover, sealed "Say hi" compose, greetings inbox
Security fixes from review: forged-tombstone injection, delete-by-sender-trust,
remote SetPolicy poisoning, stored XSS (escapeHtml quotes, 7 pre-existing sites),
greeting off-switch now revokes prior bios' slots.
No PoW (rejected: inverted cost). Session-relay gating untouched.
201 core tests pass; CLI + desktop build; 3-node integration green.
DEPLOY GATE: wire-incompatible with v0.7.3 — ALPN bump to itsgoin/4 lands in
Iteration B. Do not build/deploy/anchor-swap before that.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LGiPD2cF75mnvneSCjdDC5
Bandwidth + bootstrap hardening on top of v0.7.2. Wire-compatible with
v0.7.0/v0.7.1/v0.7.2; no protocol changes.
EDM port scanner DISABLED
- hole_punch_with_scanning() now does only single quick punch + parallel
punch over 30s window. The EDM port-scanner branch is gone from the
live path because per-probe endpoint.connect() amplifies catastrophically:
iroh accumulates every connect() target into a per-endpoint paths set
and probes them all under QUIC NAT-traversal in the background. A
100-probes/sec / 5-min scan inserted ~30k paths; iroh probed all of
them. Observed at 22MB/s outbound from one client — DoS-grade.
- Scanner body preserved as edm_port_scan_disabled_v0_7_3() with all
supporting helpers (PortWalkIter, scanner_semaphore, role-based
scanner/puncher split, found_tx/found_rx channel pattern,
deadline + tokio::select! orchestration) marked #[allow(dead_code)].
Refactor target: replace per-probe endpoint.connect() with raw
socket.send_to() so probes don't enter iroh's path store.
Bootstrap probing batched
- New probe_anchors_batched() helper: 3 anchors in flight at a time,
2s stagger between batch dispatches, 10s per-anchor timeout, no abort
on success. First success unblocks the bootstrap flow; remaining
probes continue in background and fill peer connections naturally.
- Phase 2 (bootstrap fallback) still only fires when every discovered
anchor failed — preserves load-distribution intent. Replaces the
sequential 50s+ timeout cascade users observed with old data dirs.
Stale-anchor self-pruning
- New storage.get_known_anchor_last_seen() and storage.delete_known_anchor().
- maybe_prune_stale_anchor(): when a probe fails AND last_seen_ms > 3 days,
delete the entry from known_anchors immediately. Recoverable anchors
(failed once, succeeded recently) are preserved. Self-healing for old
data dirs whose discovered anchors point to keypairs that rotated
months ago.
Android close button kills NodeService
- New NodeService.stopFromNative() Kotlin static method called via JNI
from android_wifi::stop_node_service(). exit_app invokes it on Android
before app.exit(0). Previously the button ended the Activity but the
foreground service kept networking running.
Cosmetic
- Power-icon SVG (inline) replaces ⏻ so Android webviews lacking
U+23FB don't render a missing-image tofu box.
Docs
- design.html section 11 rewritten for portmapper (UPnP+NAT-PMP+PCP,
v0.7.2) including per-platform contract and bidirectional anchor
watcher.
- design.html section 10 marks session relay as opt-in (v0.7.2) and EDM
scanner as disabled-pending-refactor (v0.7.3).
- download.html carries v0.7.3 release notes.
- MEMORY.md updated; older v0.7.0/v0.7.1 status sections condensed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Network/reachability improvements + a relay-privacy fix. Wire-compatible
with v0.7.0/v0.7.1; no protocol changes.
- Replace hand-rolled UPnP (igd-next) with the portmapper crate. All three
protocols (UPnP-IGD / NAT-PMP / PCP) run in parallel, auto-renew
internally. PCP adds IPv6 firewall pinholes and works on iOS without
the multicast entitlement. Pulled in transitively via iroh already, no
net dep growth.
- Android: UPnP/PCP/NAT-PMP attempted on WiFi/Ethernet with a
WifiManager.MulticastLock acquired for the lifetime of the mapping.
Cellular skipped early (no UPnP/PCP gateway, avoid 3s discovery waste).
- TCP port-mapping gate removed for mobile — phones with permissive NAT
can now serve HTTP for direct browser fetches.
- Anchor reachability watcher (bidirectional): clears is_anchor after
>5min of no port mapping; restores it when the mapping comes back.
Network roams self-heal without restart. Mobile never auto-anchors.
- Session relay opt-in restored. relay.session_relay_enabled setting
defaults OFF (anchors included — servers shouldn't silently burn
bandwidth either). Gates both serving (can_accept_relay_pipe) and
using (auto-fallback in node.rs). UI toggle in Settings. Relay-style
signaling (RelayIntroduce / worm_lookup / N1-N3 shares) unaffected.
- URL Phase 1: share links now contain only the post ID
(itsgoin.net/p/<post>). Anchor handler already supported post-ID-only
URLs (author was optional); just dropped the author hex from the
generator. Older URLs with author hex continue to work.
- Quick app close button in header (with confirm) — useful for stopping
network activity between sessions on mobile.
- JNI null-pointer guards on ndk_context handles in android_wifi.rs.
MEMORY rule sharpened to distinguish session relay (byte pipe, opt-in)
from relay-style signaling/discovery (always on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#6: UI rename — Network Identity → Device Address.
Just labels/wording, no backend changes. Tauri command names
(list_identities, create_identity, etc.) and DTO fields stay as-is;
the rename is purely user-facing. Settings labels, lightbox titles,
toasts, danger-zone text, and welcome-screen "Import an Identity"
button all updated. Distinguishes more clearly from Personas (which
ARE the posting identity peers see).
#7: Bug fix — redundancy showed 0 for new posts.
Root cause: get_redundancy_summary queried
SELECT p.id FROM posts WHERE p.author = ?1
with ?1 = self.node_id (the device's network NodeId). After the
v0.6.0 network/posting-ID split, posts are authored by posting
identities (personas), not the network NodeId — so the query
returned 0 of my posts and the redundancy panel showed all zeros.
Fix: storage::get_redundancy_summary now takes &[NodeId] and uses
WHERE author IN (?, ?, ...) over every persona on the device.
Node::get_redundancy_summary gathers them via
list_posting_identities() before delegating.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three of the v0.7.0 device-testing feedback items, deferred for later
rebuild/redeploy (Scott opted to batch UI fixes).
(#1) Friend button on bio modal:
- Primary action when neither following nor vouched: [Friend] (=
follow + vouch in one click) plus secondary [Follow only].
- When following without a vouch: [Add Vouch] primary, [Unfollow]
secondary.
- When both follow + vouch (= friends): [Unfriend] (= revoke vouch
+ unfollow, with the rotation-cost confirm wording).
- The standalone [Vouch] / [Revoke Vouch] flows stay reachable from
the existing Vouches list in Settings.
(#2) Profile shows "unnamed" — bug fix:
- set_profile updated the profiles table + emitted a profile post,
but never updated posting_identities.display_name. list_posting_
identities returns from the latter, so the Personas list kept
showing "(unnamed)" forever after the first-run auto-persona was
named.
- Now set_profile also upserts the posting_identities row with the
new display_name (secret_seed + created_at preserved).
(#5) Export/import + persona-vs-device clarity:
- Settings reorder: new "Your data on this device" explainer card up
top (Personas = who you are to peers; Identities = device's
network address, rarely useful to touch).
- "Move to another device" section renamed + given a plain-English
description; primary [Export personas] / [Import from another
device] buttons.
- "Identities (advanced)" demoted below; warning text added.
- Export wizard heading: "Export your personas"; radio labels use
persona/keys language consistently.
- Import wizard heading: "Import from another device"; explainer
notes that the default action restores personas.
Tracking memory created at memory/project_v071_followups.md for
deferred items (#4 PQ vouch delivery, #6 rename, #7 redundancy,
#8/#9/#10 awaiting clarification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two security/operational findings from the deeper pre-deploy audit:
1. Key-burn replay attack (security)
- Before: replace_fof_slot did a blind UPDATE on receive. An
attacker replaying an older signed FoFKeyBurn diff (Monday's)
after a newer one (Friday's) would revert the slot.
- Fix: new fof_key_burns table tracks (post_id, slot_index) ->
max(burned_at_ms) seen. replace_fof_slot now refuses to apply
a burn whose timestamp is <= the stored max. Atomic transaction
ensures the gating-update + monotonic-record stay consistent.
- apply_fof_key_burn_locally signature gains burned_at_ms; the
three callers (connection.rs receive, node.rs author API, the
fof.rs roundtrip test) all updated.
2. Unbounded sweep lock-hold (operational)
- Before: sweep_unreadable_on_new_v_x walked up to 4096 queued
posts per call, all under one storage lock. ~2s lock-held worst
case. Attack: spammy bio posts trigger repeated sweeps.
- Fix: MAX_SWEEP_PER_CALL = 256. Remaining entries processed on
subsequent V_x arrivals. Bounds lock-hold to ~250ms worst case.
One new test (158 total):
- fof_key_burn_replay_rejected: applies Monday burn, then Friday
burn, then replays Monday — asserts stored state stays at Friday's
pub_x.
Bio-post replay was also evaluated: vouch grants are HPKE-sealed
(unforgeable) and stored INSERT OR IGNORE on (holder, owner, epoch)
so replay is a no-op. No fix needed.
Revocation + access-grant were also evaluated as idempotent by their
storage layers (INSERT OR IGNORE / dedup-on-pub_x). Safe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new tables + cache/sweep wiring per docs/fof-spec/
layer-5-prefilter-and-cache.md:
vouch_unlock_cache (reader_persona, author) -> winning V_x
- Records the V_x that last unlocked a post from this author. Next
post from same author: hot path is 1 HMAC + 1 AEAD attempt.
vouch_unreadable_posts (reader_persona, post_id)
- Queue of FoF posts that no held V_x currently unlocks. Swept when
a new V_x lands in the persona's keyring.
own_fof_post_ceks (author_persona, post_id) -> (CEK, slot_binder_nonce)
- Author-direct decrypt fast path. Populated at publish so authors
skip wrap-slot trial entirely when reading their own posts.
find_unlock_for_post:
- Cache fast path: look up winning V_x, prefilter, single AEAD.
- Full scan fallback on cache miss; record cache hit on success;
record unreadable on full miss.
read_fof_closed_body:
- Author-direct fast path: lookup_own_fof_post_cek before trial-unlock.
sweep_unreadable_on_new_v_x:
- Walks ALL unreadable for the persona (the new V_x can unlock posts
authored by anyone — the V_x owner may be a chain-link in some
third party's keyring). Wired into the vouch-grant scan path so
every new V_x triggers a sweep automatically.
Both FoF publish paths (Mode 1 + Mode 2) now cache CEK at publish.
Bug fix found by the sweep test: storage::get_post_with_visibility
wasn't loading fof_gating_json (always returned None). Fixed; reader
paths through that function now see the gating block.
16 fof:: tests pass (2 new: cache populates+hits, sweep after V_x
arrival). 150 total tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an optional supersedes_post_id pointer to Post for the
"re-issue with narrower access" path. Author publishes a new post
that references an earlier one; receivers can render "this is a
re-issued version of an earlier post" + offer to view the original.
Covered by PostId = BLAKE3(Post) — receivers can verify it wasn't
forged by anyone but the author.
#[serde(default)] for back-compat; existing posts deserialize with
None. All existing Post construction sites bulk-updated to set the
field to None.
Storage::get_post returns None for the field today; a follow-up can
add a dedicated column if/when receivers need to render the
pointer. The author-side re-issue helper that creates posts with
this field set comes in the Tauri/UI slice — the wire shape is in
place now.
148 tests pass (no regressions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For leaked-V_me scenarios. The author re-seals a single slot under a
fresh V_me, invalidating the leaked key's access to this specific
post on the wire. Comments signed under the old pub_x at that slot
are NOT auto-deleted; pair with revoke_fof_commenter if comment
cleanup is desired.
Wire format (BlobHeaderDiffOp::FoFKeyBurn):
post_id, slot_index, new_pub_x, new_wrap_slot, burned_at_ms,
author_sig (64B ed25519 over canonical tuple).
fof.rs:
- sign_fof_key_burn / verify_fof_key_burn: canonical signing tuple
includes post_id, slot_index_le, new_pub_x, prefilter+read+sign
bytes from WrapSlot, burned_at_ms_le. Identical shape to access-
grant but with slot_index instead of append.
- apply_fof_key_burn_locally: delegates to storage.replace_fof_slot.
storage.rs:
- replace_fof_slot(post_id, slot_index, new_pub_x, new_wrap_slot):
mutates the stored post's fof_gating_json. Bounds-checks slot_index.
Local-only; PostId unaffected.
connection.rs: receive arm. Verifies author_sig + applies.
node.rs:
- Node::key_burn_post_slot(post_id, slot_index, new_v_x): recovers
CEK via find_unlock_for_post, generates fresh per-V_x keypair,
seals new slot under new_v_x with the existing CEK +
slot_binder_nonce. Signs + applies locally + propagates.
CEK is NOT rotated by this op — body remains encrypted under the
same CEK as before. Locally-cached plaintext on devices that
already-decrypted is unrecoverable by any wire mechanism (out of
scope per spec).
Test brings the total to 147:
- fof_key_burn_replaces_slot: Alice burns her slot from V_me_old to
V_me_new; V_me_old no longer unlocks; V_me_new unlocks and yields
the same CEK; pub_post_set updates to the new pub_x.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the foundation for Layer 4 lifecycle operations:
Storage (own_post_slot_provenance):
- New author-local table mapping (post_id, slot_index) to
(v_x_owner, v_x_epoch, pub_x). Populated at FoF post-publish.
Used by cascade revocation to find the pub_x's that need revoking
when a V_me epoch is retired. Never on the wire.
- record_post_slot_provenance + list_provenance_for_v_x_epoch APIs.
fof::build_fof_comment_gating now returns RealSlotProvenance entries
for each real (non-dummy) slot it sealed. Owner = persona who issued
the V_x (author's own persona_id for self-slot). Both Mode 1 and
Mode 2 publish paths persist provenance after compute_post_id.
Node API:
- rotate_v_me() — pure rotation. Generates next V_me epoch in
vouch_keys_own (old epoch retained, is_current=0), republishes bio
for existing vouch targets. Returns new epoch. Used for periodic
refresh / leak response; doesn't revoke anyone.
- cascade_revoke_v_me_epoch(epoch, reason) — for every post the
author authored where slots were sealed under (self, epoch),
publish a per-pub_x revocation diff via revoke_fof_commenter. The
existing Layer 2 cascade-delete then sweeps locally-stored comments.
Returns the count of revocations published.
These combine to give the spec's "rotation + optional cascade" UX:
rotate first (cheap, grandfathers old posts), then cascade if the
user wants to actively cut off old-content access.
13 fof tests pass (new: fof_gating_real_slot_provenance asserting
provenance entries match real slots' pub_x values).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end FoFClosed (Mode 1: encrypted body + FoF comments):
Node API:
- create_post_fof_closed(content) -> (PostId, Post, cek)
Builds gating, encrypts body via fof::encrypt_fof_body, base64s it
into post.content, stores with visibility=FoFClosed +
intent=Public, propagates via update_neighbor_manifests_as.
- read_fof_closed_body(post_id) -> Option<String>
Trial-unlocks via find_unlock_for_post, decrypts body, returns
plaintext. Returns None for non-FoFClosed or non-member readers.
Tauri commands:
- create_post_fof_closed, read_fof_closed_body. Registered in
generate_handler!.
Feed rendering:
- PostDto.visibility carries the new "fof-closed" string.
- renderPost(): FoFClosed posts render with a locked placeholder
(data-fof-closed-pending=post_id span). Visual badge added.
- unlockFoFClosedPlaceholders(rootEl): post-render async pass that
scans for placeholder spans and dispatches read_fof_closed_body
for each. Fills in body for FoF readers; falls back to a
"not in this FoF set" notice otherwise.
- Wired into feed-list and my-posts-list render paths.
Compose:
- "Body+Comments: FoF only (Mode 1)" option in comment-perm-select.
Selected → dispatches to create_post_fof_closed.
CLI feed renderer + Tauri feed-DTO match arms updated to handle
FoFClosed.
New end-to-end test brings total to 146:
- fof_closed_body_end_to_end: Alice authors FoFClosed body; Bob (with
Alice's V_me in his keyring) unlocks + decrypts; Carol (no
matching V_x) cannot unlock and sees only ciphertext.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the Mode 1 (encrypted body) primitives:
PostVisibility::FoFClosed
- New tag variant. The actual gating data (slot_binder_nonce,
pub_post_set, wrap_slots) lives in Post.fof_gating — single source
of truth shared between Mode 2 (Public + fof_gating) and Mode 1
(FoFClosed + fof_gating). Invariant: FoFClosed implies Some(gating).
fof::encrypt_fof_body / decrypt_fof_body
- ChaCha20-Poly1305 under the gating CEK with slot_binder_nonce as
AAD (binds body decrypt to the post's gating; an attacker who
steals CEK can't reuse it against a different post).
- Plaintext format: real_len_u32_le || body_bytes || random_padding.
Length prefix lets the reader strip padding after decrypt.
- Bucketed body padding: power-of-2 from 1KB up to 256KB, then
+256KB linear above. Different bodies in the same bucket produce
identically-sized ciphertexts (test asserts this).
fof::next_body_size_bucket(real) -> usize
- Min 1KB, power-of-2 to 256KB, then +256KB steps. Aligns with the
future storage chunk size at 256KB+.
Three new tests (145 total):
- body_bucket_rule_boundaries: spec-conformance for the bucket sizes.
- fof_body_roundtrip: encrypt → decrypt; wrong CEK rejects; wrong AAD
(slot_binder_nonce) rejects.
- fof_body_padding_hides_real_length: 5B body and 500B body produce
same-sized on-wire ciphertexts (1KB bucket).
8 match arms updated to handle FoFClosed across import, network, node,
storage. Most paths skip FoFClosed-specific handling (it goes through
the FoF wrap_slot path); revoke_post_access bails with a pointer to
the FoF revoke helpers; index_post_recipients no-ops (FoF has no
per-recipient identifiers on the wire).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three new Tauri commands surface Layer 2 to the frontend:
- create_post_with_fof_comments(content) → { postId }
Builds the FoF gating block from the default persona's keyring and
publishes a Mode 2 post (public body, FoF-gated comments).
- comment_on_fof_post(post_id_hex, body)
Backwards-compat wrapper: comment_on_post now auto-detects FoF
gating on the parent post and dispatches internally. Existing
comment-send UI works unchanged.
- revoke_fof_commenter(post_id_hex, pub_x_index, reason_code)
Wraps the Node helper. UI for per-comment revocation can hang off
this without further Node changes.
Node::comment_on_post now branches on parent post fof_gating presence
— this is the central routing point so neither the frontend nor
existing comment-send code needs to know about FoF specifics. FoF
posts get FoF comments automatically; non-FoF posts get the legacy
path. Same Tauri command, same frontend handler.
Frontend:
- Compose: comment-perm-select gains a "Friends of Friends" option.
When picked, compose dispatches to create_post_with_fof_comments
instead of the standard create_post. Standard set_comment_policy
diff fires after so receivers' four-check accept rule activates.
- Attachments + non-default-persona FoF posts are out-of-v1; compose
reports + aborts rather than silently producing a non-FoF post.
Layer 2 backend + minimal-viable UI complete. Granular per-comment
revoke UI and access-grant UI deferred — Node + Tauri primitives
exist; the surfaces can be added without further crypto work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the access-grant primitive end-to-end:
Wire format:
- BlobHeaderDiffOp::FoFAccessGrant { post_id, new_pub_x,
new_wrap_slot, granted_at_ms, author_sig }. 64-byte Ed25519 sig
by post author over canonicalized tuple.
fof.rs:
- sign_fof_access_grant / verify_fof_access_grant: identical shape
to revocation but covers (pub_x, wrap_slot, granted_at).
- apply_fof_access_grant_locally: appends to local pub_post_set +
wrap_slots. Refuses to apply if new_pub_x is already revoked
(prevents accidental re-admission of a previously-blocked signer
per Layer 4 resolved decision). Idempotent on (post_id, new_pub_x).
storage.rs:
- append_fof_access_grant(post_id, new_pub_x, new_wrap_slot): mutates
the stored post's fof_gating_json column to append the new entry.
PostId (in id column) is unaffected — local-evolution semantics:
the stored gating diverges from the original t=0 snapshot as
access-grants and revocations land.
connection.rs: receive arm verifies author_sig + applies locally.
Author API (node.rs):
- Node::grant_fof_access(post_id, new_v_x): recovers the post's CEK
by trial-unwrapping the author's own slot (find_unlock_for_post),
generates a fresh per-V_x keypair, seals a new wrap slot under
new_v_x with the same CEK + slot_binder_nonce, signs the grant,
applies locally for immediate UI, then propagates via
propagate_engagement_diff.
New test brings the suite to 142 passing:
- fof_access_grant_appends_and_unlocks: pre-grant Carol cannot
unlock; Alice grants; post-grant Carol unlocks and recovers the
CEK; duplicate grant skipped; revoked pub_x cannot be re-admitted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the full revocation primitive end-to-end:
Wire format:
- BlobHeaderDiffOp::FoFRevocation { post_id, revoked_pub_x,
revoked_at_ms, reason_code, author_sig }. 64-byte Ed25519 sig by
post author over (post_id || revoked_pub_x || ms_le || reason).
fof.rs additions:
- sign_fof_revocation(author_secret, ...): builds the canonical
signing tuple and signs.
- verify_fof_revocation(post_author, ...): Ed25519 verify; false on
any shape/key/sig failure. CDN-verified before any side effect.
- apply_fof_revocation_locally(storage, ...): records in
fof_revocations + cascades retroactive delete of locally-stored
comments matching the revoked pub_x via pub_post_set lookup.
Receive path (connection.rs): new arm for FoFRevocation diffs.
Looks up post.author from storage, verifies author_sig (rejects
diffs where payload.author != post author or sig invalid), then
applies locally. Propagation continues via existing mechanism.
Author API (node.rs): Node::revoke_fof_commenter(post_id,
pub_x_index, reason_code) resolves pub_x from gating.pub_post_set,
signs with the persona's identity secret, applies locally for
immediate UI update, then propagates via propagate_engagement_diff.
Two new fof tests bring the suite to 141 passing:
- fof_revocation_cascades: full author → publish → commenter →
revoke → cascade-delete + recorded-in-storage roundtrip.
- fof_revocation_wrong_author_rejected: Mallory signs claiming
Alice's authorship → verify rejects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Threads optional fof_gating through create_post_inner so a published
post can carry the author-signed gating snapshot at publish time
(covered by PostId = BLAKE3(Post)).
New public entry point:
Node::create_post_with_fof_comments(content, attachments)
-> (PostId, Post, PostVisibility, cek: [u8; 32])
Builds the FoFCommentGating block via fof::build_fof_comment_gating
from the default persona's keyring (own V_me + every received V_x),
then calls create_post_inner with VisibilityIntent::Public (Mode 2
keeps body public). Returns the per-post CEK to the caller for local
caching (decrypting one's own comments later).
Existing create_post / create_post_as / create_post_with_visibility
threads `None` through the new arg — back-compat for non-FoF posts.
CommentPolicy::FriendsOfFriends is NOT published as a SetPolicy diff
in this commit; the post's `fof_gating` field is itself the signal
that the post supports FoF commenting. The four-check CDN verify gate
(next commit) reads fof_gating directly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the on-wire shapes for FoF Mode 2 comment-gating per
docs/fof-spec/layer-2-mode2-fof-comments.md:
- WrapSlot: per-V_x slot with 2B prefilter_tag + 48B read_ciphertext
+ 48B sign_ciphertext (sealed CEK + sealed priv_x_seed). 98 bytes
total per slot. Receiver trial-decrypts via prefilter match.
- FoFCommentGating: author-published gating block embedded in
Post.fof_gating. Carries slot_binder_nonce (32B random; replaces
spec's circular "post_id in HKDF info"), pub_post_set (1:1 with
wrap_slots, includes dummy pubkeys), wrap_slots, and revocation_list
(initially empty; revocation diffs accumulate on the BlobHeader copy).
- RevocationEntry: author-signed entry triggering retroactive comment
delete + pub_post_set removal on every file-holder that receives it.
- CommentPermission gains FriendsOfFriends variant. Existing match arm
in connection.rs handle-incoming-diff path is extended with a
"drop pending CDN four-check verification" stub (full verify in a
later slice).
- InlineComment extended with three optional fields:
pub_x_index: index into parent post's pub_post_set
group_sig: 64B ed25519 sig under priv_x
encrypted_payload: ChaCha20-Poly1305 ciphertext under CEK_comments
All #[serde(default)] for back-compat. Old comments deserialize
cleanly with None.
- Post gains optional fof_gating field for the author-signed snapshot
at publish time. PostId = BLAKE3(Post) covers it, so any tampering
is detectable. Mutations (revocation, access-grant) arrive later as
diffs against the local BlobHeader copy.
All 21 existing Post construction sites + 4 existing InlineComment
sites updated via perl -0pe sweeps to pass None for the new fields.
Full test suite: 134/134 pass (4 new slot crypto + 130 existing).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node helpers (crates/core/src/node.rs):
- vouch_for_peer(target): derives target X25519 pub from NodeId,
inserts into own_vouch_targets, republishes bio post so the new
VouchGrantBatch propagates to the receiver via CDN.
- revoke_vouch_and_rotate(target): per Scott's design, revocation IS
the rotation primitive. Marks target current=0, generates new V_me
epoch in vouch_keys_own (prior retained, Layer 4 receiver-chain),
republishes bio. Revoked persona retains old V_me → grandfathered
access to old content; locked out of new content sealed under V_new.
- list_vouches_given / list_vouches_received: enriched with display
names via resolve_display_name.
Tauri commands (crates/tauri-app/src/lib.rs):
- vouch_for_peer, revoke_vouch_for_peer (single-action commands)
- list_vouches_given, list_vouches_received (DTOs with camelCase)
- All registered in the generate_handler! list.
Frontend (frontend/index.html + app.js):
- New "Vouches" section in Settings tab with side-by-side Given /
Received lists. Per-row Revoke button on Given entries with confirm
prompt explaining the rotation semantics.
- Bio modal gains Vouch / Revoke Vouch button next to Follow/Unfollow.
State derived from list_vouches_given on modal open.
- loadVouches wired into the settings-tab activation handler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the publish side of FoF Layer 1 vouch distribution:
- VouchGrantBatch gains bio_pub_nonce (32B random per batch). Replaces
the spec's circular "bio_post_id in HKDF info" — BLAKE3(post)
depends on vouch_grants, so we need a content-independent binder.
Recipient-free per HPKE key-privacy; serves the same anti-replay
purpose as bio_post_id would have.
- profile::build_vouch_grant_batch reads current_own_vouch_key +
list_current_vouch_targets, generates eph keypair + bio_pub_nonce,
seals V_me for each target, bucket-pads with random 48B dummies,
and shuffles. Returns None when there are no targets.
- next_vouch_batch_bucket implements the FoF Layer 3 padding rule:
minimum bucket 8, power-of-2 up to 256, then linear +128 steps.
Bucket-padding-tests verifies all boundaries.
- Storage gains next_bio_epoch_for(persona_id): monotonic counter
per persona, used by receivers' scan cache. Stored in settings.
- build_profile_post signature extended to take Option<VouchGrantBatch>
+ bio_epoch: u32. Both publish_profile_post_as (initial post) and
set_profile (subsequent edits) build the batch and bump the epoch
on every publish.
- Test sites updated to pass None/0 for the new args.
Receive-side scan (next commit) reads VouchGrantBatch + bio_pub_nonce
to trial-decrypt wrappers and populate vouch_keys_received.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds VouchGrantBatch type to types.rs and extends ProfilePostContent
with optional vouch_grants + bio_epoch fields (back-compat via
#[serde(default)]). VouchGrantBatch carries one shared batch_eph_pub
+ a Vec<Vec<u8>> of 48-byte wrappers; receivers identify their wrapper
by AEAD success, not position.
Wires V_me auto-generation into both persona-creation paths:
- Node::open first-run auto-persona block now seeds vouch_keys_own
epoch=1 alongside upsert_posting_identity.
- create_posting_identity calls ensure_initial_v_me after the new
persona is stored.
Helpers live as private free functions at module scope so both the
sync (Node::open holds a Storage guard) and async (create_posting
holds a StoragePool) sites can share them. Idempotent — re-running
on a persona that already has a current key is a no-op.
Two existing ProfilePostContent construction sites updated to set the
new fields to defaults (None / 0); they'll get real values when the
publish path is wired up in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
per-author feed filter, ignore primitive
The old People tab was built on network-layer presence (`is_online`,
`last_seen` from the mesh), which was lost when v0.6.1 anonymized the
network id from the posting id. Every named follow is authored under a
posting id that doesn't appear in the connection-layer tables; the
"Online" section listed nobody useful and Discover depended on the
same broken signal.
Replaced with signals derived from signed content:
- Following is sorted by most-recent-post timestamp (the real meaning
of "activity" in a post-anonymization world).
- Discover lists named peers we've received signed profile posts from
(via Phase 2d), filtered by follows / ignores / self.
- Click-a-name surfaces a bio modal with View Posts / Follow / Message
/ Ignore actions.
- Author-scoped feed filter (`View Posts` on any person) renders a
"Showing posts from X" banner with a Clear button.
- Ignore is a new local-only primitive; ignored peers' posts and
profiles are excluded everywhere and the ignored list is editable
in Settings.
Core changes:
- New `ignored_peers(node_id, ignored_at)` table + storage helpers
(`add_ignored_peer`, `remove_ignored_peer`, `list_ignored_peers`,
`is_ignored_peer`). Schema created fresh; no migration since the
table is purely additive and empty on prior installs.
- All 6 feed-query sites now also exclude `author IN ignored_peers`.
- New `Storage::last_activity_for_authors(&[NodeId])` — one batched
query returning max post timestamp per author, excluding non-feed
intents (Control / Profile / Announcement / GroupKeyDistribute).
- New `Storage::list_discoverable_profiles(&self_id)` — named profile
rows where node_id is not self, not in follows, not in ignored, and
`public_visible = 1`. Sorted by profile `updated_at` DESC.
- New `Storage::delete_setting(key)` — missing counterpart to
set/get.
- Node wrappers: `last_activity_for_follows`, `ignore_peer`
(also drops any follow + social route for the ignored peer),
`unignore_peer`, `list_ignored_peers`, `list_discoverable_profiles`.
- `list_follows` Tauri command now sources `last_activity_ms` from
the posts-driven batched query rather than the network peer record.
Tauri commands: `list_discover`, `ignore_peer`, `unignore_peer`,
`list_ignored_peers`.
Frontend:
- Following list: see-new-activity button pattern (staged data +
explicit user click to rearrange, so the list doesn't reorder under
a tap mid-scroll). Periodic people-tab polling stages + lights up
the button; clicking it re-renders.
- Discover: rewrites the old peer-table-based list to a profile-post
feed. Each card shows name + bio + profile-update age, plus Follow
/ Posts / Ignore actions.
- Bio modal: reuses the existing generic popover. Loads display name
+ bio via `resolve_display`, shows follow state, offers View Posts /
Follow-or-Unfollow / Message / Ignore-or-Unignore.
- Author filter: banner renders at the top of the feed when active;
clear button restores full feed. Filter state is a single `authorFilterNodeId`
field consumed by `filterFeedPosts`.
- Settings → Ignored section lists ignored peers with unignore buttons.
124 / 124 core tests pass.
Two bugs the v0.6.2 Discover story was going to expose:
1) Many named personas don't have a profile post yet, so the planned
profile-post-driven Discover listing would show them as headless.
Existing personas predate the Phase 2d profile-post primitive, and
imported personas arrive with a display_name but no matching post.
2) Fresh install always generates a blank disposable persona before
the user can pick fresh-vs-import. If the user picks import, that
blank persona lingers forever — visible in the Personas list, a
potential default, confusing.
Fixes:
Profile-post backfill (node.rs):
- New `Node::backfill_profile_posts_for_named_personas`: scans posting
identities, skips unnamed or already-covered ones, and emits a
signed profile post at the persona's own `created_at` timestamp. Run
once from `Node::open_with_bind` after migrations. Idempotent via
the new `Storage::has_profile_post_by_author` check. Chronology is
preserved (post timestamp matches persona creation), so a later
genuine profile update the user authors always wins the `> old_ts`
monotonicity check on receivers.
- `Node::create_posting_identity(name)`: if `name` is non-empty, emits
the profile post inline so new named personas are Discover-able the
moment they're created. Uses the new `publish_profile_post_as`
helper, which signs with the persona's own secret (not the default
posting secret) and propagates via `update_neighbor_manifests_as`.
First-run persona marker + targeted prune (node.rs + storage.rs + import):
- `Node::open_with_bind`'s auto-gen block now also writes
`first_run_auto_persona_id = <hex>` into the settings kv. This marks
the specific disposable persona from the fresh-install flow; later
prune logic uses this id, not a "any empty persona" rule, so manual
empty personas are never touched.
- `Node::try_prune_first_run_auto_persona`: deletes the marked id iff
all four gates pass — still exists, no display_name, no authored
posts, no authored reactions/comments, and it's no longer the
current default. Any one failure → clear the marker and keep the
persona. New storage helpers `has_any_post_by_author`,
`has_any_engagement_by_author` back the check.
- `set_profile` clears the marker when a non-empty name lands on the
marked persona (user claimed it).
- `storage.delete_setting(key)` — new one-line helper.
- `import_as_personas_cmd` in tauri-app calls the prune after import
completes; the cmd's return message reports "(cleared blank starter
persona)" when it fires.
- New `get_first_run_auto_persona_id` Tauri command so the frontend
can filter the blank persona out of the Personas list while it
still exists.
- Frontend `loadPersonas` filters the marker id out of
`personasCache` before rendering.
Tests: 124 / 124 core tests pass.
The anchor rotated its network key on 2026-04-22 22:57 UTC during the
v0.6.1 upgrade (keeping the old key as its posting identity). The
DEFAULT_ANCHOR constant was never updated, so every v0.6.1 and v0.6.2
client has been pinning the old cert identity when connecting, producing
a TLS "UnknownIssuer" handshake error.
Symptom: fresh clients can't bootstrap; existing installs drop when the
anchor's old connection times out and can't re-handshake.
Verified: rebuilt CLI with the new constant successfully connects to
the anchor, completes the initial exchange, registers as a mesh peer,
and runs a pull sync.
Note: `DEFAULT_ANCHOR_POSTING_ID` in lib.rs still holds the OLD key
(17af14...) — that's correct, it's the anchor's posting identity used
to verify signed announcements, distinct from the network key used for
QUIC cert verification.
New primitive: `VisibilityIntent::Announcement`, a public post whose
author MUST be the hardcoded bootstrap anchor posting identity
(`DEFAULT_ANCHOR_POSTING_ID`) and whose content carries an ed25519
signature by that key. Forged announcements (any other author, or
bad signature) are rejected by `control::receive_post` before storage
— they never enter the DB and never propagate via neighbor-manifest
diffs. Only the real anchor can publish announcements, and it does so
sparingly as part of the release deploy flow.
Uses release announcements to drive an in-app upgrade banner:
- Anchor publishes a signed `{category:release, version, channel,
download_url, ...}` post during every deploy.
- Clients receive it via the normal CDN; `apply_announcement_if_applicable`
stores the latest-per-category/channel in the settings kv, keyed
e.g. `announcement:release:stable`.
- Welcome screen checks storage on startup; if the stored release
version > CARGO_PKG_VERSION on the user's selected channel, a banner
appears with a Download button that opens the system browser.
- Settings gets "Updates" section with Stable / Beta radio + Check-now
button + current status line.
Core:
- `DEFAULT_ANCHOR_POSTING_ID: NodeId` constant (32 bytes, the anchor's
current posting id — `17af141956ae...`).
- New `VisibilityIntent::Announcement` variant; feed filters in all 6
`get_feed*` / `list_posts*` query sites updated to also exclude the
new intent AND the pre-existing `GroupKeyDistribute` intent.
- `types::AnnouncementContent` + `ReleaseAnnouncement` structs.
- `crypto::{sign,verify}_announcement` — length-prefixed field digest
with a "has release" 1-byte flag.
- New `announcement` module with `verify_announcement_post`,
`apply_announcement_if_applicable`, `latest_release`,
`build_announcement_post`, and a `StoredAnnouncement` envelope saved
to settings so the UI can render without a full post scan.
- `Node::publish_announcement` refuses to run unless the default posting
id equals the anchor constant — accidental use on client installs
fails loud.
Wire / receive:
- `control::receive_post` verifies announcement signatures upfront
alongside Control and Profile. Same pattern; same guarantees.
CLI one-shots (no daemon):
- `itsgoin <data_dir> --print-identity` — prints network_id +
default_posting_id, exits.
- `itsgoin <data_dir> --announce --ann-category release
--ann-channel stable --ann-version X --ann-title ... --ann-body ...
--ann-url https://itsgoin.com/download.html` — builds + stores +
propagates the signed post, exits.
deploy.sh:
- Now runs the announce one-shot inside the anchor-restart window
(after binary swap, before start). The DB is free during that gap,
so the one-shot can write without conflicting with the running
daemon. The restarted daemon loads all storage on boot and serves
the new announcement to pulling peers.
Tauri IPC:
- `check_release_announcement(channel)` → Option<ReleaseAnnouncementDto>
— returns None when up-to-date.
- `get_update_channel` / `set_update_channel(channel)` — persists in
settings kv key `ui_update_channel`; defaults to stable.
- `open_url_external(url)` — desktop-only (xdg-open / open / cmd start);
refuses non-http(s) URLs. Android needs the opener plugin — TODO.
Frontend:
- Upgrade banner on the welcome screen, populated by
`loadUpgradeBanner()`. Hidden when no newer release is known.
- Settings → Updates section with Stable/Beta radio + Check-now button
+ current status line.
Tests: announcement signature roundtrip; non-anchor author rejection;
non-announcement intent is a no-op. 124 / 124 core tests pass.
Removes the last persona-signed direct push on the wire. Group/circle
seeds no longer travel via the 0xA0 `GroupKeyDistribute` uni-stream from
admin to member. Instead the admin publishes an encrypted post containing
the seed + metadata; each member is a recipient; the post propagates via
the normal CDN. Members decrypt with their posting secret to recover the
seed.
Eliminates the wire-level coordination signal between an admin endpoint
and each member endpoint when a group is created, a member is added, or
a key is rotated.
Core pieces:
- New `VisibilityIntent::GroupKeyDistribute` variant.
- New `types::GroupKeyDistributionContent` — JSON payload inside the
encrypted post: group_id, circle_name, epoch, group_public_key, admin,
canonical_root_post_id, group_seed.
- New `group_key_distribution` module:
- `build_distribution_post(admin, admin_secret, record, group_seed, members)`
returns `(PostId, Post, PostVisibility::Encrypted)` — wraps the CEK
per member using standard `crypto::encrypt_post`.
- `try_apply_distribution_post(s, post, visibility, our_personas)`
iterates every posting identity's secret trying to decrypt; on
success stores `group_key` + `group_seed` and returns true.
- `process_pending(s, our_personas)` scans stored
GroupKeyDistribute-intent posts and applies any we can decrypt.
Node API:
- `add_to_circle`: builds a distribution post wrapping the current seed
to just the new member, stores with intent=GroupKeyDistribute, and
propagates via `update_neighbor_manifests_as` (no direct push).
- `create_group_key_inner`: at group creation, after wrapping keys for
every non-self member, builds one distribution post addressed to all
of them and propagates through the CDN.
- `rotate_group_key`: same pattern at epoch rotation.
- New `Node::process_group_key_distributions` — scans and applies.
`sync_all` now calls it automatically so seeds take effect right after
a pull cycle.
Removals (wire-breaking; v0.6.2 already forked):
- MessageType 0xA0 (`GroupKeyDistribute`), its payload struct, the
handler in connection.rs, and `Network::push_group_key` all deleted.
ConnectionManager's `secret_seed` (network secret) is no longer used for
group-key unwrapping — that shifted to posting secrets in the apply
pass, matching the v0.6.1+ identity split where group keys are wrapped
to posting NodeIds.
Tests: new `member_decrypts_and_applies` covers a recipient decrypting +
storing the seed and a non-recipient failing to apply. Workspace
compiles clean; 118 / 118 core tests pass on a stable run (pre-existing
flaky `relay_cooldown` test with a 1ms timing window is unrelated).
Introduces **groups** — a new many-way primitive anchored at a public root
post — reusing the existing circle encryption machinery. Circles stay
one-way (admin posts only, as before). Groups are distinguished from
circles by a single field: a non-null `canonical_root_post_id` on the
group-key record.
Type / schema changes:
- `GroupKeyRecord.canonical_root_post_id: Option<PostId>` (serde default).
When `Some`, the record represents a group rooted at that public post;
when `None`, it's a traditional circle.
- `group_keys` table gets a `canonical_root_post_id BLOB` column + an
index `idx_group_keys_root` on it. Migration added for upgraded DBs;
the CREATE TABLE statement carries the column so fresh DBs match.
Wire:
- `GroupKeyDistributePayload` gains an optional `canonical_root_post_id`
field. v0.6.1 peers will deserialize it as absent and continue treating
the record as a circle. All three sender sites (new-circle distribute,
add-member distribute, epoch-rotation distribute) pass the field through.
Storage:
- `create_group_key` / `get_group_key` / `get_group_key_by_circle` write
and read the new column. Added a shared `row_to_group_key` helper so the
two lookup functions don't drift.
- New `get_group_by_canonical_root(root_post_id)` — the inverse lookup
used by posting / retrieval flows.
Node API (new):
- `create_group_from_post(root_post_id, initial_members)` — creates a
backing circle named `group:<6-byte-hex-of-root>`, initializes the
group key with `canonical_root_post_id` set, and invites each initial
member (reusing `add_to_circle`'s wrap+distribute path so members get
the seed on the wire). Returns `(GroupId, circle_name)`.
- `post_to_group(root_post_id, content, attachments)` — any member with
the group seed can call this. Looks up the group by root, routes
through `create_post_with_visibility(Circle(name))` (which already
chooses `GroupEncrypted` when the seed is present), then stores a
`ThreadMeta` row linking the new post back to the canonical root so
retrieval can reconstruct the group.
- `list_group_posts_by_root(root_post_id)` — returns all contributions
via the ThreadMeta parent index. Callers decrypt normally; members see
full content, non-members see encrypted blobs.
Shared plumbing:
- `create_group_key_for_circle` now delegates to a shared
`create_group_key_inner(circle_name, canonical_root)` helper, keeping
one place where the record is constructed and the seed is persisted.
Notes:
- No crypto change: groups use the same `GroupEncrypted` primitive
circles already used. The "admin-only post" restriction on circles was
a UX choice, not a cryptographic limit — groups expose the many-way
path directly by letting any member with the seed call `post_to_group`.
- ThreadMeta is the clustering primitive. It already existed for split
comment threads; groups reuse it so the query pattern
("posts whose parent is root X") stays in one place.
- Frontend UI for groups is deferred — the backend surface is complete
and exercise-able via Tauri/CLI.
Tests: new storage test asserts canonical_root lookup round-trips and
that circles (no root) are invisible to the root lookup. 117 / 117 core
tests pass.
A comment can now reference a separate Post that carries the full body
(long text, attachments, rich formatting). The inline comment's `content`
becomes a short preview string; the referenced post propagates through the
normal CDN and readers fetch it lazily when rendering the expanded view.
Type change:
- `InlineComment` gains `ref_post_id: Option<PostId>` (#[serde(default)]).
When None, `content` is the full comment text (v0.6.1 shape — unchanged on
the wire). When Some, `content` is the preview.
Signature binding:
- `crypto::sign_comment` / `verify_comment_signature` now take
`ref_post_id: Option<&PostId>`. The signed digest appends
`b"ref:" || ref_post_id` only when a ref is present, so plain comments
produce the same digest as the v0.6.1 scheme and remain verifiable
without a migration. When a ref is present the signature binds it, so a
peer can't strip or swap the reference without re-signing.
Storage:
- `comments` table gets a `ref_post_id BLOB` column (nullable). Added to
both the CREATE TABLE statement and a conditional ALTER TABLE migration
so upgraded DBs pick it up automatically.
- `store_comment`, `get_comments`, `get_comments_with_tombstones` read and
write the column.
Node API:
- `comment_on_post` stays as the plain-comment entry point (calls the
inner helper with `ref_post_id = None`).
- New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich
comments. Both share a single inner helper that signs, stores, and
propagates via BlobHeaderDiff.
connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()`
to the signature verify so forged or rewritten refs are rejected.
Tests: new crypto test asserting the signature binds ref_post_id (strip /
swap / drop all fail); new storage test asserting ref_post_id roundtrips
through live + tombstone reads. 116 / 116 core tests pass.
Client-side UX (pulling the ref post on expand, composing rich comments)
is frontend work that will land with the next UI iteration.
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.
Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
content field. Ed25519 signature by the posting secret over length-prefixed
display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
`apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
as Control) so bogus signatures never enter storage, and applies them
after store so the `profiles` row updates atomically with the insert.
Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
intent=Profile, applies it locally (upserts the profiles row keyed by the
posting identity), then propagates via `update_neighbor_manifests_as`.
Stops calling `network.push_profile` — display changes no longer trigger
a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
`node_id`, matching where the row is written now.
ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.
Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
v0.6.2 wire fork: every persona-identifying direct push is gone. Public posts
propagate only through the CDN (pull + header-diff neighbor propagation).
Encrypted posts propagate only through pull with merged author-or-recipient
match. There is no remaining sender→recipient traffic correlation signal on
the wire for content.
Protocol (network-breaking):
- Retire MessageType 0x42 (PostNotification), 0x43 (PostPush),
0x44 (AudienceRequest), 0x45 (AudienceResponse). Their payload structs are
deleted along with the handlers and senders.
- SocialDisconnectNotice (0x71) / SocialAddressUpdate (0x70) sender
functions targeting audience are deleted; the existing handlers stay
(both already dead code on the send side).
Core removals:
- `push_to_audience`, `notify_post`, `push_delete`,
`push_disconnect_to_audience`, `push_address_update_to_audience`,
`send_audience_request`, `send_audience_response`, `send_to_audience` —
all gone from network.rs.
- `handle_post_notification` removed from connection.rs.
- `request_audience`, `approve_audience`, `deny_audience`,
`remove_audience`, `list_audience_members`, `list_audience` removed from
Node.
- `audience_pushed` step removed from post creation.
- `AudienceDirection`, `AudienceStatus`, `AudienceRecord`,
`AudienceApprovalMode` removed from types.
- Storage: `store_audience`, `list_audience`, `list_audience_members`,
`remove_audience`, `row_to_audience_record`, `audience_crud` test, the
`audience` CREATE TABLE, and the audience-dependent social route rebuild
branch all removed. Upgraded DBs retain the orphan `audience` table;
nothing touches it.
Follow-on cleanups:
- `SocialRelation::Audience` + `::Mutual` collapsed into just `Follow`.
The Display/FromStr impl accepts legacy "audience"/"mutual" strings from
pre-v0.6.2 DBs and maps them to Follow.
- Blob-eviction priority function drops the audience factor; relationship
is now own-author vs followed vs other. Tests updated accordingly.
- `CommentPermission::AudienceOnly` → `FollowersOnly`. Check uses the
author's public follows (`list_public_follows`) rather than a separate
audience table. `ModerationMode::AudienceOnly` similarly renamed.
- Follow/unfollow routines simplified: no audience downgrade logic;
unfollow removes the social route entirely.
UI:
- CLI: `audience*` commands removed.
- Tauri: `AudienceDto`, `list_audience`, `list_audience_outbound`,
`request_audience`, `approve_audience`, `remove_audience` commands
removed from invoke_handler. Frontend: audience panel and audience/mutual
badges removed; compose permission dropdown shows "Followers" instead of
"Audience"; `loadAudience` is a no-op stub that hides any leftover DOM.
Tests: 111 / 111 core tests pass.
Breaking change: v0.6.2 nodes won't interoperate with v0.6.1 for delete
propagation, visibility updates, direct post push, post notifications, or
audience requests. Upgrade both ends.
Replaces two persona-signed direct pushes with CDN-propagated control posts:
a single `VisibilityIntent::Control` post type whose content is a signed
`ControlOp` the receiver verifies and applies. Deletes and visibility updates
now flow through the same neighbor-manifest CDN path as regular content — no
direct recipient push needed for persona-signed ops.
Core pieces:
- `VisibilityIntent::Control` + `VisibilityIntent::Profile` variants.
- `ControlOp::DeletePost` / `ControlOp::UpdateVisibility` (JSON, ed25519-signed
by the target post's author over op-specific byte strings).
- `crypto::{sign,verify}_control_{delete,visibility}` signing primitives.
- `control::build_delete_control_post` + `build_visibility_control_post`
for authors to construct control posts.
- `control::receive_post` — unified incoming-post path used by all 6 receive
sites. Verifies control signatures BEFORE storing, so bogus controls never
enter storage and can't be re-propagated via neighbor-manifest diffs.
- `control::apply_control_post_if_applicable` — executes the op under the
same storage guard as the insert.
Feed filter:
- Feeds (`get_feed`, `get_feed_page`, `list_posts_page`,
`list_posts_reverse_chron`) now exclude `Control` and `Profile` posts so
they propagate + tombstone without surfacing.
- Sync/export path (`list_posts_with_visibility`) keeps its own unfiltered
query so control posts still propagate via CDN.
Wire protocol:
- `SyncPost` carries `intent: Option<VisibilityIntent>` so control posts
arrive with their intent preserved.
- `BlobDeleteNotice` (0x95) removed — orphan blobs on remote holders evict
naturally via LRU rather than via a persona-signed push. Code path,
payload, sender, tests, and `delete_blob_with_cdn_notify` all gone.
Tests: control delete roundtrip (apply + tombstone) and wrong-author
rejection (not stored, not applied). 112/112 core tests pass.
Fresh installs now generate two independent ed25519 keys — one as the
network (QUIC) identity in identity.key, and a SEPARATE one as the
default posting identity in posting_identities. They share nothing.
v0.6.0 upgraders: if the default posting key equals the network key
(the state Phase 4's migration left us in), rotate identity.key to a
fresh random value. The old key stays in posting_identities as the
default persona — peers keep seeing the same author on our posts; only
the QUIC NodeId changes. A one-shot reconnect-churn on upgrade, then
back to normal.
Storage:
- Drop seed_posting_identity_from_network (v0.6.0-specific helper)
- Add count_posting_identities()
Node::open_with_bind:
- Load identity.key (network secret — network-only from now on)
- Ensure posting_identities has at least one entry; if empty, generate
an INDEPENDENT random posting key as the default
- Detect default-posting-key == network-key collision and rotate
identity.key, logging the migration
- default_posting_id / default_posting_secret resolved from storage
Decrypt:
- decrypt_posts now takes &[PostingIdentity] and tries each held
persona as a recipient candidate. Past DMs to any persona on this
device (including ones added via Import as personas) decrypt
correctly. Callers pre-load list_posting_identities() alongside
group_seeds.
- decrypt_just_created looks up the author's specific posting identity
rather than assuming the default.
Profile broadcasts (wire-level privacy):
- Profile stays keyed by network NodeId — the field is load-bearing
for N1/N2/N3 social routing (anchors/recent_peers/preferred_peers
feed build_preferred_tree_for and peer-anchor reachability lookup).
- But push_profile and InitialExchange now STRIP display_name, bio,
and avatar_cid before sending, via new
PublicProfile::sanitized_for_network_broadcast(). A name attached to
the network id would correlate the QUIC endpoint to a human. Until
v0.6.2 introduces persona-signed profile posts, peers display
authors as hex.
Auto-follow only the default posting id (network id is never an
author, following it would be dead weight).
All 111 core tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Users can now hold multiple posting identities on one device and
publish content under any of them. Each persona has its own ed25519
key; peers see them as distinct authors with no link back to the
device's network identity.
Node methods:
- list_posting_identities() -> Vec<PostingIdentity>
- create_posting_identity(display_name) — generates a fresh ed25519
key, persists, auto-follows self
- delete_posting_identity(node_id) — refuses to delete the default
- set_default_posting_identity(node_id) — validates identity exists;
Node's cached default_posting_id/secret picks up on next restart
- create_post_as(posting_id, content, intent, attachments) — routes
through a shared create_post_inner that takes posting_id +
posting_secret as parameters
Post creation pipeline:
- create_post_with_visibility now delegates to create_post_inner
using default_posting_id/secret
- create_post_inner threads posting_id / posting_secret through
every content-signing, encryption, manifest, blob-header, and
CDN-manifest step — the persona is fully honored end to end
- update_neighbor_manifests now takes a posting_id param too, so
posts from persona X only update neighbor manifests for X's own
prior posts
Tauri IPC:
- list_posting_identities / create_posting_identity /
delete_posting_identity / set_default_posting_identity
- create_post_as with posting_id_hex + the same visibility params
as create_post
CLI:
- personas / create-persona <name> / delete-persona <id>
- post-as <posting_id> <text>
Smoke-tested two-persona scenario:
- A creates "Work" persona; posts from default and Work
- B follows both; pulls from A; gets all three posts
- Authors are AB84BA... (Work) and 7CD949... (default) — distinct
on the wire
Frontend UX (Settings > Personas, compose picker, filter pills,
merged feed labels) is scoped as a separate commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Decouple the signing identity from the network identity. This phase
ships the plumbing only — every device still has exactly one posting
identity, copied from the network key on first 0.6.3 launch so all
existing signed content keeps verifying. Phase 5 builds the
multi-persona UX on top.
Types:
- New PostingIdentity struct: { node_id, secret_seed, display_name,
created_at }
Storage:
- New posting_identities(node_id, secret_seed, display_name,
created_at) table
- Methods: upsert / get / list / delete posting identities;
get/set default posting id (stored in settings)
- seed_posting_identity_from_network: idempotent migration inserts
the network key as the single posting identity and sets it default
on first 0.6.3 launch
Node:
- default_posting_id + default_posting_secret fields populated on
startup via the migration
- All content signing / encryption / key wrapping now uses
default_posting_secret; the old Node.secret_seed field is gone
(iroh holds the network secret internally)
- author field on all locally-created content is now
default_posting_id (equal to node_id for upgraders until Phase 5
introduces separate personas)
- Auto-follow-self covers both network_id and default_posting_id
(same in 0.6.3, may diverge in 0.6.4+)
Export/import:
- Bundle now includes posting_identities.json in
IdentityOnly / PostsWithIdentity / Everything scopes
- restore_posting_identities(zip, storage) reads and upserts on
import
Smoke-tested:
- Fresh 0.6.3 install: posting_identities seeded from network key;
default set; new post's author = default_posting_id = network_id
- Two-node pull sync: B pulls A's post, signature verifies across
the wire
- 111 core tests pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The file_holders table is now the only tracker of per-file peer
relationships. post_upstream, post_downstream, blob_upstream, and
blob_downstream are dropped at first launch after the seed migration
copies any existing entries.
Schema:
- DROP TABLE IF EXISTS on all four legacy tables after seeding
- Seed migration guards with sqlite_master table_exists check so fresh
installs don't crash trying to read non-existent sources
- Remove CREATE TABLE statements for the four tables from init
- Remove Protocol v4 Phase 6 post_upstream priority migration (dead)
- Remove blob_upstream preferred_tree column migration (dead)
Rust:
- Remove add/get/remove post_upstream, post_downstream,
blob_upstream, blob_downstream methods
- Remove get_blob_upstream_preferred_tree / update variant
- Rewrite get_eviction_candidates's downstream_count subquery to
count file_holders entries
- Rewrite apply_delete's cascade cleanup to clear file_holders
instead of post_upstream/post_downstream
- cleanup_cdn_for_blob now clears file_holders for the CID
Callers:
- All dual-write sites in connection.rs and node.rs now do
touch_file_holder only (legacy writes removed)
- get_stale_manifests replaced with get_stale_manifest_cids; caller
in node.rs picks a refresh source from file_holders
Tests:
- Remove blob_upstream_crud, blob_downstream_crud_and_limit,
blob_upstream_preferred_tree, remove_blob_upstream,
post_downstream_crud
- Add file_holders_lru_cap and file_holders_direction_promotion tests
All 110 core tests passing. Workspace compiles clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switch ALL propagation-decision reads to the flat holder set.
push_manifest_to_downstream now targets file_holders instead of
blob_downstream. ManifestPush receive-side relay likewise — known
holders fan out to up to 5 most-recent peers instead of a directional
tree.
Blob delete notices: single flat fan-out to file_holders; the legacy
upstream_node tree-healing field is emitted as None (wire-stable via
serde default) and ignored on receive — the post-0.6 flat model
doesn't need sender-role distinction. send_blob_delete_notices keeps
its Option<&Upstream> parameter as an unused placeholder for signature
stability with the call sites in this commit.
Other reads migrated:
- blob fetch cascade: step 2 now tries "known holders" (up to 5)
instead of a single upstream
- manifest refresh: downstream_count reported from file_holder_count
- web/http post holder enumeration
- Worm search post/blob holder fallback (both connection.rs paths)
- DeleteRecord fan-out rewires to file_holders
- Under-replication replication check: < 2 holders
Storage additions:
- get_file_holder_count(file_id)
- remove_file_holder(file_id, peer_id)
Legacy upstream/downstream writes are still happening from Phase 2b;
those + the tables themselves go in 2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
propagate_engagement_diff now targets the post's flat holder set (up to
5 most-recent) instead of the post_downstream directional tree. The
holder set naturally subsumes the old upstream+downstream partition, so
the separate "also send to upstreams" loops at each engagement call
site are removed (reactions, comments, comment edit/delete, receipt
slots, comment slots).
handle_blob_header_diff on receive:
- records the sending peer as a file holder (an engagement exchange is
proof the peer holds the post)
- re-propagates to the holder set minus the sender
Writes to post_upstream / post_downstream still occur from Phase 2b
(dual-write); those and the legacy tables will be removed in 2e.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Populate the flat holder set alongside every existing post_upstream /
post_downstream / blob_upstream / blob_downstream write so that read
paths can be switched over in the next commit without losing continuity.
Events wired:
- Pull sync receive (3 paths in connection.rs)
- PostPush receive (public posts only after Phase 1)
- PostFetch via notification (discovery pull)
- PostDownstreamRegister
- Replication accept (downstream) + replication-driven pull (upstream)
- Attachment upstream recorded after replication blob fetch
- ManifestPush receive (remote is a CID holder)
- ManifestPush send (downstream peer becomes CID holder)
- Blob fetch fallback (upstream lateral sources)
Direction is tracked as Received vs Sent. Not load-bearing for routing;
retained for future use. LRU cap of 5 enforced on every touch.
Legacy upstream/downstream writes remain in place; they'll go away
together with the table drops at the end of this phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Encrypted posts now propagate only via the CDN (ManifestPush + neighbor
header updates), eliminating the sender→recipient traffic signal on the
wire. Encrypted DMs are indistinguishable from any other encrypted post.
- Remove push_post_to_recipients entirely from network.rs
- Remove call sites in create_post and re-encrypt-on-revoke
- PostPush handler now ignores non-public visibility (kept for public
audience push path)
Known gap: non-follower DMs won't reach until Phase 3 (merged pull +
recipient-match). Followers receive via the existing CDN path — new
posts trigger neighbor-manifest updates, ManifestPush fans out to
downstream holders, recipients pull missing post IDs from followed
authors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feed pagination:
- Cursor-based pagination: get_feed_page/get_all_posts_page (20 posts/page)
- Batched engagement queries (3 bulk SQL queries instead of 4 per post)
- IntersectionObserver for infinite scroll (sentinel at midpoint)
- Viewport-based media loading (blobs only load when post enters view)
- Pre-fetch next page immediately after current page renders
Duplicate identity detection:
- Anchor detects when a NodeId is already mesh-connected during initial
exchange and sets duplicate_active flag in response
- Client skips sync tasks when duplicate detected
- Frontend shows red warning banner
Privacy:
- Fixed pkarr leak: clear_address_lookup() removes default dns.iroh.link
publishing. Only mDNS (local network) discovery enabled.
Android:
- SAF integration via tauri-plugin-android-fs: exports open native "Save As"
dialog so users can save to Downloads/Drive/etc.
- Download/export paths use app data dir on Android (writable)
- File picker gated behind desktop cfg (blocking_pick not on Android)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Node::open_with_bind no longer runs bootstrap (anchor connect, NAT
probe, referrals). New run_bootstrap() method called from background
task after UI is live.
- Background tasks (pull cycle, diff cycle, etc.) start after bootstrap
completes, not during block_on.
- Feed no longer pre-loaded during welcome screen readiness check.
Ready button enables immediately after get_node_info succeeds.
- Feed loads on tab switch, not during startup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Merge-with-key: decrypt exported posts using original identity seed, re-create
under current identity with prior_author in BlobHeader for provenance tracking.
Download page restructured with stable (v0.4.4) + beta (v0.5.0-beta) sections.
Version bumped across all crates.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Per-peer sync (People tab Sync button) now resets last_sync_ms to 0 so
the responder sends ALL posts, not just posts newer than last sync.
ManifestPush post discovery now fetches blobs alongside discovered posts
while the connection is live, instead of waiting for the next prefetch cycle.
Hole punch: filter_reachable_families() checks endpoint bound sockets and
removes addresses the local endpoint can't reach (IPv4-only won't try IPv6).
Applied to hole_punch_single, hole_punch_parallel, hole_punch_with_scanning.
Relay introduce paths switched from is_publicly_routable to is_shareable_addr
so LAN addresses (192.168.x.x) are included for same-WiFi hole punching.
is_shareable_addr made pub(crate).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Eliminate all conn_mgr lock holds during network I/O across 14 actor commands
and bi-stream handlers. PostFetch, TcpPunch, PullFromPeer, FetchEngagement,
ResolveAddress, AnchorProbe use brief locks for data gathering only. WormLookup,
ContentSearch, WormQuery use connection snapshots for lock-free cascade fan-out.
RelayIntroduce extracts forwarding data under brief lock, does I/O outside.
BlobRequest, PostFetchRequest, ManifestRefresh use Arc clones instead of conn_mgr
lock. ConnectionActor hoists shared Arcs (storage, blob_store, endpoint) for
lock-free access. ResolveAddress adds 5s per-query timeout (was unbounded).
Initial exchange failure now aborts mesh upgrade (was silently continuing with
broken connection). connect_to_peer/connect_to_anchor use consistent 15s timeout.
Rebalance connects outside the lock via pending_connects pattern.
StoragePool: 8 concurrent SQLite connections in WAL mode replace single
Mutex<Storage>. Reads run fully parallel; writes serialize at SQLite level only.
PRAGMA busy_timeout=5000 for graceful write contention.
Mobile bottom nav bar (<=768px) with icon tabs. Text sizes: XS/S/M/L/XL
(75%/100%/125%/150%/200%), default M. localStorage persistence for instant
restore. Toast repositioned above mobile nav.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Security:
- Reaction signatures: ed25519 sign/verify (sign_reaction, verify_reaction_signature)
Backward-compatible — unsigned reactions from old nodes still accepted
- Comment signature verification: verify_comment_signature now called on receipt
- Reaction removal authorization: only reactor or post author can remove
- BlobHeader author verification: lookup actual author from storage, don't trust payload
Lock contention (4 fixes):
- ManifestPush discovery: cm lock released before PostFetch I/O
- Pull request handler: load under lock, filter without lock, brief re-lock for is_deleted
- Pull sender: split into two brief locks (store posts, then batch upstream+sync)
- Engagement checker: batch all chunk results, single lock for writes
Data cleanup:
- Post deletion cleans post_downstream, post_upstream, seen_engagement tables
- Added TODO-hardening.md documenting remaining DOS/security/lock/data issues
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>