100 Commits
Author SHA1 Message Date
Greg Shuflin dd5e063271 Spelling: American, in the podcast save/drop copy
Build Debug APK / build (push) Successful in 5m17s
2026-09-07 00:42:38 -07:00
Greg Shuflin e52652c3a2 Android can save an episode, drop it, and play the copy
The player screen grows the same three-state control the desktop's cards
have — Save for offline / Saving… / Remove download — and episode rows
and queue rows carry the `⬇ Saved` mark beside the listen label.

The control is on the player screen rather than on every row, which is
where the desktop puts it: a phone row is ~350dp and already ends in the
queue control, so a second button would leave the title two words. This is
the same width decision as the two-column/stacked split on detail screens,
not a difference of taste — the rows still say whether an episode is
saved.

Playback opens the saved copy through `local_audio_path`, which decrypts
to a file under the app cache named after the blob's own address: a second
play finds it already there, and an episode re-saved from a publisher who
changed the file lands under a different name rather than silently serving
the old audio. Decrypting streams a chunk at a time, so a 200 MB episode
does not have to fit in memory twice on its way to ExoPlayer — which
`getBlobBytes` would have required.

A failed decrypt falls back to streaming: the enclosure is still there,
and refusing to play something because a cache write went wrong would be
the worse answer.
2026-09-07 00:42:38 -07:00
Greg Shuflin c40d80606f The desktop can save an episode, drop it, and play the copy
Episode cards and the episode screen grow one control in three states —
Save for offline / Saving… / Remove download — and a `⬇ Saved` mark that
also appears on queue rows. The mark is per node and is only ever an
addition: its absence says "not here", not "nowhere".

Saving is a tracked `Import` job keyed on the episode document, so the
click returns at once and two presses are one download. A save refused by
that dedup says so where the control is.

`episode_audio_source` is what makes any of it worth doing: every place
that hands an episode to the player now prefers the saved copy and falls
back to the enclosure URL, rather than three screens each deciding. An
episode saved and then streamed anyway is a download that cost bandwidth
and bought nothing.

`EpisodeRow` loses its derived `PartialEq`. `saved_locally` and
`saving` are facts about this disk, not about the document, and no sync
can change either — leaving them in would put a "these episodes changed
elsewhere" marker over the user's own download finishing. They are kept
fresh through `current` instead, which is allowed because a mark
changing in place moves nothing.

The episode screen also stops retyping "+ Queue"/"✓ Queued", which have
lived in `copy::podcast_queue` since the queue landed.
2026-09-07 00:42:38 -07:00
Greg Shuflin ee2a29c49f An episode can be downloaded and kept, and dropped again
The TODO's "no offline copy of an episode": playback streamed the
enclosure and nothing ever put the audio in the blob store, so an episode
could not be taken onto a plane.

`podcast::save_episode_audio` downloads the enclosure to a staging file
and seals from there. Two new `BlobStore` methods make that possible
without holding the episode in memory — `store_file` seals from a path
and `load_to_file` decrypts to one, both a 64 KiB chunk at a time. A
200 MB episode was 200 MB resident through `store`'s `&[u8]`, and the
phone is the node most likely to be asked to hold one. `store_file` reads
twice, because convergent encryption derives the content key from
`BLAKE3(plaintext)` of the whole blob — that price is paid in disk reads
rather than in memory, and a test pins the two paths to the same address.

Downloading gives the episode a document, whether or not anyone has
started listening: saving a back catalogue for a flight is something the
user has said about those episodes.

`release_episode_audio` drops the local copy, deliberately not through
`eviction`. That machinery exists to stop a user deleting the ensemble's
last copy and pays live presence queries for it; a podcast enclosure has a
copy the ensemble never controlled, at the URL on the document itself.
The two records eviction *would* have written are still written, through
its `record_local_release` — extracted here, because owing peers a
release log is not eviction's alone.

The episode document survives, and must: `Provenance::Syndicated` is
never tombstoned, since a deterministic id that has been tombstoned can
never be used again.

`saved_locally` joins the two batched projections both frontends already
read, so a list gets the mark without a query per row.
2026-09-07 00:42:38 -07:00
Greg Shuflin 86007fdd31 The Queue tab survives a trip to the player and back
`rememberSaveable` for the podcast library's tab, and the words on the
two-answer strip stop claiming only two screens draw it — the queue is the
third, over a hand-ordered list rather than a buffer, and what each of its
answers costs is different enough to say.
2026-09-07 00:42:38 -07:00
Greg Shuflin 30f3c78d9c Progress writing follows the transport, not the screen showing it
Playback outlives the screen on both modalities — Android's transport is a
`MediaSessionService` that keeps going from the notification, and the
desktop's bar sits under every tab of the Podcasts agent — but the write
loop was owned by the player screen. Leaving it flushed once and then
recorded nothing, so an hour listened from the lock screen came back as
the two minutes before the user navigated away. On the desktop it was
worse than that: the subscription hung off the podcast *detail* screen, so
an episode played from the Queue tab was never recorded at all.

Android gets a `ListenProgressRecorder` that follows the Podcasts handle
on the transport for as long as the app is alive; the player screen keeps
only the resume *read*, which is still its own (a position arriving
mid-episode must never seek under the listener). `flushListenProgress`
and the scope it needed go with it — there is nothing left to outlive.

On the desktop the same subscription moves up to `PodcastsView`, which
owns the player and lives as long as the app. The feed's remembered rate
stays with the detail screen, which is the only thing that knows which
feed it belongs to.

What is written and when is unchanged; `docs/audio-player-ui-design-\
principles.md` now says which lifetime it belongs to, since "flushed when
leaving the screen" was the line that made this look correct.
2026-09-07 00:42:38 -07:00
Greg Shuflin ccd942aad0 Android episode rows say where the listener got to
The phone already wrote the position and resumed from it; the list said
nothing, so an episode started and abandoned looked exactly like one never
opened.

A row's second line grows a third fact after its type and date — the
`DocumentDisplayModel.status` slot, null for every other kind of document,
which is most of them. The episode's words are `copy::listen_state`'s, the
same ones the desktop's cards draw, and they arrive already written on the
batched `episode_states` read the queue control was already making.
2026-09-07 00:42:38 -07:00
Greg Shuflin 06ace12d09 Android gets the play queue, and the control that fills it
`podcast_queue` — the cross-series queue the desktop has a whole tab for —
was desktop-only, which also left the phone with no way to put an episode
anywhere but straight into the player.

A Queue tab joins Subscriptions and New episodes in the podcast library,
and each episode row grows the `+ Queue` / `✓ Queued` control the
desktop's cards have. Both read their words from `copy::podcast_queue`.

The queue is ordered by hand, which makes it the sharpest case of
"a sync must never disrupt what the user is doing" on a phone: an
arriving reorder moves the row under a thumb already reaching for it. So a
re-read that differs from what is on screen is held and announced — the
same two-answer strip and the same words the desktop shows — and while it
is held the reorder controls are inert. The user's own moves apply at
once, and a write that fails re-reads the store and says so, rather than
leaving the list claiming a move the ensemble never heard.

Rows come through `episode_states`, one batched read per page rather than
one per row.
2026-09-07 00:42:38 -07:00
Greg Shuflin 4c6ad8043c One episode-list join and one set of queue words, for every frontend
The phone is about to grow a queue screen and listen state on its episode
rows, and both were about to be assembled a second time in Kotlin.

- `podcast::episode_store_state` is the batched join the desktop's
  `with_store_state` was doing by hand: derived document id, listen state
  and queue membership for a whole page of a feed, in one pass. Asking per
  row is two store round trips per episode on a list of thirty, and a sync
  re-reads the list.
- `QueuedEpisodeRow` carries the episode whole (`StoredEpisode`) rather
  than the four fields the desktop happened to draw. The queue is the one
  place an episode is reached without its feed's list in hand, so opening
  one from there has nowhere else to get its description or publish date.
  The property-reading loop it had grown is now `podcast::stored_episode`,
  shared with `list_feed_episodes`.
- `copy::listen_state::label` and `copy::podcast_queue` move the words
  out of the desktop screen: "▶ 12m left", "✓ Finished", "+ Queue",
  "✓ Queued", "Remove", and the empty-queue sentence. Two modalities
  drawing the same rows from the same synced artifact must not each spell
  them.
- The FFI grows `episode_states`, the five `queue_*` calls, the queue's
  incoming-change banner, and `podcast_queue_labels`.
2026-09-07 00:42:38 -07:00
Greg Shuflin 18341719d0 Android saves where the listener got to, and resumes from it
Android played episodes and remembered nothing. The artifact
(`ArtifactKind::ListenProgress`), the store functions, and the desktop's use of
them all existed; the phone simply had no path to them, which is also why it
created no episode documents at all once browsing a feed stopped writing them.

`ListenState` now crosses the FFI as itself — it is a three-variant enum, and a
mirror record would be a second place for those variants to disagree — under
three new `PodcastFfi` methods: `listen_state`, `save_listen_progress`,
`save_listen_finished`. All three sit on the `agents::podcast` functions the
desktop already calls, so both modalities write the same row through the same
code.

The player screen then does, in order:

- derive the episode's document id from its feed and publisher id, which writes
  nothing and works before any document exists;
- read the stored position and start playback there — read *once*, before
  playing, because a position arriving from a peer mid-episode must never seek
  under the listener;
- create the document on the first transition to playing, which is what
  "starts listening" means and the moment an episode earns a row;
- write the position every 5 seconds, and once more the instant playing stops.

Two ways this goes quietly wrong, both handled. A pause that only cancels the
write loop loses up to the interval, and pausing is how people ordinarily stop
— so stopping flushes rather than just returning. And the end of an episode
stops playback too, so the stop-flush would race the `Finished` write and could
land a position two seconds short of the end on top of it; the flush stands
down when the track ended. Leaving the screen flushes as well, on a
repository-owned scope, because playback outlives the composition and the
composition's own scope is already cancelling by then.

The write policy — every 5s, floor at 3s in, `Finished` rather than a position
at the end — is now written down in `docs/audio-player-ui-design-principles.md`
rather than decided twice. Both players' constants cite it. Android's live with
the writer in `PodcastRepository`, so the floor holds for every path that
writes a position instead of each call site remembering it.

What this does not do: the episode *list* still shows no listen state on
Android, where the desktop's rows read "12m left". And on both modalities the
write loop belongs to the player screen, not the transport, so a phone playing
from the notification after the screen closed records nothing past its parting
flush. Both are in TODO.md.
2026-09-07 00:42:38 -07:00
Greg Shuflin a4775169d9 Podcast episodes get derived ids, and a document only when there's something to store
Two changes that turn out to be one.

**Derived ids.** Episodes took fresh v4 ids and `save_feed_episodes`
deduplicated by scanning the feed's existing children for a matching
`PodcastEpisodeApiId`. That only compares against what *this* node holds,
so two nodes refreshing one feed independently created two documents per
episode and sync delivered both — the failure `docs/rss-reader.md` §3.3
exists to reject. `episode_document_id(feed, api_id)` derives the id
through `documents::derived_id`, and the dedup scan is deleted along with
`existing_episode_api_ids`.

**A document is what an episode with state looks like.** Searching a
podcast and subscribing wrote the first 50 episodes as documents, every one
of which then synced to every node. Nothing was stored *about* them:
listen position, queue membership and (eventually) downloaded audio are the
only reasons an episode needs a row. So `save_feed_episodes` becomes
`ensure_episode_document`, singular and idempotent, called at the moment
the user plays or queues an episode — not when they browse one.

The two changes are the same change: a derived id is what makes browsing
without writing possible. A row the API just returned can be asked what its
document id *would* be, so a listing carries listen state and queue
membership for episodes no node has ever written a document for, and the
document that later appears lands at exactly that id.

The list on screen is now the publisher's, from the API, with the store
layered over it — plus any episode this node holds a document for that has
since dropped off the feed's first page, which would otherwise silently
vanish. A failed fetch falls back to what is held rather than erroring, so
an offline node still reaches what it has state on.

Android loses its offline episode list and gains no writer: it saves no
listen position and has no queue, so nothing there creates an episode
document yet. `PodcastAgent.ensureEpisode` is the seam; TODO.md carries it.
2026-09-07 00:42:38 -07:00
Greg Shuflin 013c885d60 One helper computes every derived id, instead of seven near-copies
Several kinds of row here have an id that is a function of what they are
rather than a fresh v4 — a music artist from its tag, an agent's settings
row, a fact under a key — which is what lets two nodes create "the same"
thing without coordinating and converge on one row after sync.

Every one of them derived its id for itself, and while they all agreed on
UUIDv5, the particulars drifted: a namespace constant per site, three
conventions for joining key parts (`\u{1f}` in facts, `:` in anchors,
`\u{0}` in credentials), and no shared answer to whether the parts arrive
already normalized. Agreement on those particulars *is* the point — two
derivations that differ in any of them produce two ids just as surely as
two different algorithms would, and each side is internally consistent, so
nothing notices.

`documents::derived_id` is now the only place `Uuid::new_v5` is called for
a row id. A caller names a `Domain` and the parts of its key; the namespace
and separator are the helper's. Canonicalization moves in with it, as
`folded` (trim + case-fold) and `collapsed` (adds NFKC and a whitespace
squeeze), both idempotent so a caller that normalizes for its own reasons
can pass the result straight through. The `Domain` enum is the registry of
every derived id in the codebase; adding one has nowhere else to go.

