Real gap: there was no way to stop a specific peer's noise (new
readings, replies, hearts) from showing up, short of disconnecting
them entirely. Mute is deliberately narrower than a network-level
block: their content is still fully synced and stored (protocol
correctness for the rest of the network is untouched), only the local
social feed and store insertion for their InterpAuthored/AffirmAdded/
ResponseAdded activity is suppressed. Collaborative-doc mechanics,
display names, and circle invites always apply regardless — muting
someone's chatter shouldn't corrupt a shared document or hide a
functional message addressed to you.
- store: new `muted_peers` table + mute_peer/unmute_peer/muted_peers,
covered by a roundtrip unit test.
- app/feed_item: `is_muted_social_event` is a small pure predicate,
unit-tested directly (red-first: wrote the five cases, then the
three-line match) rather than needing network-level BDD coverage
for what's an entirely local, app-layer concern.
- app: AppModel caches the muted set in memory (loaded once at start)
so the hot SyncStateEvent path never awaits a DB round-trip; a new
"Mute"/"Unmute" button on each connected peer's sidebar row (dimming
their name when muted, mirroring the existing pending-row dim-label
convention) toggles it.
Wires the backend self-leave capability (see the prior BDD commit) into
circle_page.rs: your own row shows "(You)" and, unless you're the
circle's manager, a "Leave circle" button in place of the revoke
control (leaving as the sole manager would orphan the circle, so that
path isn't offered). Leaving asks for confirmation via a destructive
adw::AlertDialog, then drops the now-stale page from the content stack
and navigates back to Chart if it was the visible page.
Also hides the "Revoke access" button on other members' rows unless
the local viewer is themselves a manager — the backend already
rejects a non-manager's revoke attempt, so showing it to everyone was
a dead-end click.
The legacy InterpOp::Affirm path always correctly computed whether an
affirmation targeted the local user's own content, but StateEvent::
DocAffirmed — the path every current edit actually goes through, since
0.9+ writes exclusively use the collaborative doc model — hardcoded
targets_me = false unconditionally. The bell badge and feed targeting
have never actually detected a heart on your own writing for the
model real users hit today.
The collaborative model has no single "author" the old check relied
on, so added ZodiaStore::doc_has_contributor (does this peer have any
block still in interp_key's author ring) as the closest equivalent —
test-driven, including a case proving the ring holds multiple
contributors rather than getting treated as single-author.
Also surfaces a real-time "X affirmed your reading" toast reusing the
ToastOverlay infrastructure from the circle-invite feature. Per
explicit feedback: purely-informational events like this are a better
fit for a desktop notification (notify::send) than an in-app Toast —
shipping as a Toast for this release since the app is early-stage, and
flagged as something to reconsider if it feels tiresome moving
forward, not changed now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires the previous commit's backend into the actual app: inviting
someone now also calls notify_circle_invite right after
invite_to_circle succeeds, and the recipient's own client shows an
adw::Toast ("X invited you to a circle") with a Join button — no
timeout, since an invite shouldn't silently expire the way a status
message would. Accepting it calls open_circle, then asks the new
member to name the circle for their own sidebar (the inviter's name
for it is intentionally never synced — see the backend commit's note
on why circle names stay local-only).
Required wrapping the whole window's content in an adw::ToastOverlay
(previously nothing in the app used AdwToast at all) so a toast can
surface from any page, not just one specific tab — general-purpose
infrastructure the next notification-worthy event can reuse without
repeating this setup.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A ninth gap in the same vein — a placement at 29° of any sign is a
well-known, unambiguous astrological concept (urgency/culmination
themes) that needed nothing beyond the longitude already computed for
every placement. Considered and deliberately skipped void-of-course
Moon in this same pass: it needs real-world validation data (known VOC
windows) I don't have access to, and getting timing advice wrong is a
different risk class than a decorative label — astrologers actually
use VOC for real decisions. Critical degree has no such risk: it's
pure, unambiguous arithmetic on data already computed.
Test-driven against the real edge case (30.0° must read as 0° Taurus,
not critical, not as "still near a 29 boundary" from a sloppy
implementation checking the wrong modulus).
Shown as a "· critical degree" note on the affected placement's degree
label (planets and angles both), plus added to the glyph legend from
the previous commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
chart.houses.cusps has held all 12 house cusp longitudes since houses
were first implemented, but the only place that ever read it was a
stub-detection check (all-zero cusps == geohash too coarse). A full
per-house sign+degree table is a standard chart display every other
astrology tool provides, and this app had the data for it the whole
time with nowhere to put it.
Along the way, added an explicit test for something only ever implied
by other passing tests: House 1's cusp is exactly the Ascendant's
degree in every degree-based system (Placidus/Koch/Equal). The first
version of this test included Whole Sign too and failed — correctly:
Whole Sign houses start at the *sign boundary*, not the Ascendant's
literal degree, a deliberate astrological convention, not a bug. Fixed
by scoping the test to the three systems where the invariant actually
holds, rather than "fixing" correct behavior to match a wrong
assumption.
Surfaced as a new "Houses" section on the Chart tab, hidden for stub
charts (no real houses to show).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every tab shows dense astrological notation (planet symbols, aspect
glyphs, sign glyphs, now also retrograde marks and Moon phases) with
zero explanation anywhere in the app — a real barrier for anyone not
already fluent in the notation, and the opposite of the "genuinely
social, welcoming to newcomers" experience the app is going for.
Adds a "?" button to every tab's header (shared via make_tab_toolbar,
so it's consistent everywhere rather than duplicated per page) opening
one dialog covering all four categories. Built from AspectKind::all()
(newly exposed — the enumeration already existed privately for
detection) and Planet::all(), so it can't silently drift out of sync
with what the app actually renders the way a hand-maintained list
would; a new aspect kind shows up here automatically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
compute_aspects already finds every individual aspect within orb, but
nothing looked for the specific *combinations* of three that form the
named geometric configurations astrologers specifically watch for.
Adds zodia_core::detect_patterns: Grand Trine (three planets mutually
trine) and T-Square (an opposition, both ends squared by a third —
the "apex"). Test-driven against both the positive shape and the
negative space around it — two legs of a triangle without the third,
or an opposition with only one of the two required squares, correctly
detect nothing rather than a false positive.
Added Ord/PartialOrd to Planet (pure enum, no fields, safe to derive)
so pattern-matching aspect pairs could use a plain canonical
(min, max) ordering instead of a string-comparison workaround.
Surfaced as extra rows in the Chart tab's Balance section alongside
stelliums — silent when a chart has neither pattern, the common case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sun sign, Moon sign, and Ascendant sign were each already computed
separately (placements, houses) but never combined into the single
most commonly asked-for astrology summary in casual/pop use ("what's
your Big Three?"). Adds Chart::big_three(), test-driven against the
real bug class this kind of code invites: three near-identical
longitude lookups where a copy-paste swaps which field gets which
body — the test uses three signs far enough apart that a swap would
be caught immediately, not two similar ones that might coincidentally
still pass.
Shown as a single prominent centered line at the very top of the Chart
tab, above Balance and Placements — the first thing a user sees.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A fourth classic natal-chart pattern with no prior support — "you have
a stellium in Capricorn" is a well-known astrological observation this
app had no way to surface despite already deriving each planet's sign
and house. Adds zodia_core::stelliums_by_sign/stelliums_by_house
(conventional 3-body minimum), test-driven with synthetic
PlanetPositions so the grouping logic is verified independent of real
ephemeris output. House-based grouping correctly returns empty for
stub charts (all-zero cusps) rather than producing meaningless groups
from house 0 for every planet.
Surfaced as extra rows in the Chart tab's Balance section, one per
detected stellium — silent (no rows) for charts with no stellium,
which is the common case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A third fundamental astrology concept Zodia never computed, despite
already deriving each planet's sign for placements. Adds
zodia_core::natal_balance — counts of the 10 tracked planets across
the four classical elements (Fire/Earth/Air/Water) and three
modalities (Cardinal/Fixed/Mutable), derived directly from each sign's
fixed element/modality (no ephemeris dependency, pure classification).
Test-driven against the actual classical mapping table, plus a
dedicated check on Cancer specifically since index 3 is where the
4-sign element cycle restarts — the kind of boundary an off-by-one
would hide in a purely cyclic-pattern test.
Surfaced as a new "Balance" section on the Chart tab, above Placements
— two plain summary rows, no InterpKey plumbing needed since this is a
derived summary rather than contributable content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same gap as retrograde: Zodia already computes Sun and Moon longitude
for every chart, but never derived phase from their angular separation
despite it being one of the most universally recognized astrology
concepts. Adds zodia_core::moon_phase (8-phase enum from the Sun-Moon
separation angle), test-driven: the first test revealed a real bug in
the *test's* own scan logic, not the implementation — New Moon's 45°
segment straddles the 0/360 wraparound, so a naive linear scan across
one cycle sees it twice non-contiguously. Fixed by checking phase
reachability as a set instead of an artificial "N contiguous runs"
count, which was fighting the circularity rather than testing it.
Surfaced in two places: the natal Placements section now shows "Born
under a [phase] Moon" (a real, commonly-discussed birth-chart trait),
and the Sky tab shows today's live phase at the top — useful with zero
natal data at all, and changes slowly enough (~3.5 days between named
phases) that no ticker is needed, just computed once per page build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Zodia tracked zero retrograde-motion awareness anywhere in the
codebase — a real gap for an astrology app, since retrograde Mercury
is one of the most commonly discussed astrological concepts. Adds
zodia_core::is_retrograde(planet, jdn), detected numerically (sign of
the one-day-back longitude delta, handling 0/360 wraparound) since the
simplified ephemeris model has no per-body analytic velocity term.
Verified against real-world retrograde patterns before writing
assertions: Mercury shows ~69 retrograde days/year in this model
(matches its real ~3-4 annual retrograde periods), outer planets
substantially more (Earth laps them yearly), Sun/Moon never (a real
geocentric-astrology invariant, not a model artifact).
Surfaced as a classical "℞" glyph next to any retrograde planet on the
Chart tab's placement rows.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Circles had no real front door — the only way to share into one was to
publish/edit a reading, then go find it in the Sky feed and notice a
small avatar icon on your own card. Adds a "Share to a circle" button
directly on every aspect/placement detail page, reusing the existing
OpenShareToCirclePicker flow (loads the doc's current body when the
page hasn't sent one, same as the feed-card path) — no new backend
plumbing needed, just a second entry point into what already worked.
Also: "Talk about this" (the voice-room button, previously "Start
discussion") silently no-op'd when nobody else was present on that
reading — confirmed by a real user clicking it and seeing nothing
happen. It now sends a notification explaining why, instead of failing
silently. Real fix (disabling the button until presence exists) is
follow-up UI work, tracked as a stopgap for now.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Start discussion" / "voice circle" and the Circles privacy feature
both used "circle" in their copy despite being completely unrelated —
confirmed confusing in practice: a real user clicked "Start discussion"
expecting to create a private circle and got a voice room instead.
Renamed to "Talk about this" / "voice room", no circle language left.
Also adds an optional display-name field to first-run setup, right
after the welcome header and before birth details — introducing
yourself before your chart, rather than leaving your own display name
undiscoverable behind a pencil icon on the Network tab (the only place
it lived before). Saved via the same LocalConfig::save_display_name
path the Network tab uses, and broadcast on the first network connect
like any other change.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds InterpOp::SetDisplayName (legacy log 0, always-on global topic —
same propagation as Author/Revoke), materialised as
StateEvent::DisplayNameSet and exposed as ZodiaClient::set_display_name.
Receivers persist the newest name per peer (last-writer-wins by the
op's own timestamp, not local receipt time, since local time would let
a replayed stale op incorrectly "win"). The GTK app resolves a shown
name as: local nickname > peer's broadcast name > truncated hex,
threaded through every place a peer's name was previously
hex-only. A pencil button next to the Network tab's status line lets
the user set their own name, persisted via a new display_name.txt in
LocalConfig and re-broadcast on every reconnect.
Also tags every zodia-sdk cucumber test peer with a reserved sentinel
name (zodia_sdk::TEST_PEER_DISPLAY_NAME) and has the app filter any
peer broadcasting it out of the Network tab's discoverable-peers list —
mDNS reaches the whole LAN, so a local test run was showing up as
"new people" in a real running instance on the same network. A local
nickname still overrides the filter, for a developer who wants to keep
a specific test peer visible.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The circle-share button only ever appeared on InterpAuthored cards
authored by the local user, but nothing in the GTK app calls author()
— PublishDocEdit is the only path, which produces DocEdited events.
Worse, DocEdited was never pushed to the editor's own feed (the
pipeline only echoes it back from peers), so a user's own edits never
appeared in their own feed at all. Net effect: the circle-sharing
feature was unreachable from the shipped UI.
Push a local FeedItem on PublishDocEdit (mirroring the existing
doc_rolled_back pattern), and let the share button also fire for own
DocEdited cards, loading the doc's current body on demand since that
payload carries no body of its own.
Also adds a BDD scenario proving revoke_from_circle (already
implemented but untested) actually stops a removed member from
reading subsequently shared content.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Chart/network/synastry recaptured against the current UI; adds a new
circles screenshot showing a real circle with an invited member.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Deterministic per-circle accent color (Signal/Matrix-style avatar
colors), derived from the circle id hash so it needs no storage of
its own - a small dot in the sidebar row and circle page header via
Pango markup, no CssProvider plumbing needed for a one-off color.
Invite now offers two buttons instead of one: read-only (the common
case) or read+write, turning a one-way share into a small
collaborative circle where invitees can post their own interpretations
back - same InterpOp::Author + share_interp_to_circle plumbing either
way, just a different Access level at invite time. No in-place
promotion yet: p2panda-auth's own add() errors with AlreadyAdded on an
existing member, so changing someone's level today means revoke then
re-invite (confirmed by reading the source, not assumed) - a real,
undocumented-until-now UX gap, not implemented in this pass.
Verified: full workspace build + test suite green, plus a second real
GUI smoke test confirming the accent dot renders correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Circles get their own sidebar section (own rows, one per circle),
positioned right below Chart/Sky/Network and above the "Others" (direct
peer) section — no bundled management tab, per direction mid-session.
Clicking a row (re)builds and shows that circle's page (plain
widget-builder function, mirroring stargazer_page.rs rather than a
relm4 sub-Component, since it only needs to fire AppMsgs on click):
member list with per-row revoke, and an "Invite" list of known
connected peers not already members.
Circle creation has no standalone form — it's folded into the
share-to-circle flow itself ("born out of" whatever's being shared):
the new share button on a feed card's own contributions opens a picker
(existing circles, or type a new name to create-and-share in one step).
New local-only persistence: circles.tsv (circle_id_hex -> name), same
flat-TSV pattern nicknames.tsv already uses for "hash has no name".
Verified: full workspace build + test suite green (including the
16-scenario circle-sharing cucumber suite), plus a real GUI smoke test
(Xvnc + screenshot) confirming the app launches clean and the Circles
sidebar section renders correctly and in the right position. Full
interactive click-through (invite/share dialogs) wasn't verified live
— no input-automation tool (xdotool etc.) was available in this
sandbox to drive clicks, only screenshot capture.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
zodia-circles needs p2panda-encryption for real group encryption, which
only ships at 0.7.0 and requires p2panda-core ^0.7.0 — the original
Phase D PRD's premise that this "wraps p2panda 0.6's group-encryption
primitives" was wrong, that crate doesn't exist at 0.6.x.
Header::timestamp was dropped upstream; replaced with zodia-ops's
OpExtensions carrying a Timestamp via the Extension trait, matching
p2panda-core's own documented extensions pattern. OperationStore lost
its log-id generic parameter, SyncHandle::publish became sync, SeqNum
narrowed to u32. The operations_v1.timestamp column was also dropped
from p2panda-store's schema, which broke prune_older_than at runtime
(caught by the existing pruning.feature cucumber scenarios, not by
compilation) — fixed by decoding the still-present header CBOR blob
per candidate row instead of filtering by a now-nonexistent column.
Full workspace test suite + the 16-scenario zodia-sdk cucumber suite
pass unchanged. Details in docs/prd/p2panda-0.7-migration.md.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Standalone asset for non-vector use (README, sharing). Rasterized
from the same canonical SVG the GTK icon theme and macOS CI packaging
already use directly — that pipeline is untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Completes docs/prd/granular-topic-subscription.md - the last unshipped
piece was app.rs actually calling touch_subscription when a
non-chart aspect page opens. The SDK primitive shipped two commits
ago; nothing called it yet.
BDD-driven: wrote needs_lazy_subscription(interp_key, chart) - a pure
function deciding whether a key is already permanently subscribed via
the user's own chart (false) or needs its own grace-limited
subscription (true) - and its two Given/When/Then tests first,
against a function that didn't exist yet (compile failure = red),
then implemented it and wired it in (green). 4 tests now in the app
crate's bin target, up from 2.
Wired via the same site that already announces EditorPresence join on
page open (aspect_view.rs's build_doc_reading_group) - sends
AppMsg::TouchKeySubscription, handled in app.rs by checking
needs_lazy_subscription and calling client.touch_subscription(key,
600s) if needed. 600s reuses transit_ticker.rs's existing
TICK_INTERVAL rather than a new constant.
Documented a real, deliberate deviation from the original PRD sketch:
touch_subscription's grace clock starts at first touch (page open),
not at leaving, since the SDK's current API can't cleanly distinguish
"still viewing" from "touched once." A single continuous page visit
longer than 10 minutes would let the subscription lapse mid-visit -
accepted as good-enough given typical visit lengths, with a documented
fix path (periodic re-touch) if it turns out to matter.
Verified: full workspace build clean, all tests green (14/14 cucumber
scenarios, 4/4 app-crate tests), and the compiled binary re-launched
under a real session a third time - ran stably for ~40 real minutes
before an unrelated sandbox pause/resume disrupted its network
namespace (gossip/iroh errors, no panics, no application-level
failures) - not a regression, the app handled it as designed
(retrying, then exiting cleanly rather than hanging).
Full-crate reachability check (every AppMsg variant, not just the
files touched by the migration) found AppMsg::{ShareInterp,
AffirmInterp, SubmitInterp, SubmitResponse} are never sent by any live
widget - the legacy "competing whole interpretations" write UI was
already fully superseded by the collaborative-doc model and had no
surface left to trigger it from. Deleted all four from app.rs, and
correspondingly ZodiaClient::affirm()/respond_to() + their Command
variants from zodia-sdk, since nothing in the workspace called or
tested them once the dead handlers were gone.
Correction: this also fixes the finding that started the app.rs->SDK
migration two commits ago. An earlier, narrower audit (grep across
only aspect_view.rs and app.rs) concluded SubmitRevoke had zero UI
trigger. Wrong - just an incomplete grep that missed feed_view.rs,
which has a real working "Revoke and delete" button on activity
cards, wired end to end. author()/revoke() were kept in zodia-sdk:
both have live call sites and real cucumber coverage
(author_propagation.feature, revoke_propagation.feature). The SDK
migration was still worth doing regardless (it fixed two real
architectural gaps - attach() and per-peer sync lifecycle events) but
the specific capability gap that motivated starting it didn't exist.
Verified: full workspace build clean (zero warnings), all tests green
(14/14 cucumber scenarios), and the compiled binary re-launched under
a real X11/Wayland session a second time post-cleanup - stable, no
panics, clean shutdown.
Completes the migration docs/prd/zodia-sdk.md always intended.
SyncPublishMsg, try_spawn_sync's hand-rolled ZodiaSyncNode+ZodiaPipeline
wiring, and the direct zodia_sync/zodia_pipeline imports in app.rs are
gone. app/Cargo.toml no longer depends on zodia-sync at all.
try_spawn_sync now calls ZodiaClient::attach(&net, ...) and bridges
client.events()/client.sync_lifecycle_events() into
AppMsg::SyncStateEvent/AppMsg::SyncLifecycle via tokio::spawn tasks -
same message types the rest of the app already handles, so no
downstream changes needed there. All 9 SyncPublishMsg::{Publish,
PublishDoc} call sites now call the matching ZodiaClient method
directly and await a real Result (logged on error) instead of
try_send-and-forget. subscribe_own_chart_keys awaits client.subscribe
per key.
Two real gaps in zodia-sdk found and fixed by actually attempting this
migration, not by design review:
1. ZodiaClient::connect always spawned its own ZodiaNetwork. app.rs
also needs ZodiaNetwork directly for Tier-1 consent/chat/AV, which
this SDK doesn't cover - two independent ZodiaNetworks under the
same identity would be wasteful and wrong. Added ZodiaClient::attach
(&net, signing_key, data_dir), reusing an existing network's
endpoint/gossip. run() restructured around a NetworkSource::
{Owned,Attached} enum - the Owned case must keep ZodiaNetwork bound
for run()'s whole lifetime, not just setup, since dropping it early
tears down discovery/mDNS.
2. sync_status() only exposed aggregate counts, but app.rs's existing
Network tab needs raw per-peer SyncStarted/Finished/Failed with
remote_pk and received_ops. Added SyncLifecycleEvent +
sync_lifecycle_events(), broadcast alongside the aggregate.
3. Two legacy ops (InterpOp::Affirm, InterpOp::RespondTo) had no
ZodiaClient method at all - this SDK's original scope missed two
live app.rs call sites. Added affirm() and respond_to().
Two new zodia-sdk unit tests cover attach() specifically, proving it
carries real sync traffic and not just constructs. Verified beyond
compilation: the compiled binary was actually launched under a real
X11/Wayland session (XDG_DATA_HOME pointed at a scratch dir, no real
user data touched), completed setup, spawned network via attach()
successfully, ran stably for several minutes with no panics, shut down
cleanly. Full workspace build + all tests (unit, integration, 14
cucumber scenarios) green throughout.
Still open: affirm()/respond_to() have no cucumber coverage yet
(added under time pressure to unblock this migration); the Lagged
backpressure test is still open too.
Core of docs/prd/granular-topic-subscription.md: derive p2panda log_id
from interp_key (zodia_ops::log_id_for_key) instead of the hardcoded
INTERP_LOG_ID=0, and add a per-key topic (topic_key_for_interp) so
subscribing to a key's topic actually scopes what replicates.
ZodiaSyncNode now holds a topic->SyncHandle map instead of one fixed
handle; subscribe/unsubscribe open and drop per-key topics on demand.
publish_doc (DocOp, the active write path) routes through the derived
log/topic; publish (legacy InterpOp) stays on log 0 over the global
topic, since existing signed ops can't be re-homed to a new log_id.
App wires the always-subscribed set: every key in the user's own
natal chart is subscribed right after sync spawns, on both cold-start
paths. On-demand subscribe for arbitrary aspect pages, the
grace-period unsubscribe timer, and mock-backed ZodiaSyncNode tests
are not yet implemented — tracked in the PRD's progress notes.
DocOp is a wire-format-incompatible addition that the PRD planned to
ship as 0.9.0 with a known-incompat note; it actually shipped as the
0.7.1 patch alongside the activity feed. Decode-time behavior is safe
(unrecognised ops are skipped, not fatal) but the version number
didn't carry the signal the PRD intended. Record what happened in the
PRD and add a compat note to the already-published 0.7.1 release
description.
Sky tab becomes a live activity feed (feed_view/feed_item/transit_ticker)
backed by store-side feed rows, with notification bell badge wiring.
Add zodia-doc crate: Loro-backed collaborative interpretation documents
with an author-veto ring, used by aspect_view and the store for shared
edit history.
core: add house_transit_window for house-based transit windows, and
Planet::from_name for parsing interp keys.
Adds PRDs for activity-feed, circles-mvp, and collaborative-interpretations.
RespondTo is now wired end-to-end so peers can riff on each other. A
response is its own first-class interpretation that hangs off a parent
via parent_log_id (BLAKE3 content-hash, same targeting model as Affirm).
Persisted in the existing interpretations table with a new
parent_log_id BLOB column; orphan responses (parent not yet known
locally) are still kept so they materialise once the parent arrives
via sync.
Pipeline gives us StateEvent::ResponseAdded already from Phase A; this
commit makes it do real work instead of being a no-op. The app side
handles SubmitResponse for the authoring path: local insert via
store.insert_response_from_op + sync publish as InterpOp::RespondTo,
so any peer subscribed to the global topic will see and persist the
same row.
Wire-format cleanup: RespondTo.parent_op_id renamed to parent_log_id
for consistency with Affirm.target_log_id. Both target content
hashes, not p2panda operation hashes (the rationale we settled on
when starting Phase B).
Store:
* New column parent_log_id BLOB on interpretations (best-effort
ALTER on init for legacy DBs)
* insert_response_from_op(parent_log_id, body, author_pk) — derives
the response log_id, copies the parent interp_key when known
("response" kind) or stores empty key with "response_orphan" kind
until the parent arrives
* responses_for(parent_log_id) — returns InterpRows ordered by
received_at ASC for chronological thread display
* row_to_interp_row helper extracted from the existing fan-out
UI (aspect_view.rs):
* Each community parent row gets a "💬" Respond button alongside the
Affirm button (baselines remain affirm-only, no response button)
* Click opens an adw::AlertDialog with a text entry; submit fires
AppMsg::SubmitResponse
* Responses appear immediately below their parent as smaller indented
ActionRows tagged with the responders 4-hex author tag, pre-fetched
from store.responses_for during detail-page build
* Live refresh after a fresh response submit isnt wired yet — the
user has to navigate away and back to see it. Documented in the
function comment; will land naturally when Phase D moves UI state
onto the pipeline.
Tests: 27 passing across the workspace.
After mutual consent has been exchanged once, future IncomingChannel
events from the same peer should not require another user click — the
peer is already in the trusted set, the only thing happening is a
reconnection because one side restarted, roamed, or temporarily lost
NAT state.
Before this commit the auto-accept fast path only matched the
OutgoingPending case (the mutual-pending happy path). Persisted
Connected peers fell through to the "incoming consent request — waiting
for user approval" branch on every reconnect, causing the auto-
reconnect loop initiated by PeerDiscovered to time out unless the user
manually re-accepted on the receiving side.
Visible in the user-supplied log as repeated "consent exchange: receive
error: closed by peer: 0" on the side that initiated the reconnect:
the other side was sitting on a notification waiting for a click.
Fix: model the two valid auto-accept reasons explicitly via a small
AutoAcceptKind enum. FirstTime preserves the old behaviour for
OutgoingPending (notify-on-connect, persistence write). Reconnect
sets is_new=false so ConnectionComplete suppresses the notification
and skips the redundant save_stargazers write.
No new schema, no wire-format change.
Per-peer sync status (Syncing… / Caught up · N ops received / Failed: …)
now shows up as a 'Sync activity' group at the bottom of the Network
tab. Makes the p2p layer legible — users see which Zodia identities
their device is exchanging the community log with, and roughly how
much arrived.
Pipe:
TopicLogSyncEvent::{SyncStarted,SyncFinished,Failed}
→ SyncEvent::{SyncStarted,SyncFinished,Failed} (zodia-sync)
→ AppMsg::SyncLifecycle(SyncLifecycle) (pump task)
→ AppModel.sync_peer_status updated (update handler)
→ NetworkTabMsg::Refresh.sync_status (send_network_refresh)
→ 'Sync activity' adw::PreferencesGroup (network_tab update_view)
Changes:
* zodia-sync: replace the operations-only `inbound_ops: Receiver<Operation>`
with `inbound: Receiver<SyncEvent>`. Subscription task forwards both
OperationReceived and the lifecycle variants (SyncStarted / SyncFinished
with received_ops + received_bytes from Metrics / Failed with error).
p2panda-core::VerifyingKey added to imports for the lifecycle variants.
* app: new SyncLifecycle enum + SyncPeerStatus enum. AppMsg gains
SyncLifecycle(SyncLifecycle). AppModel gains a sync_peer_status
HashMap<[u8;32], SyncPeerStatus>. Pump task forks: OperationReceived
feeds the pipeline as before; lifecycle goes straight to the new
AppMsg. Lifecycle handler updates the map and bumps
network_changed_token so the refresh routes the new data into the tab.
* network_tab: NetSyncStatus row data + Refresh.sync_status field +
a rendered 'Sync activity' group (titled, described, one ActionRow
per peer).
Tests: 27 passing. No new unit tests for the lifecycle wiring — it's
straight enum routing with no logic worth fixturing; smoke-testable on
the device with two app instances pairing.
Affirmations (♡) now flow through the same LogSync gossip layer as
authored interpretations. When a user affirms, two things happen:
1. Local: store.affirm(log_id, our_pk) — UI counts update immediately.
2. Network: InterpOp::Affirm { target_log_id } published via sync.
Peers' StateEvent::AffirmAdded handlers mirror the row into their
local affirmations table, so counts converge across the network.
Sybil resistance is (target_log_id, voter_pk) uniqueness, enforced by the
existing affirmations table's UNIQUE constraint. Duplicate Affirm ops
from the same voter are silently dropped via INSERT OR IGNORE.
Targeting model: Affirms reference the Zodia content hash log_id
(BLAKE3(interp_key || body)), not the p2panda operation hash. Means
duplicate-content authorings from different peers collapse to one
community row with combined affirmations — better UX, and zero schema
migration for what was already content-keyed locally.
Field renames in zodia-ops + zodia-pipeline:
- InterpOp::Affirm.interp_op_id → target_log_id
- StateEvent::AffirmAdded.interp_op_id → target_log_id
- Dropped the running_count from AffirmAdded — the in-memory count was
only what the materializer had seen since startup, not a meaningful
convergence signal. Store is the source of truth.
Side fix: net/tests/channel.rs (broken since the 0.6.0 iroh 0.98 bump,
not covered by cargo build) updated to use the new iroh::Endpoint
preset API. Endpoint::builder(Minimal) for loopback-no-relay tests.
Tests: 27 passing workspace-wide. Two new assertions in zodia-pipeline
that two distinct voters produce two AffirmAdded events with the right
target_log_id + voter pubkey.
Plumbs the inbound LogSync stream through ZodiaPipeline. The op flow
end-to-end:
remote peer → LogSync sub → node.inbound_ops → pipeline.process(op)
→ pipeline.next() → AppMsg::SyncStateEvent → store.insert_from_op
→ recent_interps refresh + UI bump
Publish goes the other way: AppMsg::ShareInterp now packages the entry
as InterpOp::Author and forwards it to node.publish(op), which CBOR-
encodes, signs the p2panda header, persists to the operation log, and
as InterpOp::Author and forwards it to node.publish(op), which CBOR-
encodes, signs the p2panda header, persists to the operation log, and
broadcasts via LogSync.
Changes:
* zodia-sync: drop InterpPayload (legacy Zodia-sig wrapper), drop
ReceivedInterp, drop the ZodiaStore dep entirely. Expose raw
Operation<()> on `inbound_ops` and accept InterpOp on publish.
The subscription task is now a thin forwarder — decoding lives in
the pipeline.
* zodia-store: add insert_from_op(key, body, author_pk) that trusts
its caller (the pipeline) for authentication and leaves author_sig
NULL. Tier-1 community_for_keys still filters on
author_sig IS NOT NULL so re-sharing is unaffected.
* app: SyncInterpReceived → SyncStateEvent(StateEvent); the
SyncPublishMsg::Publish payload becomes a plain InterpOp; the pump
task moves from tokio::spawn to glib::MainContext::default()
.spawn_local because ZodiaPipeline is !Send (single-threaded by
design).
* sync/Cargo.toml: zodia-store dep removed, zodia-ops dep added.
Wire-format break: peers on this branch encode InterpOp::Author instead
of the legacy InterpPayload struct. Old peers' bodies will produce
StateEvent::Skipped { MalformedOp } and be dropped silently. That's
acceptable for a feature branch; the release note will flag it when
this lands.
Workspace builds clean. 8/8 unit tests pass across zodia-ops +
zodia-pipeline.
Three small bugs in the consent + sync flow that pre-dated the migration
but stand out now that LogSync 0.6 is back online:
1. do_interp_sync was gated on is_new=true in ConnectionComplete. After
the first consent, a peer reconnecting later wouldn't re-share its
relevant-to-this-pair community entries. Now runs every successful
connect.
2. ZodiaNetEvent::InterpReceived (live Tier-1 InterpShare arrivals) wrote
to the DB but did not refresh self.recent_interps or bump the change
token, so the network tab's activity feed stayed stale until the user
triggered some other refresh. Now updates both.
3. PeerDiscovered only re-issued AppMsg::Reconnect for OutgoingPending
peers. A previously-Connected peer who came back online wouldn't
trigger a reconnect even though their announce was being received.
Now retries when the peer's state is Connected and we don't already
have an active channel — so simply having both apps up + on the same
network is enough for the connection to come back.
Real sync layer back, now backed by a persistent p2panda-store SqliteStore
at $DATA_DIR/sync_log.db (separate from interpretations.db so we don't have
to share schemas with the rusqlite-era zodia-store rewrite).
API churn handled:
- MemoryStore → SqliteStore (persistent; LogStore + TopicStore impls come
from p2panda-store, no custom TopicMap needed anymore)
- LogSync::builder(store, topic_map, endpoint, gossip) → builder(store, endpoint, gossip)
- TopicLogSyncEvent::Operation(op) → ::OperationReceived { operation, metrics }
- TopicLogSyncEvent::SyncStarted(_) → ::SyncStarted { metrics }
- TopicLogSyncEvent::SyncFinished(_) → ::SyncFinished { metrics }
- Header.public_key → verifying_key; Header.previous field removed
- Header.timestamp now Timestamp (use Timestamp::now())
- get_latest_entry / insert_operation now live on LogStore/OperationStore traits
Side effect: offline-catch-up of community interpretations is back online.
ZodiaSyncNode::spawn now requires the data_dir path so it can place its log
DB alongside the main store; caller updated.
API churn handled:
- p2panda_core::PrivateKey → SigningKey, PublicKey → VerifyingKey
- iroh_endpoint::Builder::private_key() → .signing_key()
- gossip.stream(): now takes Topic instead of [u8; 32] — use Topic::from(key.0)
- p2panda-core 0.6 in zodia-crypto + zodia-app
zodia-sync is stubbed: the new LogSync API (SqliteStore-backed, TopicStore
trait, restructured FromSync events) needs a real rewrite that I'm deferring
to Phase 3. The stub exposes the same public surface (spawn/publish/
ReceivedInterp) but performs no actual sync, so live Tier-1 direct exchange
still works while offline catch-up of community interpretations is paused.
Workspace builds clean. Smoke-test before piling Phase 3 on top.
Convert AspectView to SimpleAsyncComponent so init+update can await the
async store. Affirm/submit clicks route through AppMsg::AffirmInterp /
AppMsg::SubmitInterp so the parent handles the actual store write on its
own runtime — the click closure just dispatches the message and updates
the UI optimistically.
Drop the dangerous _blocking helpers on ZodiaStore (would have panicked
on GTK main thread since GTK callbacks run outside tokio context). Last
sync callsite — the chat-history preload in AppModel::init — now uses
direct .await inside the async init.
Workspace builds clean. Phase 1 (storage migration) is done; rusqlite is
gone, sqlx-sqlite is the only SQLite linker, p2panda 0.6's libsqlite3-sys
dep no longer conflicts. Next: Phase 2 — bump iroh 0.96 → 0.98 + p2panda
0.5 → 0.6, then Phase 3 (replace ZodiaSyncNode with the new Node API).
Migrate zodia-store from rusqlite to sqlx-sqlite to unblock p2panda 0.6
(which also links sqlite3 via sqlx, conflicting with rusqlite's libsqlite3-sys).
zodia-store and zodia-sync compile cleanly; ~10 build errors remain in
app/src/aspect_view.rs and stargazer_page.rs where sync GTK widget init +
click closures need to read/write the now-async store. Next step is to
pre-fetch in async parent (AppModel) and pass plain data into the sync
SimpleComponent — not yet done.
WIP checkpoint on port/p2panda-node-api; do not merge to main.
The self-hosted iroh-relay at stargaze.whatdoyouliketodo.com was no longer
reachable (Caddy up, iroh-relay daemon down), producing 'Failed to connect
to relay server' warnings on every startup. Remove RELAY_ZODIA,
ALL_RELAYS, and BOOTSTRAP_NODE_ID and let iroh use its public default
relays. Delete the zodia-bootstrap binary and service unit — no longer
needed.