Skip to content

SPORE v0.7.0

Latest

Choose a tag to compare

@github-actions github-actions released this 01 Aug 19:35
82e19b4
  • A Direct medium is now a name, not a hardcoded enum — and an unknown one is
    ignored instead of poisoning the offer it arrived in.
    direct::Medium was a
    closed #[repr(u8)] enum, which was the wrong shape twice over. DESIGN.md
    already says the nutrient list is closed while the bridge list stays open, and
    a medium is the Direct plane's version of a bridge — so enumerating them in the
    core made every new medium an edit to src/ and an allocation somebody had to
    hand out. Worse, an unknown code had to be a decode error: Offer::decode
    propagated it with ?, so a peer advertising one unfamiliar path alongside three
    usable ones got nothing at all. A medium is now a length-prefixed name carried
    verbatim; an unrecognised one decodes cleanly and is simply a candidate nobody
    declared willingness for, so it is skipped like any other unusable path, and an
    offer of only unknown mediums answers no_medium — a reason rather than silence.
    The name is bound into the KDF, so a record still cannot be replayed onto a
    different medium. The SPDR profile is VERSION = 2 for the encoding change,
    bumped rather than finessed because a v1 peer would mis-parse every candidate —
    cheap now, since until the daemon wiring below nothing could start a pipe at all.
    The frozen v1 envelope wire is untouched; SPDR is opaque payload riding on it.

  • Direct now offers a global IPv6 address when the host has one — the WAN path
    that needs no traversal at all.
    Candidates were IPv4-only, so a node with a
    global v6 from its ISP — which is most of them — advertised only a LAN address
    and a reflexive one that needs a hole punch to work. A global v6 has no NAT in
    front of it
    : it is already the address a peer dials. It is ranked between the
    LAN locator and the reflexive one, so choose prefers it over a path that does
    not exist yet, and it is offered only when the host actually has one —
    link-local, unique-local, loopback and multicast are all rejected, since
    advertising any of those is a candidate that can never connect. A path firewall
    may still drop unsolicited inbound; that is a pinhole a punch can open rather
    than a mapping that must first be discovered, so it is better odds and not a
    promise. The daemon now prints which locators it is offering and what each one
    needs, so "why did it not connect" is answerable without a packet capture.

  • A node can now discover the address the outside world sees, and answer that
    question for other nodes (P-Direct-NAT step 2).
    Direct could only ever offer
    the address a node was told it had, so it worked on a LAN and nowhere else.
    direct::stun is a minimal binding client and echo — RFC 5389's binding
    exchange and nothing more: no auth, no FINGERPRINT, no ICE, no dependency, with
    unknown attributes skipped rather than rejected so a full STUN server still
    interoperates. The echo half ships with it and is the point: a daemon's
    stun: port answers statelessly (one packet in, one out, nothing retained), so
    one SPORE node is a reflexive-locator server for another and the network need
    not quietly depend on a third party's STUN server. A discovered locator is
    offered as a second candidate ranked below the LAN one, so a path that works
    without crossing a NAT is always preferred. This does not make NAT traversal
    work:
    most NATs drop an unsolicited inbound datagram, which is what the
    coordinated hole-punch (step 3) exists to fix — the daemon says so at startup
    rather than implying the new locator is reachable. Verified with two daemons,
    one asking the other's echo.

  • Android can bring a Direct pipe up too — through the same code as the
    daemon.
    Wiring it separately would have meant two implementations of one
    negotiation, which is exactly the per-platform punch logic the roadmap's
    engineering pattern forbids, so the runner moved into the core as
    direct::UdpRunner and both native runtimes became thin adapters over it: the
    daemon supplies stderr and its config, the JNI layer supplies a handle and five
    poll-driven calls. Kotlin never touches a Direct socket — it says where the
    device is reachable, feeds delivered envelopes in (getting back whether each was
    signalling, so an app message is never swallowed), and ticks. Compile-checked
    only:
    there is no Android SDK in this environment, so the JNI symbols and
    Kotlin declarations are verified symmetric in both directions and the crate
    builds and lints clean, but no phone has run it. The daemon path is the one with
    two-process evidence, and it was re-verified unchanged after the refactor.

  • The daemon can now bring a Direct pipe up. The core seam below made
    signalling possible; nothing called it, so Direct still could not be started
    from anything you can run. src/cli/direct.rs is that consumer: direct: in
    the config says where a node is reachable, direct-to: names a peer to keep a
    pipe to (the daemon has no control surface to start one from, so without it
    both ends would sit waiting to be offered a pipe), and the runner dispatches
    delivered DMs through Signalling, opens a UdpPort for whichever candidate
    won, and carries the reply back over send_direct. Verified with two real
    daemon processes
    negotiating over a shared folder bridge and bringing up a
    pipe, not only in a unit test. Honest limits, printed by the daemon itself:
    LAN only — a node cannot yet discover its own reflexive address, so it
    advertises what it was told and NAT traversal remains the unbuilt
    P-Direct-NAT track; and there is no app above the pipe yet, so inbound records
    are logged and dropped rather than routed somewhere that does not exist.

  • Direct signalling can now reach a peer at all — SPDR rides send_direct.
    The negotiation codec, key schedule and socket adapters were all built and
    tested, but nothing tied them to the mesh: no code anywhere outside
    src/direct.rs looked at an SPDR payload, so no app could start a pipe, which
    is why NAT traversal had never actually been hit in practice. direct::Signalling
    is the missing seam — it turns the plaintext of a delivered DM into a Signal
    saying what to open and hands back the state to finish with. A whole negotiation
    now runs over the real send_direct/on_rx path in a test, sealed and signed
    like any other DM, and the resulting pipe carries traffic both ways.
    A real API gap fell out of it: Pipe::answer took the port before
    choose ran inside it, so a responder willing to use more than one medium could
    not use it — it would have had to guess which medium would win. accept (decide,
    no port) and Pipe::answer_with (open, then derive) split the two; answer
    stays as the single-medium convenience it always effectively was. Deciding is the
    core's, opening is the runtime's — the seam docs/DESIGN.md's runtime model
    already implied. Unanswered offers expire rather than holding an ephemeral secret
    forever. Purely additive; the SPDR wire and direct::VERSION are unchanged.
    No daemon or Android build calls this yet — that wiring is the next step.

  • The palette is now defined once and generated into every surface that renders
    it, with the contrast ratios computed instead of typed.
    The same hexes were
    hand-maintained in four places — site/style.css, the standalone node's inlined
    CSS, Android's Compose Palette, and docs/VISUALDESIGN.md's tables — each
    carrying its own re-typed WCAG ratios, so a palette change meant a manual
    four-way audit and the ratios could silently stop describing the colours they
    sat beside. They had already drifted: Android carried three ratios per colour
    where the CSS carried one, --dim on void was written as both 4.68:1 and 4.6:1,
    and --warn/--bad existed only in the standalone. design/tokens.json is now
    the one place a colour is defined and design/generate.py emits all four,
    following the proven bindings/spec.json pattern with a matching CI job
    ("design tokens in sync"). The generator refuses to run if a pairing stops
    matching the grade the source claims for it, in either direction — a colour
    that is no longer readable, or a "never do this" pair that has quietly become
    fine, since a stale safety claim is as misleading as a stale colour. No
    rendered colour changed:
    every hex in all four files is identical, counts
    included; the diff is comments, ordering and formatting only.

  • A node now maintains itself on a timer instead of only when traffic
    arrives — and the desktop daemon retries unacked sends for the first time.

    The expiry sweep and prekey rotation were reachable only from Node::on_rx,
    so a quiet node pruned nothing and never advanced its forward secrecy; and
    resend_unacked had exactly one production caller anywhere in the tree — the
    Android JNI — so §8 resend-with-backoff worked on Android and nowhere else.
    New Node::tick(now) is the one periodic entry point: it runs the sweep and
    returns whatever fell due for resend. Hub::tick() wires it for hosted
    nodes, the daemon calls it in its existing beacon loop, and Android's existing
    periodic call now goes through it (no Kotlin change). Purely additive — the
    ingest-side sweep still runs and both are idempotent, so a runtime that never
    ticks behaves exactly as before. SPEC's runtime contract makes the timer
    normative.

  • Storage is now something a runtime supplies, not something the core
    assumes.
    The store could only ever spill to a filesystem directory, so a
    runtime whose storage is not a disk — a browser tab with IndexedDB, an MCU
    with flash — had somewhere to put bytes and no way to offer it, and silently
    ran memory-only. SpillBackend is now a public trait
    (put/get/remove/ids) with FsSpill as the filesystem implementation,
    and Node::set_spill_backend accepts any other. Node::set_spill_dir is
    unchanged and still the filesystem path. Wire format and the frozen API are
    untouched
    — purely additive. The verification that matters is deliberately
    not delegated: a backend moves dumb bytes, while the id-matches-content
    check, the exactly-one-envelope check and expiry all stay in the store,
    because a backend is by definition somewhere other things can also write
    (C-ST4).

  • Every GitHub release now carries the means to rebuild itself, not just the
    APK.
    spore-android.apk was the only release-cutting workflow ever
    attached to a release; the offline source bundle (every dependency vendored,
    cargo build --offline works immediately) and the standalone browser node
    existed but were either a CI artifact that expires after 90 days or only
    ever served live from GitHub Pages, with no permanent, offline-verifiable
    copy. Both are now attached to every rolling and tagged release alongside
    the APK — docs/APPS.md's desktop/CLI card and docs/CONTINUITY.md's seed
    table point at them directly instead of only "clone and vendor it
    yourself." Matches MISSION.md's "rebuild without us" — this repository,
    this site, and crates.io can all disappear and a release you already
    downloaded still rebuilds. Nightly builds (the 5-deep rollback history) are
    deliberately APK-only, to keep their per-build storage cost from growing
    5×; the rolling and tagged releases are the two that matter for this.

  • Removing (or pausing) a core-owned UDP/TCP bridge — or Wi-Fi Direct — now
    actually stops it (PR2 carried-forward).
    bridge::udp::run/run_primary/
    run_group and bridge::tcp::run used to have no way to be told to stop:
    Android's JNI layer spawned them on a detached OS thread with no handle back,
    so their Remove button (where one existed at all) never had anything to call.
    They now take a stop flag, checked on every already-short-timeout read except
    for TCP listen mode's accept(), which has no read-timeout equivalent and so
    is polled non-blocking instead — a real fix, not a cosmetic one: a plain flag
    check after a blocking accept() would still hang forever with nobody ever
    connecting. WifiDirectBridge's Remove used to tear down only the P2P group,
    leaking the UDP flood underneath it forever; it now stops that too. The
    always-on default LAN bridge ("UDP broadcast") joins the Pause/Resume toggle
    system below rather than getting Remove-only, since it has no manual re-add
    control and a one-way Remove would have meant no LAN bridge until the app
    restarted. CLI/daemon bridges (ax25, i2p, reticulum's TCP/UDP
    companions, tor) are unaffected — Ctrl-C still ends the whole process, so
    they get a stop flag that's simply never set. Wire format and JNI ABI shape
    unchanged; nativeStartUdp/nativeStartTcp/nativeStartUdpLimited now
    return the hub iface instead of nothing.

  • Android: a bridge can be paused without losing it (PR2 carried-forward
    toggle).
    Removing a bridge always meant re-entering its setup from scratch
    — re-scanning for the BLE device, re-granting the mic permission. Audio modem,
    Meshtastic BLE and RNode BLE now get a Pause/Resume control next to Remove:
    Pause stops the transport and frees its hub interface but keeps the row and
    its configuration, and Resume restarts with that exact same configuration,
    landing back in the same row rather than adding a duplicate one. Wi-Fi Direct
    and Web don't get one yet — Wi-Fi Direct's actual transport turns out to be
    the core-owned UDP bridge with no stop hook (a Pause would silently leave the
    socket running), and Web can aggregate any number of added relays that a
    Resume can't yet replay — both stay Remove-only rather than offer a control
    that would lie about what it did. No wire or wasm changes.

  • The standalone web node is on the real palette now, not a stand-in (C1).
    web/spore-standalone.html has carried its own inline stylesheet since it
    was written — it has to, being a single offline file — but that stylesheet
    was still the generic dark scheme it started as, never actually updated to
    docs/VISUALDESIGN.md's Neo-Tokyo Tactical Wasteland tokens, despite the doc
    claiming it did. Now on the same hex values as site/style.css and
    Android's Chrome.kt Palette (both of which were already correct), in both
    dark and the "Field Notes" light variant. Also added: a 2px cyan
    :focus-visible ring on every interactive element (previously none), and a
    kevlar-faced :disabled button state (previously none — with the button
    face now pink, an unstyled disabled state would have rendered exactly the
    "translucent pink" VISUALDESIGN forbids). A related semantic fix: the log's
    "received" lines and the "bridge open" badge used the primary-action accent
    color, which read as success by coincidence in the old palette's green but
    would have read as the wrong thing once accent became pink — remapped to
    the dedicated --ok token. Audited Android for the same class of bug
    (pink-on-kevlar, disabled-state treatment, missing focus rings); found
    already correct there. No wire or wasm-export changes.

  • Android: Feed polish (B8). Following a topic now says so — "Following
    #x", "Already following #x", or "Node not started yet" — instead of the
    Follow button silently doing nothing or nothing visible happening on a
    duplicate tap. A corrupted or truncated attached image now shows "couldn't
    load this image" instead of "decoding image…" forever, which previously
    looked identical to an image that just hadn't finished loading. A post's
    [text](url) links stay inert on tap (a link in a signed-but-public post is
    attacker-controlled text — no drive-by opens), but a long-press now copies
    the URL to the clipboard so a real link is still usable.

  • The offline window is now a configurable knob, end to end (PR0 Part B).
    Prekey lifetime and the §7 ratchet's skip-key TTL were separate hard-coded
    7-day constants; they're now one field, Node.prekey_lifetime_secs, read by
    both sweep_prekeys and session bootstrap, so they can't silently drift
    apart. New Node::offline_window_secs()/set_offline_window_secs() (clamped
    to a day..365-day range) expose it. Android's Advanced screen gets a matching
    "Offline window" card (7d/14d/30d presets + custom days, persisted like the
    seed/ring), the About blurb states the active window instead of a hard-coded
    "7-day," raising above the 7-day default requires the same confirm dialog
    used for prekey-ring export, and a failed decrypt of a verified message from
    a known contact now surfaces "couldn't decrypt this — the key may have
    expired, or ask them to resend" instead of dropping silently. Wire format
    unchanged; this is local policy only. Field-verifying the window on real
    hardware stays tracked under PR6.

  • The §7 Double Ratchet is now wired into real DM traffic (PR0b). Direct
    messages were always sealed with a fresh one-shot key against the recipient's
    current prekey; the tested Double Ratchet primitive existed but was never
    actually used for send/receive. Node::send_direct/a new Node::open_dm now
    use it once a session exists (falling back to the one-shot seal otherwise).
    Sessions bootstrap from ANNOUNCE — both sides derive the same root
    independently via a static-static X25519 DH, with the numerically-lower
    address always the pair's deterministic initiator, so two peers who each
    message the other before hearing back still converge on one session. New
    envelope flag RATCHET (bit 64, previously unused) marks a ratchet-shaped
    payload; sessions are in-memory only (like the existing peer tables) and
    bounded the same way. Along the way, fixed a real bug this surfaced: a fresh
    node's bootstrap prekey rotates on its own first on_rx, and if that raced
    against its first ANNOUNCE exchange, two peers could permanently derive
    different session roots — now settled consistently before either announcing
    or bootstrapping a session. This unblocks (but doesn't itself add) PR0 Part
    B's offline-lifetime UI/config knobs. Wire format additive/unchanged for
    existing traffic.

  • Android: accessibility + density pass (B7). Icon-only buttons (attach, remove
    attachment, Feed's Bold/Italic/Code/link/image, the top bar's back/connect/settings
    icons) now announce a real name to TalkBack instead of the raw glyph or letter. Topic
    chips and bottom-nav tabs announce selection instead of relying on colour alone. Every
    button now meets the 48dp touch-target floor (most were ~36dp). A chat's message
    composer gets initial focus instead of the petname field above it. The thread view no
    longer yanks a reader who scrolled into history back to the bottom on every new
    message — it only auto-follows when they're already there, with a "↓ new" button to
    jump back manually otherwise. Reduced-motion re-verified: no new animation gaps found.
    Wire unchanged.

  • Android: bridge status is an exact-matched enum, and a denied permission has a
    recovery path (B6).
    The bridge LED used to classify status by blind substring —
    "disconnected" read as connecting (it contains "connect"), "unsupported" read
    as up (it contains "up"). Both were live bugs, now fixed by matching the small,
    known vocabulary every bridge source emits exactly instead. A connection error also
    gets its own pink-plus-icon treatment rather than fading into the same look as an
    idle bridge. A denied permission (Audio modem, Meshtastic/RNode BLE, Wi-Fi Direct)
    no longer dead-ends silently — it now offers a dialog that deep-links to the app's
    system settings. Wire unchanged.

  • Android: Advanced screen shows prekey-ring health, and export is gated (B5).
    A new core method, Node::prekey_health, reports how many prekey secrets are held,
    how old the oldest is (honestly None/"unknown" for an unstamped bootstrap entry),
    and seconds to the next scheduled rotation — exposed through a new, additive
    android/jni export (not the frozen C/Python/Go/JS bindings surface). The Advanced
    screen shows the readout live and gates "Export ring" behind a confirm dialog warning
    that a copy defeats the 7-day forward-secrecy window. Wire format unchanged.

  • Android: informative node notification + transfers overflow (B4). The
    foreground-service notification said "node running" forever; it now shows the
    address's first 8 hex chars, live peer count, and "relaying" once the node is
    actually holding envelopes for the mesh, refreshed as those change, and opens the
    app when tapped. TransfersBar now says +N more instead of silently dropping
    transfers past the first 3. Wire unchanged (app-shell only).

  • Site: copy-to-clipboard on every code block. A small "Copy" button now sits
    top-right on every <pre> across every doc page (not just the front page — the share
    bar stays index.html-only, but code blocks appear everywhere). Uses existing tokens
    only, flashes "Copied ✓" on success, falls back to "Select + copy" rather than throwing
    if the Clipboard API is unavailable. Verified with a real Playwright clipboard test, not
    just visually. Documented in docs/VISUALDESIGN.md §3. Wire unchanged.

  • Site: a calmer tone for long-form body copy (dark mode). --amber on --void
    clears 10.80:1 — nowhere near a contrast failure — but a fully saturated colour glowing
    on near-black is tiring across paragraphs in a way the ratio alone doesn't capture. New
    token --prose (#d6af5c, same hue at ~60% saturation, still 9.56:1/8.24:1 — past
    7:1/AAA) now colours every long-form paragraph, list item, and table cell across all
    doc pages, not just the three story-card ones — so dense reference pages (Bridges, Spec,
    Security Findings) get the relief too. Headings, code, buttons, and badges keep full
    --ink. Light mode is untouched (--prose aliases to --ink there; dark ink on paper
    needed no desaturating). Documented in docs/VISUALDESIGN.md §1 before the CSS changed.
    Wire unchanged.

  • Site: Home, Apps, and Continuity use illustrated story cards. A first-time visitor
    landed on a wall of text; these three pages now open with a grid of small self-hosted
    inline-SVG illustrations (CSS/tokens only, no rasters) and a one-line caption, with the
    full prose moved into <details>. Builder/reference docs (Spec, Design, Bridges, Direct,
    Rebuild, Security Findings, Hardware, Testing, VisualDesign, Roadmap, Changelog,
    Contributing, Bindings, Reference, Web guide) are unchanged. Reduced-motion disables the
    SVG animations (verified via computed style); no pink-on-olive; decorative art is
    aria-hidden. First increment of the ROADMAP "site" track — a contrast/readability pass
    and a docs-index card grid remain open. Wire unchanged.

  • Android: empty states + a PUBLIC send confirm (B3). Chats, Bridges, and Feed
    (already had one) now say something plain when there's nothing there yet, instead of
    a blank list — no unread badges anywhere, since there's still no read tracking to back
    one. Sending to PUBLIC — unlike a DM, signed but never sealed, and reaching every
    node in range rather than just the open thread — now asks first, via a new
    ConfirmDialog shared component (pink CTA, void ink; Cancel is the quiet default).
    🧪 Compiles in the apk CI; on-device QA is PR6. Wire unchanged.

  • Android: send/post feedback (B2). The plain-text chat send and the feed post used to
    clear the composer even when nothing went out — the node not being started yet was a
    silent no-op that looked like a sent message. NodeController.send/post now return a
    result (false when the node isn't up); the composer only clears on success and otherwise
    keeps your text and shows "Node not started yet — not sent/posted" — the same pattern the
    petname Save already used. 🧪 Compiles in the apk CI; on-device QA is PR6. Wire unchanged.

  • Android: chat navigation (B1). System Back now follows the screen hierarchy
    instead of leaving the app from a nested screen — a thread falls to the chats list, a
    draft post to the feed, everything else to Chats, and only Chats itself backgrounds the
    app (mirroring the existing arrow). The chat now pins to its newest message on
    open and whenever one arrives, and the composer lifts above the soft keyboard
    (imePadding). 🧪 Compiles in the apk CI; on-device QA is PR6. Wire unchanged.

  • Site: the Roadmap and Changelog are in the top navbar. Both pages were already
    rendered and linkable but kept off the nav; they're now first-class nav items
    (Home · Spec · Apps · Design · Bridges · Rebuild · Continuity · Roadmap · Changelog),
    so "what's planned" and "what shipped" are one click from any page. Wire unchanged.

  • Docs: android/PLAN.md and UX-ISSUES.md absorbed; docs cull complete. The
    M0–M5 milestones move to a "shipped milestones" paragraph in android/README.md, and
    the chat-attachment convention (the 📎 name | spore:<magnet> | mime marker and its
    parser regex, verbatim) becomes Appendix A of docs/VISUALDESIGN.md. Both files are
    deleted; references repointed. The docs/ footprint is now its canonical set (SPEC,
    DESIGN, BRIDGES, SECURITY_FINDINGS, VISUALDESIGN, APPS, CONTINUITY, HARDWARE, DIRECT,
    ROADMAP). Wire unchanged.

  • Docs: ANDROID_AUDIT.md retired into ROADMAP + TESTING.md. The Android production
    audit's status table had drifted (it still listed shipped work as open); its status now
    lives only in the ROADMAP, its still-open engineering items (received-file
    FileProvider, the JNI local-ref soak, WebView battery + Lite mode, permission-at-enable,
    loop lifecycle gating) are captured there, and its device checks stay in
    android/TESTING.md. The Verified fixes it described are already shipped and in this
    CHANGELOG. Deleted, not just unlinked — no second status surface. Wire unchanged.

  • Docs: ROADMAP.md carries the full plan; SPORE_DEEP_AUDIT.md deleted. The
    multi-PR plan that was hiding in a misleadingly named SPORE_DEEP_AUDIT.md is now
    ROADMAP.md — the single forward-looking surface: the PR map with status, the full
    detailed PR0–PR9 bodies
    (files, code sketches, tests, acceptance — kept, not
    summarised away), the hard rules and PR template, and the docs / Android-UX / palette /
    site / web tracks. An earlier pass over-culled this into a summary behind a redirect
    stub; the detail is restored and the stub is gone (no redirect docs). "What shipped"
    stays in just two places — the CHANGELOG and the ROADMAP status column. Wire unchanged.

  • iroh QUIC bridge (bridge-iroh, experimental 🧪). A new optional bridge that
    carries SPORE envelopes over iroh QUIC —
    peer-to-peer by public key, with hole punching and relay fallback for reach that LAN
    UDP and Tor/I2P don't cover. It is a normal stream bridge: KISS-framed on one bi
    stream, same best-effort store-and-forward. The one novelty is async — iroh is
    tokio-based while the rest of SPORE is synchronous, so the bridge runs a private
    runtime and wraps the QUIC stream halves as blocking Read/Write for the shared
    pump. Config: iroh (listen), iroh: <id> (dial via relay), or iroh: <id>@<addr>
    (dial direct, relay/discovery off). Tested by a two-endpoint localhost QUIC
    round-trip in a dedicated iroh CI job. Trust notes (relay phone-home, EndpointId
    ≠ SPORE address) are in BRIDGES.md. Wire unchanged — an
    underlay, not a protocol change; golden vectors byte-identical.

  • MSRV floor raised 1.75 → 1.85. Admitting iroh pulls zeroize ≥1.9 (and its
    edition-2024 zeroize_derive) into the core build via chacha20poly1305/crypto_box,
    which needs Rust 1.85. A deliberate trade, documented in Cargo.toml, CONTINUITY.md
    and the MSRV CI job; iroh itself needs 1.91 and is built only by its own CI job on
    stable. The default offline rebuild and every non-iroh bridge still build on 1.85.
    Cargo.lock moves to version 4 (needs Cargo ≥1.78) — the v3 pin only ever existed
    to stay parseable by the retired 1.75 floor.

  • SPORE Direct: a negotiated, non-routed, end-to-end encrypted datagram pipe for
    low-latency media.
    Store-and-forward is the wrong plane for voice or a live
    terminal, where holding a frame for relay adds exactly the latency you're avoiding.
    Direct is the other plane: two identities agree on the mesh (an SPDR OFFER/ANSWER
    carried over the existing sealed+signed send_direct) on a medium and an ephemeral
    X25519 key, then talk directly over an underlay with a ChaCha20-Poly1305 record.
    Keys bind both addresses, the pipe id, and the medium, so a record only opens for
    the exact pair that negotiated it, and the header is authenticated so a flipped
    type/seq fails the MAC. This first increment is the pure protocol core
    negotiation codec, key schedule, record, medium selection, a DatagramPort trait +
    in-memory Loopback, and the Pipe — fully unit-tested end to end
    (examples/direct_loopback.rs, docs/DIRECT.md). It is an application profile: no
    envelope/store/hub/wire-format change
    (golden vectors byte-identical), and it
    compiles everywhere the core does, wasm included. Real socket adapters (UDP/TCP/BLE)
    and the mesh signalling glue are follow-ups — transport, not protocol.

  • SPORE Direct: real UDP and TCP socket adapters. direct/udp.rs and
    direct/tcp.rs implement DatagramPort over std::net (gated
    #[cfg(not(target_arch = "wasm32"))], so the negotiation core still builds for the
    web). UDP maps one datagram to one sealed record over a connected socket; TCP adds
    4-byte length-prefixed framing to restore that shape over a byte stream, disables
    Nagle for latency, and refuses an over-length prefix rather than buffer toward it
    (an unbounded-buffering DoS guard). Both stay best-effort at the record layer — no
    ordered stream leaks up to reintroduce head-of-line blocking. Tested against real
    kernel sockets, including a genuine two-process UDP round-trip that re-execs the
    test binary and negotiates a live pipe across the process boundary. Wire
    unchanged
    — application profile only; golden vectors byte-identical.

  • Android: the JNI audio-output queue is bounded. The demodulator's completed
    frames sat in an unbounded queue — the mic thread fills it continuously while the
    poll loop drains one frame per tick, so a stalled consumer (or a fast/hostile
    audio feed) could grow it without limit. It now caps at 64 frames and drops the
    oldest on overflow: a demod backlog is stale audio, not data worth keeping, so the
    freshest frames win. Same "bound every cache" hardening as the store and neighbour
    caps.

  • Docs: an Android device-test checklist, and a forward-secrecy note in the app.
    New android/TESTING.md is the repeatable procedure for the things CI can't prove
    because they need a real device — fresh install, upgrade, seed reveal, that the
    identity is absent from a cloud/adb backup and a device transfer, a 24–48 h
    soak with no native abort, and the 7-day forward-secrecy window — each with a
    History section to record runs. The app's
    About card now states the forward-secrecy model in plain terms (prekeys rotate on
    a 7-day window; conversation keys ratchet forward; skipped keys drop after 7 days;
    the seed is in encrypted prefs and excluded from backup). The radio air-interface
    paths keep their existing docs/HARDWARE.md checklist; the on-device runs remain
    for hardware QA — this ships the procedure ahead of the run so a green build is
    never mistaken for a green device.

  • Store: a spilled envelope is verified against its id on every read, not just
    when adopted (C-ST4).
    The spill directory is on disk, where a backup tool, the
    OS, or a corrupted sector can change a file after we recorded it — and its name is
    only a claim about its content. Store::wire now bounds the read, decodes, and
    refuses to return bytes whose recomputed id doesn't match the one asked for;
    a mismatch reads as "not held" so the mesh re-fetches a good copy instead of us
    serving a peer bytes that fail their own content check. The adopt path
    (set_spill_dir) already did this at startup; this closes the gap on later reads.
    Unit-tested (intact loads, corrupted → None, truncated → None, no panic).

  • Android: profiles reach the mesh — peers pull your photo and name, and re-pull
    when you change them (PR4b).
    A peer's avatar now shows on their Nearby row and in
    the conversation list, fetched from them on demand: the app asks a peer for its
    profile over the request/response layer (GET /profile), and the peer replies with
    a small record — its recommended name plus the ≤256 px JPEG. The reply is only
    trusted if its authenticated sender is the very peer that was asked, so a
    flooded forgery can't poison a contact's picture; serving is rate-limited so a
    tens-of-KB reply can't be used to amplify. When you change your name or photo the
    app floods a tiny change-notify on a deterministic per-identity topic, and anyone
    who cached the old one re-pulls. Entirely an application on top of primitives the
    frozen protocol already ships — a request and a reply are ordinary signed DATA
    envelopes, so no wire-format change (the golden vectors are byte-identical).
    The one core tweak is internal: an RPC reply now retains its verified sender so the
    caller can check it. Compiled by CI, device QA is a PR6 item.

  • Android: a local profile photo, and the name framed as public. The Advanced
    screen's name field is now "Name others see," with a live preview of the avatar +
    name exactly as a peer's Nearby row renders them. You can pick a photo; it's
    downscaled to a ≤256 px JPEG off the main thread and cached locally. This is the
    local half (PR4a); PR4b (above) publishes it to the mesh. Compiled by CI, device
    QA is a PR6 item.

  • Android lifecycle hygiene. The foreground service now tears the node down on
    onDestroy — cancels and joins the poll/house loops before nativeFree, so no
    coroutine reads a freed handle — and a START_STICKY restart mints a fresh node
    rather than reusing a dropped jlong. AudioBridge.stop nulls its record/track
    after release so a stop→start cycle can't reuse a released object. BLE bridges
    reconnect on an unexpected drop with exponential backoff (1s→60s, reset on
    connect, cancelled by an explicit stop) instead of going dead until re-added, and
    the Meshtastic FromRadio drain is single-flighted so a burst of FromNum
    notifications can't stack coroutines racing on one characteristic. Wi-Fi Direct
    starts its UDP flood only once a group is confirmed up (a CONNECTION_CHANGED
    receiver + group-info check), not eagerly when the group is merely requested. Wire
    unchanged; Android compiled by CI, device QA is a PR6 item.

  • Bridges can be stopped and removed. Hub::unregister(iface) retires an
    interface by emptying its slot rather than removing it — ids are never recycled,
    because Flood's except addresses interfaces by index and a shifting vector
    would silently misroute it. A new nativeUnregisterIface JNI call exposes it, and
    the Android bridge list gets a Remove that cancels the bridge's pumps and
    unregisters its interface (Audio, BLE, Wi-Fi Direct, Web). Core-owned TCP/UDP show
    no control rather than a dead one — no fake UI. Rust side is unit-tested (stop one
    of two interfaces, the other keeps its id and traffic); the Android side is
    compiled by CI, device QA is a PR6 item. Wire unchanged.

  • Android: chat attachments stage until Send, then arrive as one bubble. Picking
    a file no longer publishes it immediately — it stages in the composer with a
    remove (✕) affordance, and Send produces a single bubble carrying the text and the
    attachment, identical for sender and receiver (a canonical
    📎 name | spore:<magnet> | mime marker, documented in docs/VISUALDESIGN.md Appendix A).
    Images preview inline (decoded off the main thread, sampled to 1080 px); any file
    opens through a FileProvider content:// chooser that vends only a reclaimable
    cache copy, never the private store. The sealed-to-a-known-peer publish path
    (contents and filename) is unchanged. Not yet device-verified — the apk job
    compiles it; manual QA is a PR6 device-matrix item.

  • S-024a: the Double Ratchet's skipped-key cache is now age-bounded (seven days,
    SKIP_TTL_SECS = PREKEY_LIFETIME_SECS) and zeroized on drop, closing the last
    forward-secrecy gap in core crypto. decrypt/skip take now; expired keys are
    purged before use. The session layer and the seal layer now read the same window,
    so SPEC §7's seven-day claim matches the code rather than only the prose. Wire
    unchanged — the ratchet is not on the frozen surface. Field-verification of the
    window on a device is tracked for a later PR.

  • main.rs dropped from 799 to 38 lines, finishing task #23. The CLI binary's
    three concerns moved into src/cli/{sim,config,run}.rs — the in-memory demo, the
    config parser, and the config-driven daemon — leaving main.rs as just main()
    and the dispatch. A pure move: a reconstructed diff against the original shows the
    only content changes are the visibility bumps the sibling-module split required
    (sim, parse_config, run_config, Spec, Config and its fields → pub(crate));
    every other line is byte-identical, and the demo prints the same output. Binary
    only — no wire contract, no frozen file touched.

  • lib.rs dropped from 3977 to 2205 lines. The 1776-line impl Node block
    moved into src/node/{identity,send,ingest,sync,datagram,files}.rs, each an
    impl Node in a descendant module of the crate root — so the methods keep full
    access to Node's private fields with no field's visibility widened. Nine
    private methods called across the new group boundaries became pub(crate) (the
    compiler's exact list); their bodies are unchanged. Node's private fields, which
    were already crate-visible via the crate-root descendant rule, are now reachable
    only from the node:: tree — a slightly tighter wall than before. Wire
    unchanged: reference/vectors.json reproduces byte-for-byte and the frozen API
    surface is untouched, so no frozen file was edited. Task #23.


⬇ spore-android.apk
— a permanent link that always serves the newest stable release.

This number versions the distribution, not the protocol. The SPORE v1
wire format and the crate's API shape are frozen and unaffected by it.

Before you rely on it: no radio bridge has been verified against real
hardware (every 🧪 in
BRIDGES.md),
and the Still open section of
SECURITY_FINDINGS.md lists
what is known and unfixed. Builds are debug-signed until a release keystore
is configured, so Android will warn about an unknown developer.


Every release also carries the means to rebuild it without this
repository, this site, or crates.io existing (see
MISSION.md):
the standalone browser node (spore-standalone.html, one file, zero
network requests) and a source bundle with every dependency vendored
in (spore-offline-bundle.tar.gz — unpack and cargo build --offline
works with no registry).
SHA-256 6fef7443a2f4daf186cdc5a2e69ea10c190902f504351128e4f932cf8c2624db