Ids move, once. The project has no backwards-compatibility constraint and
key-derivation salts are explicitly in scope for breaking changes; the two
"have not moved" tests that pinned the old per-site formulas are replaced
by one pinning this one.

Also adds `Provenance::Syndicated` — deterministic id, listed, never
tombstoned — which the podcast agent needs next and no existing variant
said. `Anchor` has the same three predicate answers but means "an agent's
well-known singleton"; there is one syndicated document per item a
publisher released.
2026-09-07 00:42:38 -07:00
Greg Shuflin a78af76391 Vault: bind vault_id as AAD when wrapping a vault key
Build Debug APK / build (push) Successful in 11m47s
Every wrapped key in vault_keys is sealed under the same node-local wrapping
key, and the wrap used empty associated data — so a row's ciphertext was
interchangeable between vaults, and the table's primary key was the only thing
saying which vault a key belonged to. Asking for one vault's key and being
handed another's would have succeeded quietly.

Binding the id makes that fail authentication instead. It matches what every
other AEAD call site here already does: a blob chunk binds its plaintext hash
and index so it cannot be spliced elsewhere, and a sealed node name binds its
pubkey so it cannot be lifted onto another member's announcement.

This changes the wrap format, so existing vault_keys rows no longer unwrap and
an existing store fails to start with a decryption error. Acceptable per the
project's status; a store is re-initialized rather than migrated.

Found while writing docs/vault-pddb-research.md (§4F).
2026-09-01 03:40:14 -07:00
Greg Shuflin d938010159 Docs: correct the vault sections of data-model and document-sync
Four claims found while writing docs/vault-pddb-research.md, none of which the
code has supported for a while:

- Blob content encryption is built (ADR 0028), so the data model's "remaining
  step" is only Property and Artifact values. The note now says which boundary
  that leaves standing: blob bytes, and nothing else.
- document-sync said a receiver without the vault key "cannot decrypt the
  content". It cannot open blob bytes; PropertyRecord.value is MessagePack
  plaintext on the wire and in SQLite at both ends.
- It also had the default vault's UUID being HKDF-derived from the ensemble
  public key. The id is the nil UUID constant; the *key* is what is derived,
  from the ensemble secret. Id and key had been conflated.
- Vault key versioning describes machinery that does not exist — vault_keys has
  no version column and default_vault() hard-codes version 1 — so it now says so
  and points at the revocation 3c item that wants rotation first.
2026-09-01 03:29:52 -07:00
Greg Shuflin 9c083f61cc Docs: mine Xous' PDDB for the Vault design
Vaults are specified but almost entirely unbuilt, so this is the cheap moment
to read the closest prior art. docs/vault-pddb-research.md is the comparison:
what transfers (the union view and its write-target rule, enumerate-vs-unlock,
key commitment, an offline store reader), what does not (a noise-filled fixed
container, make-before-break journaling, device-bound AAD, a per-device
pepper), and the pitfalls — most of which bite harder here than they did on a
single 100MiB device, because an ensemble manufactures the disk snapshots that
deniable storage cannot survive.

Its conclusion is that a locked vault and a hidden one are different products
and only the first is reachable, so TODO.md now carries the work that implies
and docs/long-term-todo.md carries the hidden vault, its trigger, and the three
storage rules it would have to be built under.
2026-09-01 03:23:03 -07:00
Greg ShuflinandClaude Sonnet 5 15b0db7a9d Spelling: British -> American across in-app copy, docs, and code
Build Debug APK / build (push) Successful in 5m51s
Rewrites every British spelling found in the codebase to American: neighbour,
colour, catalogue, behaviour, honour, flavour, grey, judgement,
acknowledgement, licence, defence, organise/optimise/initialise/recognise/
analyse, artefact, cancelled/cancelling, labelled/labelling, modelled/
modelling, and centre/centred/centring all become their American forms —
consistently in prose (docs, ADRs, comments) and in identifiers, including
the FFI-shared CatalogueSync/Cancelled enum variants and catalogue_* fields
that cross the Rust/Kotlin boundary.

Left untouched: external API names that only look like a spelling variant
(kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's
labelLarge) and the EFF diceware wordlist used for key derivation.

Adds a Code Style rule to AGENTS.md/CLAUDE.md requiring American spelling in
user-facing copy and documentation going forward, with that same carve-out.

Verified: cargo build --workspace, all 1410 Rust tests, Kotlin compile,
fmt-rust/fmt-kotlin, lint-kotlin-fast (detekt), and the lint-gui-layout/
lint-sql-params/lint-p2p-admission custom checks all pass clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHWzCF6oNiCV4ue1bBf1t
2026-09-01 03:01:16 -07:00
Greg ShuflinandClaude Opus 5 2104fc9e62 Docs: the alert-don't-apply flow now has three callers, not one
Build Debug APK / build (push) Successful in 5m11s
TODO.md's clippings item is closed. ADR 0039's `NoteEditor` prediction
gets its second correction: the guard was the hard part, not the editor,
and TextArea is what a card-sized buffer with no TextFormat wants.

ADR 0031 gains an amendment for what the third caller surfaced — that the
'identical to what the editor already holds' comparison cannot live in
SyncGuarded, because the type would then have to hold a text buffer and
stop being gpui-free. Both editable-buffer callers make it before feeding
the guard, and that is a decision worth recording rather than
rediscovering. It also stops claiming Android has no equivalent: it has
the rule (ui/IncomingBody) and the shared words, just not the type.

AGENTS.md's worked examples pointed at notes/NotesScreen.kt for a symbol
that now lives in ui/IncomingBody.kt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:28:51 -07:00
Greg ShuflinandClaude Opus 5 ef83ff3fe5 Clippings become editable on Android, on the same guard
ClippingEditSession is Android's half of the desktop's SyncGuarded: it
holds the open buffer, the store baseline and a version arriving from
another node, and IncomingBody.alertFor decides which reads are news and
which are this node's own save coming back. A ViewModel rather than
remember, for the reason NotesEditorSession is one — a decision that
vanishes on a rotation is not one the user was given.

The alert-don't-apply rule and its strip were the note editor's alone and
are now shared, because two screens holding whole-buffer last-write-wins
text is exactly when a rule stops belonging to one of them: IncomingBody
moves to the ui package with its test, and NoteEditorPane's private
banner becomes ui/IncomingBodyStrip taking a subject's IncomingChangeCopy.

ClippingsFfi gains update(), which the module doc used to say it would
not export; the reason it would not was the missing guard, and the guard
now has a name to point at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:24:48 -07:00
Greg ShuflinandClaude Opus 5 c887ba971b Clippings become editable on the desktop, behind the sync guard
A clipping's body is LastWriteWins, so an editable buffer over one is
exactly the case CLAUDE.md's first principle is about. The screen now
opens a TextArea over one clipping at a time and holds a SyncGuarded
<String> around it: while an editor is open it re-reads that clipping on
every store change, and a body this node did not write is announced in
the card and held rather than applied. Save is inert while a version is
unresolved — the banner's Load theirs / Keep mine are the only writes
that have an answer for what they overwrite, and copy::incoming_change
gains a ClippingBody subject so they say the same thing the Notes pane
does.

An editor is on screen whether or not its row is: adopting a held list
page can push the edited clipping off the loaded rows, and a decision
with nowhere to make it is not one the user was given.

NoteEditor was the component ADR 0039 expected to reuse; TextArea is
what shipped. NoteEditor owns a whole pane (monospace, Tab-indent,
markdown list continuation) and a clipping is edited in the card it is
read in, beside a composer using the same box for the same text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsACwkLMA9MUh5bvgAnZnK
2026-08-31 20:09:06 -07:00
Greg Shuflin 03a1e7471f Ensemble: stop claiming per-peer reachability the mesh cannot report
Build Debug APK / build (push) Successful in 5m9s
The reachability matrix was recorded as blocked on data — each node
gossiping its neighbour set, every node assembling the N×N picture. The
data was never the problem. The matrix arrived as the answer to "what goes
in the rectangle where the rejected node editor was", and DIRECTION.md
§8.5 argues for it as a picture: every pair appears twice and reads the
same, "which is what co-equality looks like drawn". True, and not a
question anyone was asking the screen. It is withdrawn, not deferred.

The same defect was already shipping at 1×N. The roster read the live
gossip neighbour set per peer and drew everything outside it as Degraded —
but iroh-gossip keeps a bounded partial mesh, holding a small neighbour
set on purpose and dropping edges it does not need, so a node we could
dial in one hop showed as degraded whenever the swarm had no edge to it.
That badge reported swarm topology in the voice of connectivity.

Remove it rather than repoint it at iroh Endpoint::remote_info. The
screen already probes on demand — Ping reports a real round-trip, Sync now
reports what happened — and an on-demand probe beats a dot that is stale
the moment it is drawn. Gone with it: MemberConnectionState, the
connection_state field, P2PNode::active_neighbor_ids_hex,
GossipDiscovery::active_neighbor_ids, and the dot/glyph on all four
modalities. active_neighbors stays for neighbor_count, with a doc comment
saying why it must not be exposed per peer.

The aggregate line had the same defect and is reworded, not deleted:
"{n}/{m} other nodes reachable now" becomes "N gossip neighbour(s)", a
bring-up diagnostic that claims nothing about reachability. On the desktop
it moves from the roster header into the Membership fact grid, where a
diagnostic belongs. All three phrasings now come from
copy::ensemble_roster — the TUI already disagreed with the other two —
reaching Android through an ensemble_roster_copy FFI record.

What the badge was groping toward is a real question, and it is now in
long-term-todo.md: when this node is continuously updating a document
meant to reach the ensemble live and that stops happening, the user should
be told. That is a claim about what they are doing, not about the network,
and it cannot be designed before some agent actually pushes a live
continuous update — podcast playback position being the first candidate.
2026-08-31 17:51:04 -07:00
Greg Shuflin d9efd613d2 TODO: three items move to the long-term list, which now says why things land there
The preamble claimed the distinction was not size or difficulty. It is,
sometimes: theming, self-hostable relays and parallel asset fetch are all
deferred because the cost is out of proportion to what they buy today, or
because acting now means guessing at a design decision. Name the three
reasons an item lands here, and admit that not every entry has a crisp
trigger.

Also drops the Security section from TODO.md, which had nothing open left
in it.
2026-08-31 16:59:11 -07:00
Greg Shuflin 8d9b6a65d0 linkages: a token carries capabilities, not the whole command surface
Build Debug APK / build (push) Successful in 4m4s
ADR 0017 decided against capability scoping in v1 — a paired client got what a
CLI session gets — and wrote down the price as "the same blast radius as a
compromised CLI session", worth revisiting. What made it worth revisiting was
not a use case; it was reading the list next to the fact that the holder is a
browser sandbox with no filesystem access of its own. ImportFiles reads any
path the daemon's user can read, into the store where the client reads it back.
MaterializeDocument writes any path it can write. PurgeDocument destroys data
ensemble-wide. Shutdown stops the node, and was reachable because it was
handled *above* the two hand-written guards rather than below them.
CredentialCmd::{Set,Clear} rewrites the node's API keys, and simply did not
exist when those guards were written — which is the whole argument for not
doing this with guards.

So: five capabilities in stores::linkage, one per command through
DaemonCommand::required_capability. The match is exhaustive, and that is the
part worth having — a command added without deciding what it costs stops the
build instead of inheriting the last variant's reach.

Transport becomes Caller, because the question is who is asking rather than
which socket they reached: Owner is answered unconditionally (reaching a socket
inside a 0700 store directory already means being the user who owns it), Linked
carries the linkage id. One gate, before anything is done, replacing both
guards. A holder may revoke its own linkage and no other — that is the
extension's Unlink, and it needs the caller's identity rather than the
command's cost, so it lives in Caller::permits.

Credentials are NodeAdmin in full, including the read. Status carries no secret
value, so Read would be defensible, but no linkage has a use for any of it and
relaxing this later is easier than clawing it back. Linkage listing likewise:
a client knows its own, so what a listing offers is everything else the owner
has paired.

Reused from the parked linkage-security branch: the Capability enum, the
required_capability shape, the Caller/permits split, and the revoke-your-own
carve-out. Dropped from it: LinkageScope::{Companion, Full}, `pair --full`, the
stored scope column and the narrowest-reading rule for an unknown scope. The
qualm recorded in TODO.md was right — nothing wants Full. The extension's whole
vocabulary is four commands, so a two-member scope model has one real member
and one guess about linkages that do not exist yet. Policy over capabilities is
cheap to add against a second client and expensive to un-guess once it is on
every row.

The loopback tests move with the model: a paired client can no longer enumerate
linkages, and the escalation test reads the count from the store rather than
over a wire that now refuses. New tests draw one command per refused capability
rather than re-asserting the two cases that used to be special-cased.

ADR 0017 gets the amendment; TODO.md's Security section is now empty and says
so.
2026-08-31 03:32:29 -07:00
Greg Shuflin ddcc8ee021 docs: ADR 0052 — what each of the two local listeners may trust
A node answers local callers on a Unix socket and a loopback TCP port, and
"local" has been doing damage: it suggests one trust boundary where there are
three, with different populations on each side, and more than one bug here has
come from reasoning about the wrong one.

