Skip to content

fix(announces): type peers/relays by aspect, stop relays leaking into Peers#97

Merged
torlando-tech merged 2 commits into
mainfrom
fix/announce-peers-relays-filter
Jun 21, 2026
Merged

fix(announces): type peers/relays by aspect, stop relays leaking into Peers#97
torlando-tech merged 2 commits into
mainfrom
fix/announce-peers-relays-filter

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

Problem

In the Network announces tab, the Peers filter (the default) also showed relays.

Two stacked bugs:

  1. The filter keyed on the wrong field. .peers filtered on aspect == "lxmf.delivery" || aspect == nil while .relays filtered on the independent isRelay flag. A contact that was isRelay == true and carried a delivery/nil aspect satisfied both predicates, so it appeared under the default Peers view.
  2. The classifier mis-flagged peers as relays (root cause). Contact.init(from:) ran PropagationNodeInfo.parse(app_data) before the aspect branches, and that parser accepts any ≥3-element msgpack array without verifying the destination is a propagation node. So a lxmf.delivery (or audio/site) announce carrying such app_data got tagged isRelay = true while keeping its real aspect. (Columba's own delivery announces are 2-element arrays, which is why only some relays leaked — it fired on other clients / newer LXMF formats.)

Fix

Make the aspect the sole signal for announce typing, matching the reference clients:

  • Sideband types each announce by which RNS aspect handler received it (aspect_filter == "lxmf.propagation" vs "lxmf.delivery").
  • Android does the same via NodeType.fromAspect.

Changes:

  • Contact.init: removed the PropagationNodeInfo.parse(app_data) relay heuristic. Relays are now strictly aspect == "lxmf.propagation" (via entry.isLXMFPropagationNode); an unrecognized/empty aspect defaults to .peer (matches NodeType.fromAspect's default).
  • Filters: .peers / .relays now key on the mutually-exclusive badgeType (.peer / .relay), symmetric with .sites, so the four node types can't overlap.
  • handlePythonEvent: added a warning if an announce ever arrives with an empty/unrecognized aspect — since there's no app_data fallback anymore, this surfaces it loudly instead of silently classifying as a peer.
  • app/rns_bridge.py: corrected the stale "lxmf.delivery only" scope docstring so the per-aspect propagation handler can't be narrowed away by accident.

Why nothing regresses

Both announce backends guarantee a genuine propagation node arrives tagged "lxmf.propagation" or is dropped before reaching Contact.init, so the removed heuristic only ever caught false positives:

  • Python bridge registers one RNS announce handler per aspect (aspect_filter), and RNS enforces the filter by cryptographic destination-hash match — a real propagation node can only emit "lxmf.propagation". Worst case is omission (no event), which the fallback couldn't have rescued.
  • Native Swift/NE derive detectedAspect cryptographically; a propagation node yields "lxmf.propagation" or nil (dropped before emit). A mistag would require a SHA-256 collision.

Tests

Adds AnnounceClassificationTests (the filter had no prior coverage) — 7 cases pinning the classification, including the exact leak (delivery + ≥3-element app_data → .peer), the new strict contract (untagged propagation-shaped app_data → .peer, not relay), and unknown aspect → .peer. Full ColumbaAppTests suite passes (98 tests, 0 failures) on the iPhone 17 simulator.

🤖 Generated with Claude Code

… Peers

The Network announces "Peers" filter (the default) also showed relays.
Two stacked bugs:

1. The .peers filter keyed on aspect (== "lxmf.delivery" || nil) while
   .relays keyed on the independent isRelay flag, so a contact that was both
   satisfied both predicates and showed under the default Peers view.
2. Contact.init ran PropagationNodeInfo.parse(app_data) before the aspect
   branches; that parser accepts any >=3-element msgpack array without
   verifying the destination is a propagation node, so a delivery/audio/site
   announce carrying such app_data was mis-flagged isRelay=true.

Fix: make the aspect the sole signal for announce typing, matching the
reference clients (Sideband types by RNS aspect_filter; Android by
NodeType.fromAspect). Removed the app_data relay heuristic, so relays are
strictly aspect "lxmf.propagation"; an unrecognized/empty aspect defaults to
peer. Filters now key on the mutually-exclusive badgeType, so the four node
types can't overlap.

Both backends already guarantee a genuine propagation node arrives tagged
"lxmf.propagation" (Python via per-aspect RNS announce handlers, native via
cryptographic detectedAspect) or is dropped before reaching Contact.init, so
the removed heuristic only ever caught false positives. Added a warning in
handlePythonEvent when an announce arrives with an empty/unrecognized aspect
(there is no fallback now), and corrected the stale rns_bridge.py scope
docstring so the propagation handler can't be narrowed away by accident.

Adds AnnounceClassificationTests (the filter previously had no coverage).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Fixes a double bug where relays leaked into the default Peers view: the filter keyed on aspect (which didn't exclude isRelay), and the classifier mis-flagged non-relay announces as relays by accepting any ≥3-element msgpack array as propagation-shaped app_data. The fix makes badgeType (set exclusively from entry.isLXMFPropagationNode) the single discriminator for all four filters, removing the now-redundant PropagationNodeInfo.parse heuristic.

  • Contact.init: drops the PropagationNodeInfo.parse app_data fallback; all announce typing now flows through isLXMFPropagationNode / isLXSTTelephony / detectedAspect, matching Sideband and Android's aspect-first contract.
  • applyAnnounceFilters: all four filter cases (.peers, .audio, .relays, .sites) now key on the mutually-exclusive badgeType, so overlap is impossible by construction.
  • AnnounceClassificationTests: adds 7 new tests including a direct regression pin for the delivery-peer-with-3-element-app_data leak case.

Confidence Score: 5/5

Safe to merge — the fix is surgical, well-tested, and aligns Columba with the reference clients' aspect-first classification contract.

The change removes a single mis-scoped heuristic and replaces it with a single, mutually-exclusive discriminator that matches how both Sideband and Android already type announces. All four filter predicates now key on the same field, so the overlap that caused the leak is structurally impossible. Seven new tests pin the regression case and the full test suite passes.

No files require special attention.

Important Files Changed

Filename Overview
Sources/ColumbaApp/ViewModels/ContactsViewModel.swift Core fix: removes PropagationNodeInfo.parse fallback from Contact.init and rewrites all four filter predicates to key on the mutually-exclusive badgeType. Logic is sound and symmetric.
Tests/ColumbaAppTests/AnnounceClassificationTests.swift New test file with 7 cases covering the regression (delivery peer with ≥3-element app_data), relay classification, unknown/nil aspect defaulting to peer, and audio/site no longer mis-flagged.
Sources/ColumbaApp/Services/AppServices.swift Adds a diagnostic warning log when an announce arrives with an unrecognized aspect. The knownAspects set is correct and the warning fires before the PathEntry is inserted.
app/rns_bridge.py Docstring correction only: broadens the 'lxmf.delivery only' scope note to reflect all four tracked aspects and warns against narrowing, preventing future regression by accident.
Columba.xcodeproj/project.pbxproj Registers the new AnnounceClassificationTests.swift source file in the Xcode project with consistent ACT01/ACT02 UUID references.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[PathEntry arrives] --> B{entry.isLXMFPropagationNode?}
    B -- yes --> C[badgeType = .relay\nisRelay = true]
    B -- no --> D{entry.isLXSTTelephony?}
    D -- yes --> E[badgeType = .audio\nisRelay = false]
    D -- no --> F{detectedAspect ==\n'nomadnetwork.node'?}
    F -- yes --> G[badgeType = .node\nisRelay = false]
    F -- no --> H[badgeType = .peer\nisRelay = false\n'lxmf.delivery' or unknown]

    subgraph applyAnnounceFilters
        I[.peers] -->|badgeType == .peer| J[✔ peers only]
        K[.relays] -->|badgeType == .relay| L[✔ relays only]
        M[.audio] -->|badgeType == .audio| N[✔ audio only]
        O[.sites] -->|badgeType == .node| P[✔ sites only]
    end

    C --> K
    E --> M
    G --> O
    H --> I
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[PathEntry arrives] --> B{entry.isLXMFPropagationNode?}
    B -- yes --> C[badgeType = .relay\nisRelay = true]
    B -- no --> D{entry.isLXSTTelephony?}
    D -- yes --> E[badgeType = .audio\nisRelay = false]
    D -- no --> F{detectedAspect ==\n'nomadnetwork.node'?}
    F -- yes --> G[badgeType = .node\nisRelay = false]
    F -- no --> H[badgeType = .peer\nisRelay = false\n'lxmf.delivery' or unknown]

    subgraph applyAnnounceFilters
        I[.peers] -->|badgeType == .peer| J[✔ peers only]
        K[.relays] -->|badgeType == .relay| L[✔ relays only]
        M[.audio] -->|badgeType == .audio| N[✔ audio only]
        O[.sites] -->|badgeType == .node| P[✔ sites only]
    end

    C --> K
    E --> M
    G --> O
    H --> I
Loading

Reviews (2): Last reviewed commit: "fix(announces): key .audio filter on bad..." | Re-trigger Greptile

Comment thread Sources/ColumbaApp/ViewModels/ContactsViewModel.swift Outdated
@codecov

codecov Bot commented Jun 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Addresses Greptile P2: the .audio announce filter still keyed on isAudio
(aspect == "lxst.telephony") while .peers/.relays/.sites were unified onto
the mutually-exclusive badgeType. Equivalent today, but the asymmetry is a
footgun for a Contact built via the memberwise init. Now all four filters
discriminate on badgeType.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 02dce59 into main Jun 21, 2026
3 checks passed
@torlando-tech torlando-tech deleted the fix/announce-peers-relays-filter branch June 21, 2026 04:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant