<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-common, branch 0.14</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.14</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.14'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-14T19:45:53Z</updated>
<entry>
<title>feat: a group can be left out of Search, and Search tries every node</title>
<updated>2026-09-14T19:45:53Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T19:45:53Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=cd2745cecff12e894e0dfa702bff6a90f0e8734e'/>
<id>urn:sha1:cd2745cecff12e894e0dfa702bff6a90f0e8734e</id>
<content type='text'>
`search_listed` is a per-group setting on the node, changed by a signed
operator op and carried in the sealed handshake ack. Search reads it after
the handshake and stops there: no index is fetched, cached or merged, in any
of the four views, and the page says how many groups it left out. The switch
is a "Search" section in the group's settings, shown to the operator.

Absent means listed, at every layer: roster default, ack default, and the
client only drops a group on an explicit `false` — so an upgrade or an older
node removes nothing from anyone's Search.

It is a listing preference and protects nothing: the node serves the same
index to Search and to the group page and cannot tell them apart, every
member lists the group by opening it, and a client that ignores the flag
lists it in Search too. Design §9.11 says so, so it is never described as
private. The cost is one handshake per unlisted group, because only the node
knows the setting.

Search also took `nodes[0]` twice — for the index and for the pooled
connection — the defect 4cce50f fixed on the group page only. One
`connectToGroup` now walks the list the same way: a refusal about this
browser stops, `not_hosted` or a failed connection moves on.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>chore: bump version to 0.14.0</title>
<updated>2026-09-14T12:45:19Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-14T12:45:19Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=0a07c029a4047ea0323c1020604ac9d3adeedde3'/>
<id>urn:sha1:0a07c029a4047ea0323c1020604ac9d3adeedde3</id>
<content type='text'>
Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T6jPTeocXA1BePekdsgPya
</content>
</entry>
<entry>
<title>fix: hold every background task, in both codebases</title>
<updated>2026-09-13T13:40:50Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-13T13:40:50Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=f2d9a026db453899e5bb50f101b1f5f1a9f91ddf'/>
<id>urn:sha1:f2d9a026db453899e5bb50f101b1f5f1a9f91ddf</id>
<content type='text'>
asyncio keeps only a weak reference to a task, so a coroutine started with
`asyncio.ensure_future(...)` whose result is discarded can be collected while
it is still running: the loop logs "Task was destroyed but it is pending!" and
the work simply does not happen. No error reaches the caller, and what is lost
is whatever that coroutine was in the middle of.

The node already had a guard for this, written after an abandoned stream task
lost a transcode slot for good — and it read one file, `webrtc_server.py`,
because that is where the defect was found. Outside that file there were
nineteen sites: the hub's `chat_notify` (a notification for every member of a
group), the indexer's debounce (every real-time index update), eleven in
`daemon.py` including the SIGHUP reload and each enrichment pass, two in
`ops.py`, and five in the loopback API.

`meshbay_common.background.spawn()` is the one door. It holds the task, drops
it when it finishes, and logs what it raised under the coroutine's own name —
an exception in a task nobody awaits was otherwise reported by asyncio at
collection time, out of context or not at all. A peer session's `_spawn` stays
as it is: that one can also *cancel* what it holds, which a module-level holder
cannot, because a session ends and a process does not.

`test_background_tasks.py` walks every package's source and refuses a discarded
handle. It parses rather than greps, so an assignment, a comprehension or an
await is not mistaken for one, and it was checked against a deliberate
reintroduction. A guard that stops at the edge of the file where the bug was
found is a guard against that bug, not against its class.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01UMxEQadpzPkYLFf5CYKhpW
</content>
</entry>
<entry>
<title>fix: an empty group claim is a claim on nothing</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T07:47:07Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4cce50f09a73739387d5058a6f8183ebac65ae2c'/>
<id>urn:sha1:4cce50f09a73739387d5058a6f8183ebac65ae2c</id>
<content type='text'>
A node that hosts no groups sends no `group_ids` on its hub socket, and the
hub resolved the claim with `set(claimed_groups or authorized)` — so "I host
nothing" arrived as "I host every group this account belongs to", other
members' included. Such a node can serve none of them: it holds no GEK, and
its own handshake refuses them with "Group not hosted on this node".

`/v1/groups/{id}/nodes` answers in registration order and `_node_groups` is
in-memory, so which node a client was sent to depended on who reconnected
first after a hub restart. GroupPage took `nodes[0]` with no fallback. On
2026-09-11 a hub deploy at 20:14 reshuffled the registry, a second member's
unconfigured node won the race, and a group stopped opening for everyone in
it with its only real host online throughout. Any member could take one of
their groups down, by accident, by leaving an empty node running.

Four changes, because no one of them is sufficient:

  - the hub never widens an absent claim, and `update_groups` goes through
    the same ceiling as registration — it assigned its list verbatim, so the
    bound that makes C2 hold at authentication was one message wide
  - the node states the empty set rather than omitting the field
  - the refusal carries `not_hosted`, so a client can tell "try the next
    node" from "you, here, must do something first"
  - GroupPage walks the list instead of indexing into it

The three lines involved date from 13, 20 and 23 August and each is
defensible alone. The defect is in the seam, which is where the last two
also were: a falsy empty collection must never mean "unspecified".

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>refactor(mnp)!: one operation for an app's folders, not one per app</title>
<updated>2026-09-10T15:49:58Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:49:58Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=07ff8b4f6143039fcc74b8cf7c423282bce093c1'/>
<id>urn:sha1:07ff8b4f6143039fcc74b8cf7c423282bce093c1</id>
<content type='text'>
`video_root`, `audio_root` and `photo_roots` are gone — the messages, the
signed operations, the handlers, the `ops` wrappers, the three scalars on the
handshake ack, and the client's handlers for their acks. `app_directories`
does the same thing for every application, keyed by the app's own registry
name, and it is what the SPA has been sending.

The three were the same instruction three times, differing only in the key they
wrote and whether they carried a string or a list. That shape is what made
adding an application mean adding a message type, an op, a handler and a widget;
it also meant three validation paths, and the older ones validated nothing —
a typo was stored and then quietly matched no entry, an app showing an empty tab
with no way to tell "misconfigured" from "no files yet".

**What stays, and why.** `Roster.LEGACY_DIR_KEYS` still reads `video_root` and
friends out of `group_settings`: that is a key on an operator's disk, not on the
wire, and a node upgraded into this must find its own configuration. The Search
page still reads its own older cache keys, for the same reason — the cache
outlives a deploy. `CTX_ALIASES` keeps only `chat`, which is the one app whose
second name something still reads.

The two per-app policy test files go with the messages. What only they held —
the real challenge/response path from message to database, which no other test
exercises — is retargeted at `app_directories` in
`test_app_directories_signed.py`, and the handler's own refusals (unknown app,
malformed `directories`, nobody to authorize it) join `test_app_directories.py`.

Node and common suites 1368 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>refactor(common): delete the Double Ratchet implementation nothing uses</title>
<updated>2026-09-10T15:32:00Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:32:00Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1e6f3861a1570029897a30b42121836fd03565c1'/>
<id>urn:sha1:1e6f3861a1570029897a30b42121836fd03565c1</id>
<content type='text'>
311 lines of Signal Double Ratchet and 167 lines of tests for it, with no
caller: group chat is a key per group, per epoch, per device, and a ratchet was
ruled out for it on the record — a node that serves history to devices which
were not present has to hand out each chain's earliest key, which is forward
secrecy of zero.

Deleted for the same reason `senderkeys.py` was: an implementation kept for a
use nobody has reads as an alternative somebody may reach for, and its cost is
paid at every refactor that has to keep it compiling. The four comments that
mention a ratchet keep doing so — they explain why this is not one, which is
the part worth keeping.

Common suite 158 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>refactor(common): delete the sender-key implementation nothing uses</title>
<updated>2026-09-10T15:30:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:30:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6cf21a019963468cb853e5c763ef0097115efa46'/>
<id>urn:sha1:6cf21a019963468cb853e5c763ef0097115efa46</id>
<content type='text'>
`senderkeys.py` and its 13 tests implemented Signal-style sender keys, and
production has never called them: chat is a key per group, per epoch, per
device, derived by name. The reasoning that ruled the ratchet out stays where it
belongs — in `chatbox.py`, at the top of the module that replaced it — because
the argument is the useful part, and it now stands on its own instead of
pointing at a file to compare against.

Kept code that nothing calls is worse than absent code: it reads as an
alternative somebody may reach for, and it has to be maintained past every
refactor to stay compiling, which is maintenance spent on a decision already
made.

The three comments naming `GroupSenderKeyStore` are rewritten to say the thing
they were illustrating.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>refactor(mnp)!: one answer to "may this member write", and it is the root</title>
<updated>2026-09-10T15:23:40Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-10T15:23:40Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=1dcedc77083b908b7b3b431bad679a4813884355'/>
<id>urn:sha1:1dcedc77083b908b7b3b431bad679a4813884355</id>
<content type='text'>
The group-wide `member_upload` switch is gone: the message, the signed
operation, the field on the handshake ack, the `upload` alias on every root in
the index payload, and the client's fallback path to it.

Whether a member may write has been a property of each root for a while, and
that is the model that survives: a single flag over the group cannot express
"this library is published read-only and that folder is a drop box", which is
the ordinary arrangement. What was left of the switch was a handler that logged
a deprecation and acted on nothing, and a client that read `ack.member_upload`
whenever the roots carried no `writable` — a second source for one question,
with whichever the code consulted first deciding it.

`roots.describe()` drops `upload` for the same reason: it was `writable` under
an older name, and two names for one boolean is one too many.

The paperclip now says "nowhere to write" rather than picking a root, in a group
that has none writable. That is the honest answer; the fallback picked whatever
came first and failed at send time.

Node suite 1215 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01AsoWC3GmhNdwVFomW3QjH3
</content>
</entry>
<entry>
<title>feat: MNP 3.0 — a transfer needs a lease</title>
<updated>2026-09-09T12:00:22Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T12:00:22Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac'/>
<id>urn:sha1:53ea44cb03ef6f8d941f6c8c9446551b0c5cd1ac</id>
<content type='text'>
Stage 4 of ~/next/improve-downloads.md, the flag day. Leases become compulsory
and a 2.x peer is refused at the handshake.