Mostly a consolidating record — permissions, Origin refusal, CORS, the pairing
challenge, hashed tokens, pairing refused over loopback all already exist, and
their reasoning was scattered across module docs and one commit message. Three
things it settles rather than restates:

- **Code running as the node's own user is not a boundary and will not be
  made one.** It can read store.db directly, and it can drive the socket's
  full surface including pairing. The loopback mitigations look like they
  should help here and do not: they defend against a caller who cannot read
  your files.
- **Offline copies are out of scope**, stated as the model rather than as an
  omission. The whole store directory is the secret; its mode is the whole of
  the at-rest story.
- **A token is not a CLI session**, which reverses ADR 0017's v1 grant
  decision. That one is a real decision and the implementation follows.

The deferred half — optional start-time password protection — moves to a new
docs/long-term-todo.md, for work where the decision to not build it has
already been taken and an entry in TODO.md would keep reading as unfinished
business. Each entry there carries what would make it worth doing; this one's
is the threat model gaining offline copies, and its note says to reach for a
keychain or TPM before a prompt, since only the headless node has no
alternative.

TODO.md loses the encrypt-at-rest item and gains a line saying where that kind
of thing goes now. The Argon2id entry stays but records that its one concrete
caller has moved.
2026-08-31 03:13:30 -07:00
Greg Shuflin ff95b9b6a3 The presence grid stops reading authorship as possession
`node_status::artifact_presence_for_node` was rewritten during revocation
phase 3a so that a remote node reads `Present` only on that node's own word
from `NodeContentStore` — an artifact's `origin_node` is the sender's
unsigned claim about a third node, and a relayed record carries a claim its
relayer never checked either. The helper grew four paragraphs saying so.

The desktop did not call it for every row. A node named as an artifact's
origin that has no `NodeDocumentRecord` gets an extra row on the grid, and
that row was assembled by hand twenty lines below the import: `Present` if
the local store held the artifact value, `Ref` otherwise. So the inference
the phase removed was still live on the one modality with a presence grid, and
still on the screen a user reads before deciding what is safe to delete
locally. Any ensemble member could put a `Present` on any node's row by
naming it as an author.

The fix is a third function beside the other two rather than a second
correction, because the failure mode here is a caller that assembles a row
itself: `artifact_presence_without_record` takes the same evidence its
siblings do and floors at `Unknown`, not `Ref` — a node with no record has
not claimed to hold the document either, so even the weak rung is unearned.
Two tests pin it, next to the ones that pin the same rule for the helper it
was supposed to mirror.

Nothing else derives a decision from `origin_node`: the daemon and the
Android FFI both go through the helper, and the TUI and CLI only print it.

With that closed, per-record signatures for provenance are settled as not
worth building, and TODO.md's item goes. The arithmetic is in
NODE_REVOCATION.md's Open Questions — briefly, 64 bytes on eight record types,
a signature column on seven tables because a signature has to survive relay,
a verification per row, and a cert to carry for an origin never met, in
exchange for three read-only surfaces and against an adversary holding one of
the user's own delegation certs. §3a records what would reopen it: any point
at which attribution stops being decoration and starts being load-bearing.
2026-08-31 00:54:26 -07:00
Greg Shuflin b3c3e440eb An ALPN's version number lives in its constant and nowhere else
Build Debug APK / build (push) Successful in 18m34s
Every P2P protocol's ALPN carries a version suffix, and it moves whenever the
wire shape does — this project has no backwards-compatibility constraint, so
bumps are routine. A number copied into prose is wrong from the next bump
onward and nothing fails when it rots, so seven of the eleven versioned
references in docs/ were already stale, and so were twelve of the nineteen in
doc comments.

Everything outside the constants now reads `/syn/doc-sync/…` and names the
constant for the live value. Where a bump is itself the point — a change to
what a message *means*, which is what the convention exists for — the docs
still say a bump happened, just not which number it landed on.

Three things this turned up that were not merely stale:

- `p2p/revocation_delivery.rs`'s module header called the protocol
  `/syn/revocation`. There is no such ALPN; the constant eight lines below it
  is `/syn/membership`. NODE_REVOCATION.md had copied the wrong name twice.
- `document-sync.md` carried a per-version changelog that stopped at 6 and
  skipped 4. Rewritten as what the bumps added, keeping every cross-reference
  and dropping the numbering that rots.
- ADR 0014's title named `/syn/node-admin/1` in the heading and in the index
  row, two bumps ago. The ADR number and filename are unchanged, so links
  still resolve.

AGENTS.md gets the rule, since this is the second time it has been cleaned up
and nothing was stopping it coming back.
2026-08-31 00:32:17 -07:00
Greg Shuflin f4afbbaa31 TODO: membership epochs are built, and two smaller items closed
An audit of TODO.md against the tree. Three entries described work that has
since landed:

- Revocation phase 3b. `ensemble::epoch`, `stores::epoch`, the admission
  check in both directions, gossip carriage and `retire::{bump_epoch,
  recertify}` are all in. What is actually left is the affordance — only the
  CLI and daemon can bump or rejoin, and no other modality even renders
  `current_epoch`/`this_node_epoch_is_stale` — plus 3c and 3d.
- The browser extension's store icons exist and `just extension build`
  packages a zip; Firefox signing is the remainder.
- PDF deep zoom no longer upscales a fit-resolution bitmap on either
  modality — both re-render at the active scale. What survives is the 4096px
  raster cap, past which it softens again, and only tiles lift that.

Everything else in the file was checked against the code and still stands,
including the ones that read as if they might not: the TUI's `i: import`
imports a revocation record rather than files, `JobOrigin::RemoteAdmin` is a
variant nothing constructs, and `linkage-security` is still a branch on
origin.

The same drift had reached the prose each of those entries sends you to, so
it is fixed here too:

- `NODE_REVOCATION.md` said phase 3 was design; its Status, its §3 preamble
  and its §3b heading move with 3b.
- `content-placement-redesign.md`'s header said steps 3-7 were unbuilt while
  §4 of the same file marked all seven done.
- `sync-management.md`'s summary table still described `SyncPolicy` as
  consumed by nothing, `SyncDisposition` as a store awaiting a caller (it is
  deleted), and Delete Locally, the imports surface and blob streaming as
  placeholders. Rewritten against the code, and pointed at the phasing
  section at the end, which had stayed current and is the better authority.
- `scheduler::mod` and `gui-app`'s `scheduler_driver` both said Android had
  no driver and no Wi-Fi/charging constraint, and sent the reader to a
  TODO.md entry that no longer exists. Android has `SchedulerWorker`, a
  `WorkManager` job that lets the platform enforce exactly those, and
  `syn-daemon` has a third driver neither comment knew about. The scope note
  now says what it was always getting at: the constraint belongs to the
  driver, not to `lib`. `SchedulerWorker`'s own dangling pointer to that
  TODO entry goes with it.
2026-08-31 00:09:53 -07:00
Greg Shuflin 58de31334a peer_health kind two: the peer is on a newer build than this node
Build Debug APK / build (push) Successful in 5m45s
decode_records_tolerantly drops any record whose bytes this build cannot
deserialize — an enum variant from a peer on a newer SYNC_PROTOCOL_VERSION —
and until now said so only in a WARN plus a transient clause on the Ensemble
row's Sync button. The scheduler's unattended passes, which is where most
syncs happen, reported it nowhere at all: a phone one build ahead of a laptop
loses documents on every tick and the roster stays silent. That is the same
argument that justified peer_health for clock skew.

PeerHealthDetail::SchemaAhead { peer_version, records_skipped }, written by
record_schema_gap from both ingest sites — the client pull and the server's
reverse pull — so a gap does not depend on which node started the sync.

Unlike clock skew this needs one writer, not two. The header carrying the
peer's version opens every exchange whether or not the batch behind it turns
out to cost anything, so it is the announcement's counterpart: it decides
whether there is a row, and parity clears it. The skip count only enriches it,
and is carried forward across a clean incremental batch so the message does not
flip back to "nothing has been lost yet" while the gap is still open. A skip
from a peer on the *same* revision is corrupt bytes rather than a version gap,
raises no row, and keeps the WARN.

The copy's job is direction. Every other message in copy::peer_health sits on
a row naming another node and is about that node; this is the one where the
device to go and fix is the one being read, so it says so twice — "Running a
newer version than this node", and UPDATE_THIS_NODE spelling out that nothing
needs to change on the other device. It also quotes both revisions and, when
records were actually dropped, asks for a manual sync as well: the automatic
one only carries what changed since it last ran, so updating alone will not
bring back records it has already walked past. The old WARN promised otherwise.

No UI on any of the four modalities, as the store's docs claimed: every
frontend renders through copy::peer_health rather than matching the enum.
2026-08-30 23:13:08 -07:00
Greg Shuflin dc3fc2edaf Clock-skew reconciliation is closed: dropped writes stay the sender's problem
The two open questions on the wire-time bound are both decided as "no".

A record the bound refuses is dropped and not queued. Retransmission belongs
to the sender — the node with the wrong clock is the only one that can make
its own records believable — and a queue here would hold data this node has
already said it does not believe, re-adjudicated against a bound that moves
with the wall clock. The reasoning moves into wire_time's module docs, beside
the drop-vs-clamp decision it sits next to.

Persisting announcement recency across restarts is dropped outright; its
reasoning was already written out at PeerMap::upsert, so the TODO entry was
only a second copy.

Both TODO sections go, rather than becoming Done write-ups.
2026-08-30 23:13:08 -07:00
Greg Shuflin d1c42774e4 Settings' tab row scrolls instead of squeezing
Build Debug APK / build (push) Successful in 5m34s
A fixed TabRow divides the width evenly among its tabs, so the fourth section
turned "Sync policy" into two lines — and every section added after this one
would have made it worse. SecondaryScrollableTabRow sizes each tab to its own
content and scrolls the row, which is the case Material 3 names as the reason
to pick it over a fixed row.

edgePadding = 0 so the first tab lines up with the rest of the screen; the
default inset reads as an indent rather than as a row that scrolls. maxLines =
1 on the label so a tab that somehow still runs long elides rather than
silently reintroducing the wrap this removes.

No arrow affordances: on a touch surface the indication is the next tab
showing past the edge and a swipe. Arrows are a pointer-device pattern, and
buttons at both ends would spend the width this exists to reclaim.
2026-08-30 21:39:00 -07:00
Greg Shuflin 4b473c3bf5 Clippings page as they are scrolled, on both modalities
ADR 0039 left two open items — an unpaginated `list_children`, and a "Load
more" that both screens capped around instead of implementing. They were one
problem: the cap was what kept a plain row loop inside ADR 0037's rule, and
removing it is what makes the collection genuinely unbounded on screen.

The read: DocumentStore::list_children_page takes the DocumentCursor the
document listing already uses and the same (created_at DESC, id DESC) ordering,
so paging documents and paging one document's children mean the same thing. The
type filter is in SQL, so `limit` counts rows *returned* rather than rows read
— a page of two clippings, not two children some of which are clippings. A new
(parent_id, created_at, id) index serves it; the existing parent_id index finds
a parent's rows and then sorts all of them, which for the Clippings root is the
whole cost this removes. Newest first, unlike list_children, because paging a
growing collection from the oldest end puts every arrival on a page nobody has
loaded.

The desktop: gpui::list rather than uniform_list, because these rows are
variable by design — a clipping is clamped until it is clicked, then as tall as
its text — and uniform_list gives every row row 0's height. That costs three
obligations, all of them in the code: expanding a row calls remeasure_items
rather than reset, which would send the user to the top for having clicked
something; an appended page is a splice at the end, so nothing above it moves;
a delete is a reset, because the row measured at index n is no longer the row
there. The error line, composer and changed-elsewhere marker moved outside the
list, since a virtualized list addresses items by index and three
sometimes-present things at the front would make every row's index depend on
how many are showing.

Both screens fetch ahead of the edge on scroll rather than behind a button: a
list of clippings is scanned, and stopping to click every hundred rows is worse
than the wait it avoids. settled() now reads the list's own scroll top, so a
user who has paged down cannot have rows replaced under them.

Android's held-page strip now carries the whole page rather than its rows:
showing it re-anchors on the first page, and the cursor that follows the *held*
page is the one paging has to resume from — keeping only the rows left the
cursor pointing past a page no longer loaded.

ADR 0037 gains a `gpui — list` section: it presented uniform_list as the gpui
answer, and that is no longer true. It also gains the consequence this taught —
a cap is not virtualization, only a deferral of it, and the day the cap lifts is
the day the real idiom is owed.
2026-08-30 21:34:46 -07:00
Greg Shuflin 0cf915f0a7 The Podcast Index key leaves the source entirely
It survived ADR 0051 as two #[cfg(test)] constants, on the reasoning that the
live-API test needed something to authenticate with. That reasoning is wrong
twice: a key in a public repository is published the moment it is committed
however it is gated, and a test path that quietly supplies one hides that the
production path is unconfigured.

What replaces it is one mechanism with one set of names.
credentials::env_var_for derives SYN_CREDENTIAL_<SERVICE>_<FIELD> from the
service and field, so a new ServiceId variant or a new field needs no second
table; import_from_env seals whatever the environment supplies, skipping a
blank or unset variable rather than storing an empty secret — half an
environment fills in half a credential instead of wiping the other half.

"syn settings credential-import <SERVICE>" provisions a store from those
variables in one non-interactive command, with nothing in shell history. It
reads the environment CLI-side and sends one Set per field rather than adding
an Import the daemon resolves: the daemon is a different process with a
different environment, so a daemon-side Import would consult the wrong one —
silently, and differently depending on whether a daemon happened to be up.

The live-API test calls import_from_env and returns early with the variable
names printed when nothing is set, so a checkout that cannot be expected to
carry a key does not fail over one. Verified both branches: it skips with an
empty environment, and passes against the real API with the key supplied.

import_from_env delegates to a private import_from taking the lookup as an
argument, so the four rules in that loop are tested without mutating a
process-global that the developer's own shell also writes to.

The key still needs rotating at the provider; TODO.md and ADR 0051 say so, and
now also say where a test gets one from.
2026-08-30 21:13:24 -07:00
Greg Shuflin 7411358675 docs: ADR 0051 is Accepted, and 0039 gets the name its agent already has
0051: the store shipped, and so did the surfaces the design deliberately left
open. Adds "What the surfaces settled" rather than editing the deferral out of
the Consequences, because the placement was a decision and not a detail of
drawing: its own Settings tab rather than a block under System (it is the one
place in Settings where the input is a secret, and it grows per service), a
section per service rather than one listing them all, and both screens building
their inputs from ServiceId::fields so no frontend knows what a credential is
made of. The two copy constants that followed are recorded with the reason each
exists.

0039: 5a97bd2b renamed the Snippets agent to Clippings — the AgentId, the
DocumentType, the settings pointer, the daemon command, both screens — and left
the ADR describing all of them by the old name, along with the four other ADRs
that reference it. Renamed throughout, file included, with a dated note at the
top saying what it was called and that the rename changed no decision in it. A
design record nobody can grep for is a design record nobody reads.

Its last consequence was stale on substance too: it expected NoteEditor to be
reused unchanged, and what shipped is read-only on both modalities because an
editable buffer a sync can land on needs SyncGuarded built around it first.
Recorded as what was expected and what happened, since the reason is the
useful part.

0050 is deliberately untouched: it is the ADR that *proposed* the rename, so
its `Snippet` is the pre-rename name on purpose.
2026-08-30 21:13:24 -07:00
Greg Shuflin bc51f10b9f Credentials section sits in the same shell as its sibling tabs
It rendered a bare Column while Sync policy and Ensemble each wrap in
SubAppScreen, so it was the one section in the tab row with no title and no way
back. Same shell, titled with copy::credentials::TAB.

Its save loop was a firstNotNullOfOrNull over the filled fields, which reads as
a search and is really a write with a short-circuit; spelled out as a loop that
stops at the first failure, so the message on screen is the one that happened.
2026-08-30 20:46:53 -07:00
Greg Shuflin b5d3a0490b Credentials becomes its own Settings tab, on both modalities
The service-credential store already codifies what a credential is: ServiceId
is a closed enum with Podcast Index as its variant, and ServiceId::fields says
what parts it is made of. What was missing was a place to type one in that
isn't a block near the bottom of System, and any way at all to do it from a
phone.

Desktop: Tab::Credentials, one section per service rather than one section
holding them all — a service is what the user came here for, and its own
heading is what tells them which service the two fields below belong to.

Android: CredentialsFfi (services/set_field/clear) and a Credentials section in
the settings tab row. The form is built from ServiceId::fields, so nothing in
Kotlin knows Podcast Index takes a key and a secret; the next service is a Rust
variant and no frontend work. Nothing reads a stored secret back, here as on
the desktop — the status line answers the only question a settings screen has.

copy::credentials gains TAB (the strip wants the short word) and WHERE, so the
sentence a feature shows when a key is missing names one path rather than each
surface inventing one; unavailable() now sends people to "Settings ▸
Credentials" instead of to a heading that is no longer where they'd look.
2026-08-30 20:42:01 -07:00
Greg Shuflin 4b8a74ed5d Clippings on Android
TODO.md's "Nothing on Android": clippings have synced to the phone since ADR
0039 and there was no screen to see them on, only a placeholder saying so.

Adds ClippingsFfi (list/get/capture/delete) over the existing ClippingsManager,
a ClippingItem record, and the Compose screen. Deliberately the same screen as
the desktop's rather than a phone-shaped reinterpretation: same order, same row
(the clipping itself, not a title of it — the title *is* its first line), and
the same read-only stance on existing clippings, whose bodies are
last-write-wins and would need the alert-don't-apply flow before an editor is
honest. No update crosses the FFI for that reason.

Arrivals follow the same rule as the desktop: applied when the user is plainly
idle (nothing expanded, no composer open, list at the top), held behind a
ChangedElsewhereStrip otherwise. That strip's button now reads action::SHOW
rather than a Kotlin literal, so it says what the desktop's says.

AgentPlaceholderScreen goes with it — it existed for this one agent, its doc
comment says so, and every agent Android lists now routes to a real screen.

(PodcastAgent.kt is ktfmt reflowing a comment the previous commit left long.)
2026-08-30 20:31:20 -07:00
Greg Shuflin bc7d17323d copy::clippings, before a second modality retypes it
The Clippings screen's words lived in the gpui screen: its heading, its empty
state, the source line, the held-back-arrivals marker, and both of its error
sentences. Android is about to render the same list, which is the moment
CLAUDE.md's rule bites — a paraphrase between two screens is two answers to one
question, invisible until someone opens both.

Moves them to copy::clippings, with source_line's test, and adds derivations
for the two lines that depend on state: pending(n) for the marker's count and
body_elsewhere for a clipping whose text is still on another node.

action::SHOW joins them: the button on every changed-elsewhere marker, which
the desktop and Android's ChangedElsewhereStrip had each typed for themselves.
2026-08-30 20:20:52 -07:00
Greg Shuflin 3b194a1a6a Cap Settings' explanatory prose at a reading measure
The Settings tabs already cap their body at CONTENT_MAX_WIDTH (1040px), which
keeps a label/value row's two ends associated. That is the wrong cap for a
sentence: at font::BODY a 1040px line runs about 150 characters, twice the
45-75 typography settled on, and the eye loses the line on the return sweep.

Adds sizing::layout::PROSE_MAX_WIDTH (560px) and applies it inside
section_description, so every tab's explainer gets it without each one
remembering to. section_description now takes any string rather than a
&'static str, which lets the credential-removal confirmation — a sentence built
at runtime, previously a hand-rolled div — be styled by the same helper instead
of drifting from it.
2026-08-30 20:10:14 -07:00
Greg Shuflin 5e7374edda new test ensemble key 2026-08-30 19:53:28 -07:00
Greg Shuflin a00ad94a30 Copy strings carried their own source indentation into the sentence
Five strings in copy.rs were written across source lines without the \
continuation, so the leading whitespace of the next source line landed in the
string. The Service credentials explainer was the visible one: it rendered as
"...They are stored          encrypted and sync..." with a gap mid-paragraph
on the Settings screen.

Adds a test that scans copy.rs's own text for a run of spaces inside any string
literal — it covers the ones inside a format! that no test calls, and the slip
is otherwise invisible to rustfmt, clippy and review.
2026-08-30 19:51:22 -07:00
Greg Shuflin 3dcbb1375c Service credentials: a sealed store, with Podcast Index as its first tenant
Build Debug APK / build (push) Successful in 4m58s
Implements ADR 0051. A `ServiceCredential` document per service, id derived
from the service, `Provenance::Machinery` so it is hidden from every listing and
never purgeable. Each field of a credential is an `ArtifactKind::SealedSecret`
whose row id derives from `(service, field)`, so re-entering a key overwrites
that field and two nodes given the same key converge on one row.

Values are sealed with the ensemble's vault key via `vault::encrypt_blob`, which
takes arbitrary bytes and is not tied to the blob store. This is the first
inline artifact that is not readable in the database — every display path
(`ffi::helpers`, the daemon's artifact listing, the GUI's document detail) shows
"(sealed)" rather than a value or a length, and a test asserts the plaintext is
not in the stored bytes.

Podcast Index moves onto it. The two constants are gone from the non-test path;
`search_podcasts` and `get_podcast_episodes` take a store and resolve the key
through it, which turned them into methods on `PodcastFfi` since a free FFI
function has no store to reach. The published key survives under `#[cfg(test)]`
solely so the network tests can provision a store with it — deliberately not a
fallback, so a node with no credential is told so rather than silently borrowing
a key the app shipped. It is in this repository's public history and needs
rotating at the provider.

UI: a System-tab section (enter a field, Save, Remove behind a confirmation with
a Cancel), and `syn settings credentials` / `credential-set` / `credential-clear`
— the latter reading the value from stdin when omitted, so a key need not land
in shell history. A credential is never readable over the daemon protocol, only
its status: a linked extension reaches the whole command surface (ADR 0017), so
a Get would hand every paired client every key the user holds.

Wire version 19: three new closed-enum variants.

Not verified here: the Kotlin changes (PodcastAgent gains search/fetchEpisodes,
mirroring the Rust move). This machine has no Android toolchain, so
`just lint-kotlin-fast` could not run.
2026-08-30 19:22:14 -07:00
Greg Shuflin b416121b58 docs: ADR 0051 — service credentials as sealed documents on the catalogue plane
Design for the store ADR 0050 depends on. A credential is a document
(ServiceCredential, Provenance::Machinery, id derived from the service) because
the document plane is the only thing that syncs, and syncing is a hard
requirement: an outbound effect runs on whichever node is awake, so a key
reachable only where it was typed works by luck. Machinery already means hidden,
non-purgeable and deterministically identified — four wanted properties, none
unwanted.

Keyed by service rather than by agent: the near-miss is an AgentSettings
document, but a credential is not agent state, and keying by agent gives one key
per (agent, service) pair silently duplicated.

Sealed inline with the vault key rather than blob-backed, even though the blob
store is already encrypted: blob content is the content plane, legitimately
absent on a node that has not fetched it, and a credential missing on the node
that happens to be awake is the exact failure this prevents.

States two things rather than leaving them to be found: convergent encryption
means equal values have equal ciphertext, and the existing hardcoded Podcast
Index key is in a public repository's history, so removing it from source does
not un-publish it — it needs rotating at the provider.
2026-08-30 19:22:14 -07:00
Greg Shuflin 5a97bd2b6b Rename the Snippets agent to Clippings, and its document type to Clipping
`agents/snippets.rs`'s own module documentation already said "clippings"
throughout while the type said `Snippet` — the codebase reporting that it picked
the wrong name the first time. "Snippet" reads as text-only, and ADR 0050 widens
the charter to posts and images, so the word has to cover more than text.

`AgentId::Snippets` -> `Clippings`, `DocumentType::Snippet` -> `Clipping`,
`PropertyKind::SnippetsDefaultRoot` -> `ClippingsDefaultRoot`, and the manager,
error, daemon command, CLI subcommand, GUI screen, browser-extension wire keys
and Android agent mapping with them. Both enums cross the sync wire, so
SYNC_PROTOCOL_VERSION goes to 18 and the fingerprint test's expected lists are
re-sorted — that test caught the omission rather than review doing it.

Done now rather than with the feature: this is cheapest while the agent owns one
type and one screen, and every modality added makes it dearer.

Uses of "snippet" that are not about this agent — a markdown snippet in the
markup golden tests, a rendered math snippet, a keyword-bearing code snippet in
the syntax-highlight fixtures — are left alone.
2026-08-30 19:22:14 -07:00
Greg Shuflin 7b36f8fd57 docs: ADR 0049 is Accepted — parentage stays a field, and its merge rule shipped
The decision has been in force since it was written and the work it named as
outstanding landed the same day, so Proposed no longer describes it. The ADR
README says this flips in place. Also corrects one stale line: the topological
pass is still necessary, but it lives in stores now rather than doc_sync.
2026-08-30 19:22:14 -07:00
Greg Shuflin d228f08297 docs: the identity helper is a consolidation, and credentials do not live with the node secret
Two notes from reviewing what already exists.

The podcast agent is the counter-example to the shared identity function, not a
partial solution: it derives nothing, and save_feed_episodes dedups by scanning
a feed's existing children for a matching PodcastEpisodeApiId — which only
compares against what the local node holds, so two nodes refreshing one feed
produce two documents per episode and sync delivers both. Six UUIDv5-from-a-key
derivations already exist independently across music, agent settings, anchors,
facts, edges and the podcast queue; the helper ADR 0050 needs is a consolidation
of those, with podcast episodes moved onto it.

And the credential store does not belong in or beside the `secrets` table: a
node secret is one row, never leaves the device, and losing it means the node is
gone, while a service credential is the user's, travels to every node, and costs
a re-paste. The `CHECK (id = 1)` that is right for one is exactly wrong for the
other.
2026-08-30 19:22:14 -07:00
Greg Shuflin fb93cff4ad docs: ADR 0050 — captured posts, and a note for the credential store they need
A saved microblog post gets its own DocumentType rather than being a Snippet
with a URL, on ADR 0039's own test: two timestamps (posted vs captured),
identity that has to converge when two nodes save the same post, blob-backed
media that only a type can carry a sync policy for, and an author that
documents::contact can already canonicalize.