**The bound on leaseless reads (§3.4.1) did not exist, and it is what makes the
rest mean anything.** Browsing a group is never subject to a transfer slot —
that is an operator decision and a requirement: a member must be able to browse
a group at capacity exactly as they browse an idle one. But "not leased" cannot
mean "unbounded", or a client that simply omits `tr` transfers outside every cap
and the caps are decoration. A session may now read two distinct files at once
without a lease: one because a viewer looks at one file, two so that prefetching
the next photo stays possible. A count of files and not a byte budget, because a
RAW photo is 60-80 MB and is browsing while a 40 MB archive is a download, and
no size threshold separates them. Thumbnails, posters and cover art never reach
this check at all — they resolve out of the node's own cache.

It is a fairness control among cooperating clients, in the company of
`max_concurrent_streams`, and is not a defence against a member determined to
saturate a node's disk. That member is a member, and the answer to them is
`member revoke`.

**MNP_VERSION and MNP_MIN_SUPPORTED both move to 3.0**, on both sides. The
messages are additive; the requirement is not. An opt-in switch would leave a
leaseless branch reachable on every node, which is finding C6's lesson — a
transport that accepted a bare JWT — one feature later.

**The desktop client now checks before it connects.** The SPA is served by the
hub and picks up a new client on reload; the application ships its own
interface, so an un-updated one would sign in, list groups, and fail every
connection with `version_too_old` — a refusal in a protocol vocabulary with
nothing anyone can act on. It asks `/v1/hub/version` for `client.minimum` and
says so plainly instead. An unreachable hub is deliberately *not* "too old": a
captive portal or a closed laptop must not make starting the application
impossible.

**Every package is aligned on 0.13.0.** `meshbay-client/package.json` had
drifted to 1.0.0 while the Python packages were on 0.12.0 — invisible until
something compared those numbers, and then load-bearing: an installed client
announcing 1.0.0 sorts above a 0.13.0 minimum and walks through the gate meant
to stop it. That is stated in the code rather than left to be rediscovered; it
is acceptable exactly once, because the operator is updating every client, node
and hub by hand for this flag day. A new test fails if two packages ever
disagree again, and another fails if the hub would refuse the client the tree
builds.

Node suite 1209 passed, hub suite 861 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</content>
</entry>
<entry>
<title>feat: resume an interrupted upload, and pause one</title>
<updated>2026-09-09T10:01:06Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-09T10:01:06Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=4f5d3d4ac151874f03c6fcc451d6b1d5bb1efb78'/>
<id>urn:sha1:4f5d3d4ac151874f03c6fcc451d6b1d5bb1efb78</id>
<content type='text'>
Stage 8 of ~/next/improve-downloads.md, second half, plus the gap it exposed in
stage 7.

**Asking where to resume.** The node identifies an upload by (member,
directory, filename), so a client resuming one has to name the file — and
`transfer_open`, the obvious place to ask, travels in clear. Naming it there
would undo exactly what sealing this path bought in MNP 2.0: before it, the same
file was ciphertext leaving a node and plaintext arriving at one. So the
question is asked inside the seal that already exists, as an ordinary
`file_upload` with no bytes and `chunk_index: -1`. The node writes nothing,
creates no state, reserves no name, and answers with `resume_from` in the sealed
ack. A node that predates it refuses the index, which the client reads as "start
from the beginning" — the behaviour it had anyway — and the wait is bounded so
one that answers neither does not strand an upload.

The probe is answered after every check the write path makes, so it cannot ask
questions about a directory the caller may not write to, and it answers only
about the member who asks: otherwise one member could measure another's
progress on a file they never sent, and worse, resume it.

**Pausing an upload.** Reported: no pause button on an upload, even in the
desktop app. Stage 7 built pause around the download path — a target declares
whether it can be stopped — and an upload has no local target to ask. It was
also refused by design, since a transfer handed a lease it cannot re-create must
not be offered a button that would drop its slot for good. Uploads now ask for
their slot rather than being handed one, and say they are pausable outright: a
File is seekable and the node keeps the position. Resuming re-probes rather than
trusting the client's own memory, so it works across a reconnect too.

**And the slot they hold.** `_do_file_upload` never called `slots.touch(tr)`.
Chunks are not gated by the lease, so the file arrived — but the node reclaimed
a grant nobody appeared to be using after thirty seconds, twice, then abandoned
it, and the widget follows the lease. Measured from the journal: a 3.5 GB upload
read "waiting, 0 ahead" for a minute and a half while it was transferring. The
download twin of this was fixed on 2026-09-08; the same omission was still here,
invisible until uploads took a real lease.

`test_the_upload_itself_is_sealed` now checks every message `uploadFile` sends
rather than the first. Adding the probe put a second one in front of the one it
was written for, and it would have kept passing while guarding nothing.

Node suite 1202 passed, hub suite 850 passed.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01HCGdheDLxGReuKHga3BtST
</content>
</entry>
</feed>