The agent's charter widens to cover text, posts and images, and it is renamed
Clippings — agents/snippets.rs's module docs already said 'clippings' throughout
while the type said Snippet, which is the codebase reporting it picked the wrong
name first time. Cheapest to fix now, while it owns one type and one screen.

Also decides: the post id function is shared with the future Feeds agent from
the start, canonicalization included, so a saved post and a timeline item are
one document rather than two with independent state; media are Attachment
artifacts, not child Image documents (the comics precedent rejected on purpose);
threading is facts holding uris; an image captured on its own is an Image
document with a SourceUrl, closing a gap ADR 0039 named.

TODO gains the credential store ADR 0050 depends on and does not design — with
the constraints it inherits, and the hardcoded Podcast Index key as its
acceptance test.
2026-08-30 19:22:14 -07:00
Greg Shuflin 20765adcf4 stores: the parents-before-children ordering rule moves to the schema that requires it
topological_sort_records existed in p2p::doc_sync because a batch of incoming
records has to be ordered before insert — but the reason it has to be is the
documents.parent_id foreign key, which is the store's fact, not the protocol's.
A caller that rediscovers it does so from an FK violation.

stores::document::parents_before_children now owns the ordering. It is generic
over the item and its key, so the store never learns what a DocumentRecord is;
doc_sync keeps a three-line topological_sort_records that says only how a wire
record answers 'what is your id, and your parent's'. It takes no transaction
and reads nothing: the schema's rule, not a query.

Unorderable rows — a cycle, or a parent that is nowhere — are still kept and
appended last rather than dropped, now stated as a decision: ordering a batch
is this function's job, and declining to insert a row is the caller's. The
existing doc-sync sort tests pass unchanged; three more cover the tail case
they never reached.
2026-08-30 19:22:14 -07:00
Greg Shuflin 26c6766b22 stores: own the schema outright — no in-place patches, no SQL outside stores/
Three related moves, all in the same direction: the store layer is the only
thing that knows what the database looks like.

Removes every in-place schema patch. Until there is an explicit migration
system, the schema is created once on a fresh store and never touched again:
gone are add_provenance_column, add_parent_modified_at_column,
backfill_set_elements, rebuild_stale_edges_table (which sniffed sqlite_master
for a substring before dropping a table), peer_nodes' error-swallowing
'let _ = ALTER TABLE', and node_content_status' node_blob_status fold-and-drop.
cert_fingerprint moves into peer_nodes' CREATE TABLE, and the DEFAULTs on
provenance and parent_modified_at go with the ALTERs that needed them — a
NOT NULL with no default is the honest declaration when every insert supplies
the column.

Moves reseed's GRAPH_TABLE_COLUMNS and its bulk copy into stores::graph_copy as
Store::copy_graph_tables_from, with the schema-coverage test. reseed.rs now
holds no SQL at all and no longer imports rusqlite.

Parameterizes title_kind_case_sql. The type and kind names are this crate's own
&'static strs on closed enums, so the previous sql-literal-ok waiver was sound
in the narrow sense — but a CASE arm does take a ?, and the waiver was the only
place a value reached SQL as text. The three callers now thread its binds in at
the fragment's own position.
2026-08-30 19:22:14 -07:00
Greg ShuflinandClaude Opus 5 2f3526ce56 syn-daemon: the envelope test asserts key sets, not key order
Build Debug APK / build (push) Successful in 4m12s
the_raw_value_envelope_is_the_same_json_in_a_different_key_order passed under
just unit-tests-rust and cargo test -p syn-daemon but failed under cargo test
--workspace. It asserted that routing a body through serde_json::to_value
alphabetizes the object's keys, which holds only while serde_json's
preserve_order feature is off.

That feature is not ours to set. gpui enables it, so cargo's feature
unification decides it per build graph rather than per crate: off for the
recipe, whose default-members excludes gui-app, and on for --workspace, which
pulls gui-app in. A per-crate opt-out is not available, and dropping the
dependency would mean patching gpui.

Nothing on this wire depends on the ordering either way. Both readers -- the
browser extension and humans running curl -- look fields up by name, artifacts
serialize typed enums in declaration order, and every signature in the project
is taken over a hand-built byte payload rather than over serialized JSON. So
the test now compares the parsed trees and the key sets at both levels, which
is what the byte comparison was really protecting, and names the body's three
keys so a field that goes missing or arrives renamed is still caught. Under
preserve_order the two encodings are in fact byte-identical, so the old
assertion could not have been rewritten to hold in both directions.

Keeping the feature off leaves --workspace as a canary for order-dependence
creeping in elsewhere -- worth more than pinning an order here for a difference
that is not a contract. The reasoning moves from TODO.md into the test's doc
comment, where whoever is debugging the failure will actually meet it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMAMMoRBw7j1tLfCHH1HUv
2026-08-30 19:21:47 -07:00
Greg Shuflin 5513b66cc7 TODO.md vault note
Build Debug APK / build (push) Successful in 4m13s
2026-08-30 05:38:58 -07:00
Greg Shuflin 7475e52aa4 docs: record the parentage merge landing in ADR 0049, and what is left in TODO
Build Debug APK / build (push) Successful in 3m59s
2026-08-30 03:41:32 -07:00
Greg Shuflin 66f47504c4 documents: parentage merges last-write-wins, and a move can no longer make a cycle
A document's structural fields were write-once: doc_sync inserts a row only
when it is absent and never updates one, so DocumentStore::set_parent wrote
locally and the ensemble never learned. NotebookManager::move_item moved a note
on the node the user was sitting at and nowhere else.

Adds documents.parent_modified_at — the one merge stamp on the document row —
and sync_receive_parent, which reconciles a peer's move last-write-wins. It
declines three ways, none an error: our stamp is newer, the destination has not
synced to us yet (applying would break the parent_id FK; our older stamp means
the move is re-offered later), or the move would close a cycle.

set_parent now refuses a move into the document's own descendant outright,
which is the case a single node can decide. Two nodes making moves that are
only a cycle together cannot be decided locally, so the merge path refuses to
store it and says so in the log; converging that properly wants a move log with
undo/redo by timestamp, which this deliberately does not build.

Also fixes a bug this surfaced: reseed's GRAPH_TABLE_COLUMNS never listed
provenance, so every reseed silently downgraded anchor, derived and machinery
documents to user. A new test compares the list against the live schema so the
next missing column fails rather than defaulting.
2026-08-30 03:41:32 -07:00
Greg Shuflin 47532ce30b docs: ADR 0049 — parentage stays a field on the document; edges are for facts
Records the decision not to replace documents.parent_id with an
EdgeKind::ParentChild, and the test meant to outlive the question: if the link
were lost, would you have lost a fact or would you have a broken document?
Facts are edges, structure is a field. Every existing EdgeKind passes it —
the two that are structural (ArtistAlbum, AlbumTrack) have Derived endpoints,
so their loss is bounded by a rebuild.

Notes honestly what the rejected option would have supplied for free: parent_id
has no merge rule at all. doc_sync inserts a document row only when absent, so
a document's structural fields are write-once and set_parent never propagates —
NotebookManager::move_item moves a note locally and the ensemble never learns.
Also names that nothing enforces acyclicity on set_parent. Both are now the
outstanding work this decision inherits.
2026-08-30 03:41:32 -07:00
Greg Shuflin c43982c1a7 docs: ADRs 0047 and 0048 — constituent documents, and blobs shared by several documents
0047 proposes a fifth Provenance variant, Part, for a document that is a
constituent of another rather than a thing in its own right (a comic page,
media attached to a captured post). Hidden from search and general listings by
default, revealed by an include_parts flag of its own — not by the type-naming
override, since Image is a mixed type. Rejects agent ownership (an opinion, not
a fact; fails the many-to-many case ADR 0025 treats as normal) and a parent-type
test (is_container() is {Collection, Notebook}, so it would hide every podcast
episode).

0048 states the rule for content two documents reference: liveness is a
reference count over live artifacts, bytes are deletable only when nothing
local still wants them, and every figure that promises space is deduplicated by
hash. Adds the missing primitive — nothing today writes an existing BlobRef
onto a second document — and requires that sharing be disclosed wherever it
reduces what an operation will free.

Both Proposed; nothing implemented yet.
2026-08-30 03:41:32 -07:00
Greg Shuflin 9bd407f815 gui: the roster toolbar follows the table's width, not the window's
Build Debug APK / build (push) Successful in 5m1s
The container stretched to the window while the header and the member rows
sized themselves to their columns — both `self_start`, for the border reason
noted on `render_member`. So "Refresh" tracked the window's right edge and sat
an arbitrary distance from the table it acts on, moving whenever the window
resized.

Making the container shrink-to-fit puts one thing in charge of the width: the
widest member row sets it, and the toolbar stretches to match, which is the
flexbox default once the container stops filling the window.
2026-08-30 03:22:40 -07:00
Greg Shuflin 3ede837fc7 TODO: cargo test --workspace fails a syn-daemon test the just recipe passes
Feature unification: --workspace pulls in gui-app, whose tree turns on
serde_json/preserve_order, and the key-order assertion in
`the_raw_value_envelope_is_the_same_json_in_a_different_key_order` only holds
with that feature off.

Worth an entry rather than just a memory because the failure looks like a flaky
or freshly-broken test, and the test's own doc comment says the feature "is not
on here" — true for every supported invocation, false for the one someone
reaches for when they want to check everything at once.
2026-08-30 03:22:40 -07:00
Greg Shuflin 2f679a2028 gossip: an unbelievable announcement time must not set the recency mark
`PeerMap`'s high-water mark exists to stop a *replayed* announcement — old,
still validly signed — pushing a peer's cached relay URL and name backwards.
It compares two sender-supplied times, so it needs the same bound every other
incoming timestamp already gets through `wire_time`.

Without it the guard becomes a denial. An announcement from the future sets a
mark that peer's own honest announcements cannot beat until this node's clock
catches up with the wrong one — so a device a few days out spends those days
unable to correct its relay URL, its name, or, now that this screen reports it,
to say that its clock has been fixed. The message that resolves the problem is
exactly the message being discarded.

An unbelievable stamp therefore contributes "now" to the ordering — not a clamp
to the edge of the believable range, which would still lock the peer out for a
whole day. The value itself is untouched: it is what the skew report is derived
from, and only the ordering is held to what this node can believe.

Found while running the skewed-node scenario end to end.
2026-08-30 03:22:40 -07:00
Greg Shuflin 071b7384a9 justfile: fix the daemon flag order, and libfaketime's offset syntax
Both found by actually running the skewed-node scenario end to end.

`daemon-cli` passed `daemon --foreground --store DIR`, but `--store` is a
global flag and `--foreground` belongs to `daemon start` — bpaf refuses it with
"flag `--foreground` is not valid in this context". That took
`test-env-daemon-a` and `test-env-daemon-b` down with it; both work now.

The `faketime` wrapper hands its offset straight to `date`, so it wants
"+3 days" rather than libfaketime's terse "+3d" — that spelling only works in
the FAKETIME environment variable, and the wrapper answers it with "Timestamp
to fake not recognized".
2026-08-30 03:22:40 -07:00
Greg Shuflin 4ff0dd6849 TODO: the clock-skew rejection is no longer silent 2026-08-30 03:22:40 -07:00
Greg Shuflin 66641cd311 tui, cli: a peer's clock problem where a headless node can see it
The TUI splits the same way the data does: one word per problem in the member
list, coloured by severity, and the sentences in the detail pane opposite. The
list is too narrow for prose, and without a mark on the row there would be
nothing to tell the reader which cursor position to move to.

`syn ensemble members` prints the headline and detail indented under the peer,
the shape the function already uses for this node's own revocation and
stale-epoch notices. This matters more here than anywhere: on a VPS there is no
roster to look at, so this listing is the whole diagnostic surface, and the
alternative to printing it is reading the daemon's log.

That needed `health` on `EnsembleMemberPeer` too — `ensemble members` builds a
leaner snapshot than `EnsembleStatus`. `#[serde(default)]`, like `revoked`
beside it, so an older daemon's answer still parses.
2026-08-30 03:22:40 -07:00
Greg Shuflin 0f3e20ab07 android: a peer's sync problems on its card and its node screen
Additive, because the card already stacks: a word beside the name — the same
place the revoked mark goes, and for the same reason, since this is something
the row *is* — and a tinted notice under the two controls carrying the headline
and what to do about it.

Under the controls rather than above them. A notice that pushed Ping and Sync
down every time a peer's clock drifted would move a button out from under a
thumb already reaching for it.

Every word and the amber/red choice come from `copy::peer_health` over the FFI,
in one crossing per issue and only when the issue list changes — these rows are
written by gossip and sync ingest, never by anything happening on screen. The
derivation in particular is not re-implemented here: which of three sentences a
clock problem gets depends on the bound in `p2p::wire_time` and on what the
last sync cost, and a Kotlin paraphrase would be a second answer to "has this
node stopped syncing?".
2026-08-30 03:22:40 -07:00
Greg Shuflin ced789ae1b gui: the roster row gains a line to speak on, and stops painting over itself
Two things, and the second is why the first has anywhere to go.

The action block was `w(COL_ACTIONS)` — a fixed width holding a status slot
and four `flex_none` buttons. Once the buttons' natural widths outgrew it
nothing could shrink, and `justify_end` pushed the overflow off the block's
*start* edge: the leftmost child, the ping/sync status slot, painted its green
text across the "Last synced" column. Adding "Revoke…" was enough to cross
that line, and the previous fix — widening the constant from 370 to 470 — only
moved where the next control would cross it again. It is a `min_w` now, which
cannot be outgrown.

A member row is a column of one or two lines. The identity line is unchanged
and unchanged in height, so a quiet roster is still one line per node. Under
it, only when there is something to say, a message line carrying the transient
ping/sync result and any unresolved sync problem — with the row's whole width
instead of 110px, which is the room a sentence like "Clock is 3 days fast —
its edits are not being accepted" needs to be read rather than hovered.

The border and `self_start` move out to the enclosing column so a two-line row
keeps one divider under the pair rather than growing one through its middle.
`status_priority` and its three regression tests are untouched: that policy
was right, it just renders somewhere else now.
2026-08-30 03:22:40 -07:00
Greg Shuflin 1a4ee93786 ensemble status carries each peer's sync health to every frontend
One field on `EnsembleMemberInfo`, and that field is the whole distribution
story: the desktop consumes `EnsembleStatus` directly, `ffi/ensemble.rs`
re-exports it over uniffi, and syn-daemon's `EnsembleStatusSnapshot` wraps it
verbatim — so the TUI gets it without a line of protocol work.

Read as one query for the whole roster rather than one per row, and degrading
to "nothing wrong" rather than failing the status — the same trade the
deny-list and coverage reads already make. Not being able to say what is wrong
with a peer is not a reason to be unable to say who is in the ensemble.

Always empty for this node: every issue here is something this node observed
about a peer, and a node cannot be out of step with its own clock.

`copy::peer_health::copy` bundles the four derived pieces into one record so
Android crosses the FFI once per issue instead of four times per repaint. The
daemon's JSON round-trip guard now carries a populated issue: `PeerHealthDetail`
is an internally-tagged enum nested two records deep, and that test is the only
thing that would notice if it stopped surviving serde on the way to the TUI.
2026-08-30 03:22:40 -07:00
Greg Shuflin e92d5f5b5b p2p: measure a peer's clock, and record what its skew cost
Two writers, and the distinction between them is the design.

Gossip announcements carry a signed `announced_at_secs`, so the time in one
is attributable, and they repeat on a timer whether or not anything syncs.
That makes the announcement the authoritative measurement — the only one that
can say a clock has been *fixed* — so it is the one allowed to clear a
standing warning. It is taken after the staleness check: a replayed
announcement is validly signed and, measured against now, looks exactly like
a clock that is behind.

`SkewedStamps::report` knows the other half — how many fields the bound
actually refused, which is the difference between "this clock is wrong" and
"this clock has thrown away twelve of your edits". It only ever adds. A sync
that refused nothing may simply have carried nothing, and reading that as
"resolved" is how the roster would have flapped between the two messages for
as long as the clock stayed wrong.

The two therefore compose rather than overwrite: an announcement carries a
sync's drop count forward, and a sync's skew — a lower bound, since it only
saw what its batch happened to carry — never revises a better announcement
measurement downwards. Both regressions have tests.

`peer_health` is threaded to the four ingest paths as an `Option`, which the
doc-sync unit tests pass `None` for; they build sub-stores rather than a whole
Store, and a `None` costs only the persistence, not the log line.
2026-08-30 03:22:40 -07:00
Greg Shuflin c58ca5fd7e peer health: a store and the words for what is wrong with syncing to one peer
`p2p/wire_time` has bounded incoming timestamps for a while: a stamp more
than 24h ahead of this node's clock is dropped along with the record field
carrying it. That closed the corruption case and did it silently — a peer
whose clock is wrong simply stops merging, and the only sign is a WARN line.

`stores::peer_health` is where that stops being invisible. One row per
(peer, kind), holding a serde-encoded `PeerHealthDetail`; a row exists only
while its condition holds, so resolving one is a DELETE rather than a status
flip and no screen has to decide whether a stored "ok" is stale. The JSON
detail column is what makes this the place a peer's sync problems are
recorded rather than a clock-skew column — a second kind is a variant and a
writer, not a migration.

`copy::peer_health` owns the derivation as well as the strings, per the
`content_fetch` rule: which of three sentences a clock problem gets, and
whether it draws amber or red, is a function of the stored numbers against
the live bound in `wire_time` — not something four frontends should each
work out and then disagree about.

`format_duration_rough` alongside it. Its two-of-a-unit rule is load-bearing
rather than taste: it keeps a 47-hour skew from reading as "1 day" in a
sentence whose other half says the limit is 1 day.
2026-08-30 03:22:40 -07:00
Greg Shuflin 2888741fa1 test env: a node with a deliberately wrong clock, via the flake
Exercising the timestamp bound in `p2p::wire_time` needs a peer whose clock
is actually wrong, and the honest way to get one is to move the whole
process's wall clock — so the peer's signed gossip `announced_at_secs` and
every `modified_at` it writes are wrong together, which is the real failure
rather than a simulation of one.

libfaketime comes from the flake's devShell rather than a system package, so
the scenario reproduces on any machine with the flake and is pinned to the
same nixpkgs as everything else.

The recipe builds first and fakes only the built binary: running cargo itself
under a future clock would corrupt its mtime-based freshness check. `-m`
selects libfaketime's thread-safe build, since the daemon is thoroughly
multi-threaded, and FAKETIME_DONT_FAKE_MONOTONIC keeps QUIC's own timers on
the real monotonic clock — without it iroh's timeouts move too and the nodes
fail to connect for reasons unrelated to the test.
2026-08-30 03:22:40 -07:00
Greg Shuflin 9e8b79ecbc Add TODO.md note
Build Debug APK / build (push) Successful in 3m44s
2026-08-30 02:14:06 -07:00
Greg Shuflin 0bcb270a2d Remove old TODO item
Build Debug APK / build (push) Successful in 3m43s
2026-08-28 04:20:30 -07:00
Greg Shuflin ab4edae5a2 android: the two endings get their borders back
Build Debug APK / build (push) Successful in 5m12s
Both Retire and Reset are now FlatButton's footprint in danger colours — a
full-width bordered rectangle, 56dp tall, tinted with the error colour — as
the old Reset button was. A bare TextButton next to a paragraph of body text
had no edge to read as a control at all, which matters more here than
elsewhere: these two are the destructive pair.

Stacked rather than in a row, unlike the desktop's, because "Retire this
node…" beside "Reset this node…" is ~170dp each on a phone and both wrap.
Same width-driven split the label/value lists already make.

The spinner keeps the Reset button's place while a wipe runs and both
buttons fade, so nothing moves.
2026-08-28 04:18:13 -07:00
Greg Shuflin 0717e62f86 Retire and Reset sit together under "This node" on the Ensemble screen
Reset was in System's danger zone and retire on the Ensemble tab, which put
RETIRE_VS_RESET — the one sentence that distinguishes them — above only one
of the two buttons it describes. The choice between them is the decision, so
a user who found reset on its own screen would never learn that retiring was
the option they wanted.

Both now sit in the same section in the same weight, retire first. On
Android that also means the two are the same control: reset had been a
full-width bordered Surface and retire a TextButton, which read as unrelated
acts of different severity rather than as alternatives. The progress spinner
takes the reset button's place while the wipe runs.

RESET_SEE_RETIRE goes with it — it existed only to point at the screen the
other button was on.
2026-08-28 03:54:11 -07:00
Greg Shuflin 6e2364fd5e android: retire moves to the Ensemble screen, behind a confirmation
Retire was a bare TextButton in Settings that navigated straight into the
retirement screen — a screen that clears the back stack, swallows the system
back button, and cannot be cancelled. One stray tap was the whole
confirmation. It now asks first, with the same modal shape and the same
words the desktop uses (retireConfirmTitle/Effect/OneWay were already
exported; only the button label was missing).

It also moves to the foot of the Ensemble screen, matching the desktop and
for the same reason: signing your own revocation and handing it to a peer is
the same act a row's Revoke performs on someone else. Reset stays in
Settings, and its dialog now reads its sentences from copy::revocation
instead of a Kotlin string literal.
2026-08-28 03:00:47 -07:00
Greg Shuflin a4b96fa51b gui: retire moves to the Ensemble screen, and Reset joins System
Retiring is an ensemble act — it signs a revocation for this node's own
credential and hands it to a peer, which is exactly what a roster row's
Revoke does to someone else, only self-signed. So it belongs with the roster
it affects, under a "This node" heading at the foot of the Ensemble tab,
rather than buried in System's danger zone.

Reset is the one that isn't: it deletes the same data and tells nobody, so
it stays under System, and the sentence beside it now says what it does not
do and points at the retire control. The desktop had no reset at all before,
which made "clear this and start over" a matter of finding the store
directory.

retirement.rs becomes teardown.rs, because both endings share everything
from the wipe onwards — cancel the jobs, drop the runtime, delete, sit on a
terminal screen with Quit — and only the half before it differs. An Ending
picks which; a reset skips straight to the wipe, and never offers "Wipe
now", whose sentence is about a record it does not have.
2026-08-28 03:00:39 -07:00
Greg Shuflin e6dc7bf783 Shared copy for resetting a node, beside retiring it
Android's reset dialog had its own hand-typed sentences and the desktop had
no reset at all, so adding one meant either retyping them or writing them
down once. They go in copy::revocation, next to RETIRE_VS_RESET: reset is
defined by that contrast, and now that the two controls sit on different
screens each has to say where the other one is.

reset_failed takes the manual path as an argument — clearing app storage on
Android, deleting the store directory anywhere else — the same shape
retired_store_empty uses for the same reason.
2026-08-28 03:00:30 -07:00
Greg Shuflin 4eb877550e Revoked nodes are off the roster and out of the node count
A freshly registered node draws a row reading "(unnamed) · revoked" for
every retired node in the ensemble's history: it learns the deny-list over
gossip and the retired node's stale pkarr address record from the registry,
and has no other trace of a node it has never met and will never reach.
Those rows were also counted in "N total nodes", which is the number people
read off the screen.

They used to be listed unconditionally, reasoning that a marked row beats a
node silently vanishing. That holds for a node the viewer knows; it does not
for one they don't. The count of revoked nodes stays visible either way, so
nothing vanishes silently — the rows are one control away.

ensemble::screen::roster and RosterCounts make both decisions once, so the
desktop, Android and the TUI cannot disagree about what "N total" counts:
total and other exclude revoked members regardless of whether the rows are
shown, since revealing one does not make it a member again. Desktop and
Android get a labelled toggle beside the Nodes heading, the TUI gets 'v'.
syn ensemble members still prints them — a one-shot dump has no control to
put them behind — but no longer counts them.
2026-08-28 02:23:16 -07:00
Greg Shuflin f544455c64 Retiring the GUI unlinks the store lock file on the way out
wipe_contents deliberately keeps .store.lock: unlinking a file another
process may hold open lets a third lock a fresh file of the same name.
That hazard belongs to a process that goes on using the store — a retired
node's last act before exit has no such window worth caring about, and
leaving one hidden file behind is not the empty directory the screen just
told the user about.

So the Quit button on the finished retirement screen removes it, via
Store::remove_lock_file. Best-effort and logged: the retirement is over
either way.
2026-08-28 02:23:05 -07:00
Greg Shuflin ede28e9491 gui: a retirement ends on a screen, not on the window vanishing
A peer that acknowledged promptly took the whole window with it a moment
later: Delivered -> wipe -> cx.quit(), with nothing on screen long enough to
read. Whether a peer confirmed is exactly the fact that becomes unknowable
once the node is gone, so the wipe now settles on a terminal Phase::Retired
that says which of the three ways it ended and offers Quit.

The signed record moves onto the struct and survives the wipe. It used to
live inside Phase::Waiting, so pressing 'Wipe now' — the one path where the
record is the only thing standing between the user and an ensemble that goes
on admitting a dead credential — took it off screen on the way out. It is
dropped only for a confirmed retirement, where there is nothing left to
repair by hand.

The three outcomes and the 'store directory is now empty' line go in
copy::revocation as RetirementDelivery::summary and retired_store_empty; the
CLI's wipe_after_retirement was saying the same things in its own words.
2026-08-28 01:35:32 -07:00
Greg Shuflin 83db04ee8a Bind Run now, both evictions, and the library build on both modalities
Build Debug APK / build (push) Successful in 5m21s
The last controls anywhere that showed the click rather than the job. Each
already started one with a nameable (kind, subject); each kept a boolean or a
Pending variant beside it that only its own screen could see.

Settings' "Run now" reads BackgroundSync/None; "Delete Locally" reads
Eviction/Document and "Reclaim…" Eviction/DocumentType — distinct subjects on
purpose, so a subtree eviction and a type-scoped reclaim neither dedup against
nor mask each other. Music's "Build library" reads LibraryBuild/DocumentType.
Both keep Evaluating, which is genuinely the screen's: gathering the evidence
for the confirmation dialog is not work another node can see.

FFI: JobSubjectRef gains None, since "no per-thing widget speaks for whole-node
work" stopped being true the moment Run now bound to it, and its DocumentType
variant now carries the enum rather than a snake_case name — a frontend that
had to produce that name would be a mirror of DocumentType::type_name, which
is what job_match_key exists to avoid. Both are covered by the drift guard in
free_fns' tests.

Also fixes rust/tui-app's EnsembleStatus test fixture, which had not been
updated for the three epoch fields and was failing the workspace build; the
values are the zeros every other field in that fixture already uses.
2026-08-28 00:03:49 -07:00
Greg Shuflin f90be1c5b6 gui: bind Run now, the two evictions, and the library build
The last four desktop controls that showed the click rather than the job.
Each already started one with a nameable (kind, subject); each kept a boolean
or a Pending variant beside it that only this screen could see.

- Settings' "Run now" reads the BackgroundSync/None job, so a pass the
  periodic tick or a peer's TriggerSync started shows as running, live, rather
  than being re-derived at two moments (construction and tab-switch) and stale
  in between. run_now_from_job_state is gone.
- "Delete Locally" reads Eviction/Document and "Reclaim…" Eviction/DocumentType
  — distinct subjects on purpose, so a subtree eviction and a type-scoped
  reclaim neither dedup against nor mask each other. Both keep Evaluating,
  which is genuinely this screen's: gathering the evidence for the dialog is
  not work another node can see.
- Music's "Build library" reads LibraryBuild/DocumentType.

Each screen's remaining state is the outcome sentence, hidden while the job
runs so a finished run's numbers can't be read as this one's.

ActiveJobs::watch packages hold-an-index-and-keep-it-fresh, mirroring
RemoteFetch::watch's pick-closure shape — a subscription rather than that
one's timer, since existence and progress both arrive as events and there is
no stall clock here that only a poll could read.
2026-08-28 00:03:49 -07:00
Greg Shuflin c696b431ce gui: the running-imports strip on every screen that can start one
Lifts the Documents screen's per-import lines into
`components::imports_in_progress`, a small view that owns its index and its
watcher, and puts it on Comics, PDF, Pictures, Music and Video too — five
screens whose "Import…" trigger previously showed nothing at all while a
folder walk ran for minutes.

A view rather than a render function because keeping the lines current is the
whole job: as a function each host screen would have to wire its own
ActiveJobs, job-stream watcher and notify. As a view it is a field and a
`.child()`. It watches only while its screen is on display, refreshes on
navigation to it, and renders `display: none` when nothing is importing, so a
host can place it unconditionally without a flex gap opening under the header.

Comics and PDF show it on the library view only — the reader is not showing
the list an import is about to land in.
2026-08-28 00:03:49 -07:00
Greg Shuflin adaea869f9 new test ensemble key
Build Debug APK / build (push) Successful in 4m0s
2026-08-27 18:35:05 -07:00
Greg Shuflin 82346dc8c7 gui: bind the last two desktop controls Android already binds
The per-artifact "Sync" on the document detail screen and the Documents
screen's imports were the two places Android read the live job row and the
desktop still read its own click.

The artifact row now takes its busy state from its own
ContentFetch/DocumentAsset job, so a pull the scheduler or another screen
started shows there; asset_sync keeps only the outcome sentence, which no job
carries. The Documents screen's `importing` boolean is gone: it disabled the
Import trigger for unrelated sources, missed imports started anywhere else,
and reported one anonymous "Importing…" for however many were running. In its
place ActiveJobs::of_kind lists each running import by name with its own
progress.

No link through to Jobs on the desktop lines, unlike Android's: the sidebar
entry is always on screen with a live "N running" badge.
2026-08-27 15:37:02 -07:00
Greg Shuflin ba31aa8d0a Fix stale provenance entry 2026-08-27 03:29:54 -07:00
Greg ShuflinandClaude Opus 5 ecb7e41c7a AGENTS.md: scope the build detail to the trees that need it
Build Debug APK / build (push) Successful in 3m42s
The root file loads into every session, and about a quarter of it was
build guidance that only applies inside one directory. The Android block
(the three `just doctor` toolchain cases) and the two-detekt-recipes
block now live in android/AGENTS.md; the gpui block (check-gui-nix,
build-gui-nix, and why rust-analyzer.json sets checkOnSave: false) lives
in rust/gui-app/AGENTS.md. Each is reached by a one-line pointer from the
root, and each opens by saying it is scoped and that the root file still
holds the project-wide rules. 21.5k chars down to 15.5k — roughly 1.5k
tokens back in every session, whatever the session is about.

Both new files get a CLAUDE.md symlink beside them, the same arrangement
the root already uses, so Claude Code picks them up when it works under
those directories while AGENTS.md stays the name on disk.

The split has one seam worth knowing about: the Kotlin lint entry warns
that a suddenly-slow `just lint-kotlin` means rust/lib/build.rs is
rebuilding, and that file sits outside android/, so the warning is not
loaded when you are the one editing build.rs. It stays in android/ anyway
because that is where someone hits the symptom.

Also dropped four lines the codebase already answers: the camelCase and
explicit-types conventions, the standard --tests invocation for a single
Gradle test, and an Architecture: line that still said JNI where the rest
of the file describes uniffi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7prPWuHReKauUzU34qyCh
2026-08-26 10:21:31 -07:00
Greg Shuflin 6db689fab0 epochs: syn ensemble renew-membership and rejoin
Build Debug APK / build (push) Successful in 5m9s
The CLI surface, and the shared state the other modalities will render.

renew-membership is the lost-or-stolen path, deliberately its own command
rather than a flag on revoke: the two are separate decisions and one is far
more expensive. Its confirmation's job is the blast radius, so it counts live
gossip neighbours rather than the roster total — reachable *right now* is
exactly what decides who gets renewed with this command and who is stranded
needing the keyphrase entered on them. It says that, and that losing the
keyphrase now strands those nodes permanently, before it asks.

rejoin is the way back. Needs the keyphrase and nothing else; no peer has to be
reachable, which is the property that makes it the fallback rather than the
optimisation.

EnsembleStatus grows current_epoch, this_node_epoch and
this_node_epoch_is_stale, so every modality can say 'your membership is out of
date' from one projection. The stale state is kept distinct from revoked
throughout: being revoked is something someone did on purpose, being left
behind is what happens to a device that was switched off at the wrong moment,
and only the second has a way back the user did not have to intend.

Both commands are refused over the loopback listener, same rule as retire and
revoke.

All the new copy went into copy::revocation as it was written rather than into
the screens — the rule from earlier today, applied on the way in for once.
2026-08-26 04:25:11 -07:00
Greg Shuflin 2bc9839a21 epochs: how a bump travels, and how a node left behind gets back
Three channels, mirroring how a revocation already moves.

Gossip carries EpochBump for promptness, verified against this node's own
ensemble key rather than against whoever relayed it — the property that makes
a revocation relayable, and needed here for the same reason: a node about to be
locked out must not be able to suppress the record by refusing to pass it on.

doc-sync's RevocationBatch becomes MembershipBatch and carries bumps beside the
deny-list. Same subject, same nodes want it at the same moment, and a bump is
109 bytes. Applied in ascending order so a node several epochs behind lands on
the newest rather than stopping at the first one it can apply.

The delivery protocol carries both too, and the ALPN becomes /syn/membership/1
because that is now what it is. Folding them together is the answer to the open
question the doc left: the two need the *same* exemption for the same reason —
a node refused everywhere, whether revoked or an epoch behind, has to be able
to reach something, and the only thing it may reach is a channel whose sole
effect is to hand over signed state it verifies itself. One exemption to reason
about beats two.

Minting: bump_epoch takes the keyphrase, moves the epoch, and re-certifies this
node in the same breath so the node holding the keyphrase does not lock itself
out by using it. recertify is the way back for a node that was offline through
a bump — with a verified record in hand it knows the epoch, and minting a cert
at it is purely local. Nothing is transported and no peer need be reachable,
which is what the doc predicted and what makes the fallback the part that
matters.

Not yet wired: nothing offers either of these to a user. Surfaces next.
2026-08-26 04:13:44 -07:00
Greg Shuflin 235b382074 epochs: the record, the store, and the gate it feeds
Phase 3b's mechanism, in the shape phase 2 established for revocations.

DelegationCert gains epoch: u32 inside its signed payload, so the number cannot
be edited after the fact and the fingerprint covers it. The payload version
goes to 2, which invalidates every cert issued before now — every node
re-registers, which is what an epoch bump does anyway, and the project takes
breaking changes to wire formats.

ensemble::epoch::EpochBump is a signed statement that the ensemble has moved,
and it is the *only* thing that advances an epoch. The tempting alternative —
treat a cert claiming a higher epoch as proof you are behind — has to be
refused, and that refusal shapes everything else: certs are minted locally by
whoever holds the keyphrase, so one bogus number fed to a legitimate user
re-registering would push the ensemble past every honestly-issued cert and lock
out every node at once.

stores::epoch::EpochStore holds the highest bump verified, and only ever moves
forwards. Replaying an older record is exactly what an attacker would try and
also what an honest peer does when it relays everything it holds, so a stale
bump is discarded rather than being an error. Whole records are kept, like the
deny-list keeps whole revocations and for the same reason: this node relays
bumps it did not mint, and a receiver verifies the signature itself.

admission checks the epoch before the deny-list — it is cheaper, and a stale
cert is what a *legitimate* node arrives with after being offline through a
bump, so naming that case is what lets the other end say 'your membership is
out of date' rather than refusing bare. A node whose own cert fell behind stops
serving and dialling, so it says that once instead of collecting refusals from
every peer.

Not yet wired: nothing propagates a bump and nothing mints one. Next.
2026-08-26 04:00:05 -07:00
Greg Shuflin 7735fd532b docs: say when an epoch bump happens, and what missing one feels like
3b described the mechanism and the trade-off but not the two things anyone
building it would have to decide first.

**When it moves.** Never automatically, and never as a side effect of an
ordinary revocation — phase 2 covers the common case, and bumping there charges
every other node a re-certification for a threat that is not present. The epoch
moves when the user says a device is out of their control, which is a claim
only they can make: a node that stops answering looks identical either way. The
natural place to ask is the revoke dialog, for a reason worth noticing —
revoking a peer already requires the keyphrase, which is exactly and only what
a bump needs. The dialog must say how many nodes will need the keyphrase
entered on them, and that the keyphrase is load-bearing again, before it
commits.

**What missing one feels like.** The case to design for is the phone in a
drawer, not the attacker. Refused everywhere, told why, handed the bump record
over a path that admits a stale caller — phase 2's exemption in the same shape
and safe for the same reason — and back in by entering the keyphrase locally.

Working that through changed two things.

A cert cannot establish the epoch. The mirror of "below the current epoch is
invalid" — that above it proves you are stale — has to be refused: certs are
minted locally by whoever holds the keyphrase, so a user tricked into
re-registering against a bogus epoch would push the ensemble past every
honestly-issued cert and lock out every node at once. The epoch advances only
on a signed EpochBump record, travelling exactly as a revocation does.

And it mostly closes the re-certification transport question. No cert needs
transporting: a node with a verified bump knows the epoch, and minting at it is
local. Pushing certs from the revoking node is an optimisation for reachable
nodes, and the fallback is the part that covers the offline ones.

Also corrected: the closing paragraph claimed the two mechanisms layer on the
same case. They do not. A bump evicts by itself; what the deny-list goes on
covering is revocations between bumps. And neither covers a device that took
the keyphrase too.

Repointed the audit's drifted line references by name, and marked its two
amplifier items with what §3a since did to them — one fixed outright, one no
longer decided with.
2026-08-26 03:12:18 -07:00
Greg Shuflin e6d7960177 TODO: point linkage scope at the branch where an attempt sits
Build Debug APK / build (push) Successful in 3m40s
The work is on linkage-security rather than here: parked deliberately, because
the capability model it needed to make scopes work may be more machinery than
the current browser extension warrants, and may not fit the linkages that
exist only as sketches.
2026-08-26 02:58:40 -07:00
Greg Shuflin a7e7c601be podcast: a search term reaches the API whole
Build Debug APK / build (push) Successful in 5m40s
search_podcasts built its URL with format!("…byterm?q={query}"), putting the
user's raw text where a URL has its own syntax. A search for "AT&T" arrived as
q=AT with a stray T parameter after it; anything past a # arrived truncated,
because a fragment is never sent at all. Spaces survived only because
Url::parse happens to percent-encode them.

RequestBuilder::query does the encoding, which meant turning on reqwest's
"query" feature — free, since its encoder is already in the tree via other
crates.

The sibling URL a few lines down interpolates a feed id and a max count, both
i64, and is left alone: a number has no characters to escape.

Five cases pinned against the built request rather than a sent one — this is
about how the URL is assembled, and the test should not touch the network.
2026-08-26 02:46:36 -07:00
Greg Shuflin 4ecf8aee1e copy: the podcast placeholder promised something the search cannot do
Android's search field said "Enter podcast name or URL". Both modalities call
the same agents::podcast::search_podcasts, which hits Podcast Index's
search/byterm — a term search. A pasted feed URL goes to it as a search term
and finds nothing. Subscribing by URL is not something this screen does
anywhere, so the shared placeholder is the desktop's wording.

Two things found while checking that, recorded in TODO.md rather than fixed
here:

The sync-configuration message difference is not an error. The desktop's
"see the status above" is accurate — render_remote_status shows
"Editing {node} — unreachable: {reason}" in the section above it. What the
difference exposes is that Android has no such status line at all: it never
names the node it is editing and never says the connection succeeded, only
that it failed. On the one screen whose edits leave the device, silence on
success is the wrong default. copy::settings::remote_config_unreachable takes
the reason as an Option so both modalities stay correct; closing that gap is
what would let Android pass None.

And search_podcasts interpolates the query straight into the URL, so a search
containing & or # is truncated or misparsed before it leaves the process —
"AT&T" searches for "AT". Same class as the lint-sql-params rule: a value
put into a string that has its own syntax.
2026-08-26 02:46:36 -07:00
Greg Shuflin 97d5abcff5 copy: the general audit — notes, documents, humans, linkages, settings
The rest of the sweep, found by comparing every user-visible string literal
across the four modalities and looking for near-matches. Grouped by subject,
since one subject reaches several screens on several modalities.

incoming_change — the alert-don't-apply banner. CLAUDE.md's first design
principle names three of these as worked examples and every one had written its
own sentence, including two spellings of the apostrophe. That is the wrong
thing to leave to a screen: the banner's job is to say what the user gives up
either way, which is a claim about the merge rules and not a matter of local
phrasing. The two subjects are named rather than passed as free text, and the
button words travel with them.

note_body_absent — the headline was identical on three modalities; the last
sentence of the detail was not, because the TUI cannot offer to start the note
over. That is a capability difference, so it is a parameter now rather than two
paragraphs that happen to end differently.

documents — the purge warning, the delete-locally title, the placement scope
line, the directory-import prompt. These say how far a destructive action
reaches, and three screens each spelling that out is three chances to make
three different promises. The descendant-count clause was written out four
times, identically, which is how it stayed identical: by luck.

humans — validation messages, which earn sharing for a reason most copy
doesn't: it is the app telling someone they did something wrong, and two
devices disagreeing about what is wrong is worse than either wording alone.

linkages, settings, pomodoro — the smaller ones. 'Nothing is linked to this
node yet.' had lost its 'yet' on the CLI; the node-key explainer was two
sentences on the desktop and a truncation of them on Android.

One divergence is kept and made deliberate: reading another node's sync
configuration fails with the reason on Android and without it on the desktop,
which carries it in a status line above. That is a real difference rather than
a disagreement, so it is an Option parameter and the stem is shared.

What the audit still reports is a tail of per-screen error banners — 'Failed to
load notebooks' against 'Failed to load pictures' — which are different
messages that merely rhyme.
2026-08-26 02:46:36 -07:00
Greg Shuflin 68a0f6c6ac copy: busy messages, empty states, and the asset-sync outcome
Three subjects where every modality had written its own version.

busy — six screens across four modalities had their own sentence for 'that is
already running', in two tenses and three capitalisations. The tense was
carrying meaning, so it is kept and made explicit rather than flattened:
busy::running is a refusal (the click did nothing) and busy::was_running is a
deferral (the click landed on work already under way, and its result is the one
you get). The FFI's two Busy errors go through it too, so Android's exception
text matches what the desktop shows.

empty — the jobs list and the roster. 'Nothing here' is exactly the message
someone compares across two devices when working out whether something synced,
so the two devices now say the same words. The TUI loses its parenthesised
abbreviations for these two, which read as a different answer rather than the
same one.

asset_sync — the four-way outcome of pulling a document's content. All three
modalities had independently reasoned their way to the same distinction —
'nothing was missing' is only a claim about the ensemble if a peer actually
answered the catalogue phase — and each had written that reasoning into a
comment beside its own phrasing. The derivation moves to copy.rs, which is
where it belonged: the interesting part is not the words but which of four
outcomes applies, and that is one judgement about the same snapshot fields
every time.

Android reaches the last two through empty_state_copy() and
asset_sync_summary() in ffi/free_fns.rs.
2026-08-26 02:46:36 -07:00
Greg Shuflin 59249196aa revocation 3a: authorship is not possession, and origin_node never was an amplifier
Phase 3a asked for two fixes. One was already done and the other was a
mis-diagnosis, so this closes it smaller than it was written.

The timestamp half is closed already, by p2p::wire_time: every peer-supplied
stamp decodes at one point, and one more than a day ahead of this node's clock
is rejected along with the field carrying it — rejected rather than clamped,
because clamping to now makes a bogus write the newest write, which is the
outcome the bound exists to prevent. That is the half that mattered, since
timestamps are what every merge rule compares.

origin_node is not a merge input at all. Properties and artifacts compare
modified_at, set elements compare their own add/remove stamps, tombstones
compare deleted_at. A lie about origin cannot win a comparison or suppress a
write. And the prescribed fix — derive it from the authenticated sender — would
have been a regression: doc-sync is transitive by design, so a record routinely
arrives from a peer that did not write it, and overwriting the claim with the
sender attributes every relayed edit to its relayer.

What was real is what consumed it. node_status::artifact_presence_for_node
inferred that an artifact's author still held its content, and put a Present on
the presence grid — which is what a user reads before deciding what is safe to
delete locally. That inference was unverifiable, and wrong about honest nodes
besides: a node that wrote an artifact and later reclaimed its content still
read as holding it.

So the inference goes, not the field. With no evidence an artifact now reads
exactly as the blob beside it always has, from the one row that came from the
node itself: it references the document, so Ref — 'it should have this' —
otherwise Unknown. Possession is asserted only from NodeContentStore, which is
written against the authenticated sender.

origin_node stays as provenance, documented once at Document::origin_node and
pointed at from the other seven fields and the eight wire records: safe to
show, unsafe to decide with.
2026-08-26 02:46:36 -07:00
Greg Shuflin e085ba683d copy: revocation says the same thing on every modality
I shipped four paraphrases of the same sentences. The desktop said a retired
node "prints" a record, Android and the TUI said it "shows" one; the revoke
dialog explained itself in three different ways; the self-revoked banner had
four wordings across GUI, Android, TUI and CLI.

copy::revocation is now the single definition, and every modality imports it —
the three Rust ones directly, Android through revocation_copy() in
ffi/free_fns.rs alongside placeholder_copy and action_labels. The sentences
that carry a node's name are their own exported functions, since a record of
constants cannot interpolate.

These are load-bearing rather than decorative: they are what tells someone that
revoking a node does *not* wipe it, that a retirement nobody heard can still be
repaired, and what "wipe now" costs. Four answers to that is four answers.

CLAUDE.md gains the rule, including the part I got wrong — paraphrase across
modalities is the same defect as duplication and harder to spot, and the
derivation belongs in copy.rs too, not just the strings.
2026-08-26 02:46:36 -07:00
Greg Shuflin 05db9b7530 tui: revoke a member, and import a record carried here by hand
x opens a keyphrase prompt for the selected member; i opens one for a
revocation record. Both go to the daemon on a worker thread like ping and sync
do, and both are followed by the quiet reload, so the row becomes revoked
without wiping the answer the user just asked for.

A prompt takes every key, q included. A keyphrase has letters in it, and the
one that used to quit the app is one of them.

The keyphrase names its node when the prompt opens rather than re-reading the
selection when it is sent. A peer announcing itself mid-keyphrase reorders the
roster under the cursor, and a revocation landing on whichever row the
selection had drifted to would be the worst possible version of a sync
disrupting what the user is doing.

Shown in the clear, matching how syn init and syn ensemble register read it: a
20-word bytewords phrase typed blind is a phrase typed wrong, and what this
defends against is a sibling node minting a revocation on its own, not someone
behind the terminal. Esc drops it rather than stashing it.

The minted record is kept per member and shown in full in the detail pane.
Nothing serves a record back once it is on the deny-list — the store holds its
bytes but no command returns them — so the moment it is minted is the only
chance to keep it. An imported one is not kept: it came from the user, and the
node it names may not be on this roster at all.

Ping and sync now refuse a revoked member rather than spending a dial to be
turned away at admission. Retiring *this* node stays out: it ends by deleting
the store this screen is reading, so it belongs to syn ensemble retire, which
can stop the daemon first. The refusal says so.
2026-08-26 02:46:36 -07:00
Greg Shuflin 35782c5290 podcasts: delete a dead downloader, and say what Play actually does
Build Debug APK / build (push) Successful in 3m49s
episode.rs claimed "no audio is streamed yet" while its Play has been handing
the enclosure URL to the transport bar as an AudioSource::Stream — the doc
predates the streaming work and read as if the screen were still a stub.

agents::podcast::fetch_episode_audio had no callers in any crate. It is also
the wrong shape for the offline-copy feature that would have wanted it: a
whole enclosure in a Vec<u8> is the memory profile TODO.md already flags for
BlobStore::store, so leaving it lying around invites building on it.
2026-08-26 02:01:19 -07:00
Greg Shuflin c7221f49a8 TODO: drop what's built, and fix what had gone stale
- The unified now-playing view across agents is ADR 0045, implemented on both
  modalities; its one unrelated nested bullet (configurable sidebar order)
  moves up to Agents rather than leaving with it.
- Pop-out windows exist (gui-app/src/popout.rs). The Android half of that
  bullet was a musing about a concept a phone doesn't have.
- Android's Notes screen has the incoming-body conflict UI the Android visual
  style item was still waiting on — IncomingBody.alertFor, which CLAUDE.md
  now cites as the Android worked example of alert-don't-apply.
- The Collection-card sync rollup has its mechanism (content_ledger::
  subtree_content, whose doc comment names this TODO); what is left is the
  media library screens adopting it, which the content-placement item already
  says more precisely. Deduped rather than deleted.
- Hierarchical documents display: both modalities expand a row's children in
  place.
- Podcast episode position tracking is a synced artifact; the half that is
  left is an offline copy, so the item says that and names the memory-hungry
  dead function nobody should build on. The feed-refresh item gains the trap
  that the desktop's Refresh button doesn't refetch the feed.
- Android's test inventory was three files ago; the Reliability item pointed
  at a heading that no longer exists under that name.
2026-08-26 01:54:49 -07:00
Greg Shuflin 8cdf08d304 docs: retire the change-signals TODO into the ADRs that own it
Build Debug APK / build (push) Successful in 5m34s
The work is done: every screen on every modality follows the store, and the
polls this started as are gone. What was left in TODO.md was either a fact
about the design or a deliberate exception, and both belong where someone
reading the design will meet them rather than in a list of things to do.

ADR 0041 gains "Where this ended up": the in-memory P2P state no store
channel can announce, the players that must never follow one, the record
screen in edit mode, the Sync tab's multi-second walk, and the timers that
were never polls. Its amendment now names SyncStagingStore as the one store
still to be held to the rule.

ADR 0031 gains the design that is actually owed — what a *form* needs that a
single-value guard doesn't cover: a per-field "keep mine" that doesn't revert
four fields nobody disagreed about, and an alert that can say which fields
moved. The two stopgap call sites point there now instead of at TODO.md.
2026-08-26 01:25:40 -07:00
Greg Shuflin 39c6f45b1c humans: the detail screen follows the store, and a local edit wins
Build Debug APK / build (push) Successful in 5m16s
A person can be edited on another node, and an interaction with them written
by anyone else who was there; neither reached an open detail screen on either
modality. The timeline now follows the store whatever the mode — an
interaction is its own document, shared with its attendees, and lives outside
this form — and the profile follows it while it is being read.

In edit mode the arrival is dropped and the staged edit stands. That is the
expedient answer and not the right one: the rule asks for the arrival to be
announced, for each choice's cost to be named, and for the save that would
overwrite the other node's version to wait. Both call sites say so, and TODO
records what the real design has to do — including that a form is not a text
buffer, so "keep mine" cannot mean overwriting four fields the user never
touched.
2026-08-26 01:12:48 -07:00
Greg Shuflin 54f4dbd373 stores: SettingsStore announces, and so should every store
A remote administrator can set this node's sync policy over p2p::node_admin,
and the tabs showing that policy had no way to hear it — not on a change, and
on the desktop not even on a tab switch, since the policies were read once
when the screen was built.

SettingsStore now carries a ChangeSignal on a new ChangeChannel::Settings,
marked from every write path rather than the ones judged interesting: what a
screen shows is not a store's business to predict, and predicting it wrong is
what this bug was. The desktop Settings screen and Android's Sync settings
follow it for their switches and menus. The desktop's text fields are left
alone on purpose — they hold what the user has typed and not saved.

ADR 0041 said to add a channel only when a screen asks for one. That rule is
amended there: announcing into an empty room costs a counter nobody reads,
while not announcing costs a screen quietly showing something that has stopped
being true.
2026-08-26 01:09:06 -07:00
Greg Shuflin 2a3930f10c docs: live player state is local to the node playing it
The progress artifact is what crosses nodes; playing/paused, the current
second, and the volume do not. Worth stating outright because the feature is
described as "pause on the phone, resume on the desktop", which is one letter
away from a phone that starts the desktop playing — and because a screen that
follows the store is a short step from a screen that seeks under whoever is
listening.

Written where the players are designed rather than in an ADR: it is a
standing rule about what these widgets may do, not a decision being taken
once.
2026-08-26 00:57:39 -07:00