Commit Graph
18419 Commits
Author SHA1 Message Date
Vitor PamplonaandGitHub 4a70af8bf4 Merge pull request #3896 from vitorpamplona/claude/neo4j-quartz-graph-schema-z04j8o
Declare pointer hints on Report, ChatMessage, Classifieds and ChannelCreate events
2026-08-11 16:35:14 -04:00
Claude db6b81eb04 feat(nip56): declare pointer hints on report, chat, classifieds and channel events
Quartz's PubKeyHintProvider / EventHintProvider / AddressHintProvider are the
kind-agnostic answer to "what does this event point at" — they let a caller
walk an event's references without knowing which tag name a given NIP chose
(`p` vs `P` vs `member` vs `moderator`). Measured against the 248k-event
corpus in commonTest, 84 of 403 event classes implement one, covering ~95% of
all pointer edges. This closes the four largest remaining gaps.

ReportEvent (1984) carried the most undeclared edges of any kind — 14,244 —
and they are the negative trust signal that a web-of-trust projection most
needs. Its tag classes also predate the modern layout, so they are brought up
to the structure used by e.g. NIP-88 polls:

- ReportedAuthorTag now implements PubKeyReferenceTag, ReportedEventTag
  implements GenericETag, and all three tags carry a relay hint.
- Adds parseKey / parseId / parseAddressId / parseAsHint companions.

Fixes a latent bug while doing so. NIP-56 predates the convention that slot 2
of a pointer tag is a relay hint — it put the report type there — so both
layouts are in the wild. The old reader passed slot 2 straight to
ReportType.parseOrNull, which despite its name never returns null and maps
anything unrecognized to OTHER. A modern `["p", <pubkey>, "wss://relay/"]`
tag therefore became an OTHER report and masked the event-level default. The
new shared ReportTagLayout disambiguates by shape (a slot that parses as a
relay URL is a hint, never a type) and falls back to the event-level default
when a tag names no type of its own.

Emitted tags are unchanged: assemble() still writes the legacy
`[name, id, type]` form unless a relay hint is supplied, since many clients
still read the report type out of slot 2.

Also renames ReportedAuthorTag.pubkey to pubKey to satisfy
PubKeyReferenceTag, updating the four call sites.

Coverage over the corpus goes from ~95.3% to ~98.5% of pointer edges. What
remains is GiftWrapEvent's recipient p-tag (deliberate — it is the store
owner key and handled separately) and PrivateDmEvent's e-tags.
2026-08-11 20:15:58 +00:00
Vitor PamplonaandGitHub 106853c2f2 Merge pull request #3895 from vitorpamplona/claude/embed-browser-keyboard-focus-kcgqqc
fix(embed): bring the keyboard back on a tap in an already-focused field
2026-08-11 14:17:53 -04:00
Vitor Pamplona 237ece79d5 Merge remote-tracking branch 'upstream/main' into claude/embed-browser-keyboard-focus-kcgqqc 2026-08-11 13:25:52 -04:00
Vitor PamplonaandClaude Opus 5 be95701618 fix(embed): stop a readonly field's text reaching the page after blur
On-device QA of 1de9c242 (SM-T220, Android 14). Copying from a readonly field
and then tapping an editable one wrote the readonly text into it:

    FOCUSOUT readonly
    FOCUSIN  empty
    INPUT    empty val="readonly" len=8 sel=8..8    <- page, not the user

TYPE_NULL closed the inbound half (nothing can be typed into a readonly
field's mirror) but not the outbound half. The mirror still flushes on
selection changes — a long-press select-all emits one, Chrome's
collapse-to-endpoint another — and `ime.set` carries the buffer's text.
Delivery is asynchronous, so the flush lands after the page has moved focus
and the shim applies it to whatever field is focused THEN. Host log at the
moment of the leak:

    FLUSH sending={"type":"ime.set","text":"readonly","selStart":0,"selEnd":8,...}
    FLUSH sending={"type":"ime.set","text":"readonly","selStart":8,"selEnd":8,...}

Two changes, because each alone leaves a hole:

- `stateJson()` omits the `text` key entirely while the mirrored field is
  readonly. The shim already treats a missing text as selection-only
  (`var next = (msg.text != null) ? String(msg.text) : prev`), so the
  host-drawn handles and Copy keep working off a synced selection while
  nothing can be written back.

- `onPageBlur()` no longer clears `fieldReadOnly`, and suppresses its own
  echo. The flag describes the buffer the mirror still holds, which outlives
  the blur; clearing it up front meant the second flush above — scheduled by
  `clearFocus()` moving the caret, hence firing after the reset — was
  computed as if the field were editable and shipped the text. The blur now
  wraps `clearFocus()` in `applyingRemote` and drops the queue, so nothing
  belonging to a field the page has already left can be delivered to the next
  one. That part is not readonly-specific: any field's stale buffer could
  land on its successor, readonly just made it visible.

Verified on device: the exact sequence (long-press readonly → Copy → tap the
empty field) now produces no INPUT and leaves the field empty; Copy still puts
the text on the system clipboard (IME clipboard chip shows it); selection,
handles and the Copy/Select-all bar are unchanged; an editable field still
types normally straight after the readonly excursion.

NOT verified: the hardware-keyboard half of 1de9c242's rationale (Ctrl+V via
onTextContextMenuItem). No Bluetooth/USB keyboard is attached to this device,
so that path is untested — see the PR comment.

tools/ime-test/shim-events.mjs still exits 0 (shim.js untouched).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:21:57 -04:00
Claude 1de9c242e9 fix(embed): don't let a readonly field's mirror be typed into
Audit of the branch, two findings.

`readonly` stops the *user* editing a field, not scripts: the shim writes
through the native value setter, so any text that reaches the host mirror is
applied to the page and fires an `input` event no native browser would. Cut and
Paste were refused at their call sites, but that misses a hardware keyboard
(tablets, DeX, Chromebooks) — whose Ctrl+V goes straight to
`onTextContextMenuItem`, bypassing the wrapper — and autofill. Configure the
mirror as TYPE_NULL for a readonly field instead: `onCheckIsTextEditor()` is
then false, so there is no InputConnection to type through at all, while
selection and Copy — the half native does offer on a readonly field — keep
working.

The selection toolbar's item list was rebuilt on every recomposition of the tab
layer, which recomposes on every IME inset change, bounds report and console
line, for a toolbar only shown during a selection. Remembered on the readonly
flag, so it allocates once and keeps a stable identity the overlay can skip on.

Adds tools/ime-test/shim-events.mjs, a regression test that drives the shipped
shim in headless Chromium and asserts the page→host envelopes. It fails on main
(7 cases, including "no ime.wantkb — the keyboard could never come back") and
passes here. A JVM unit test cannot cover this: the host parser runs on Android's
org.json, which the unit tests stub out, so it would pass without parsing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-11 15:21:10 +00:00
Vitor PamplonaandGitHub 278ddd2790 Merge pull request #3894 from vitorpamplona/fix/bottom-nav-webapp-tab-switch
fix(nav): switching between two pinned web-app tabs crashed onto the wrong one
2026-08-11 10:20:32 -04:00
Vitor PamplonaandClaude Opus 5 112504205c fix(nav): switching between two pinned web-app tabs crashed onto the wrong one
With two web apps pinned to the bottom bar, tapping the second one crashed:

    IllegalArgumentException: No destination with route
      Route.WebApp/https%3A%2F%2Fbrainstorm.world%2F%3Fq%3DVitor
      is on the NavController's back stack.
      The current destination is route=Route.WebApp/{url}

`saveState`/`restoreState` are keyed by DESTINATION, and every pinned tab of one
kind shares a single destination — every web app is `Route.WebApp/{url}`. So the
`restoreState = true` on the navigate restored the *sibling* tab's saved entry
instead of creating the one that was asked for, and `getBackStackEntry(route)`
then threw on a route that was never pushed.

The crash was the visible half. Instrumented on device, the navigate itself
already landed wrong:

    asked=WebApp(url=https://brainstorm.world/?q=Vitor)
    landed url=http://localhost:8765/keyboard.html

— the second web-app tab selected and rendered the first one's site. Anything
that only stopped the throw would have left that in place.

So: when the entry we asked for isn't on the stack after the navigate, take the
tab fresh — no `restoreState` (that is what handed back the sibling) and no
`launchSingleTop` (the top *is* the sibling, and reusing it is the bug). That
tab's saved scroll/ViewModel state is unrecoverable in the colliding case, but
the user lands on the tab they tapped. Tabs whose destination nothing else
shares still save and restore exactly as before, which is what that behavior is
there for.

Reproduced and verified on a tablet (SM-T220, Android 14): two web apps pinned,
switching either direction now loads the right site with its query string
applied and no crash; a fresh launch straight into either tab was already fine
and still is; and all six bottom-nav tabs cycle twice with no fatals. Same
collision applies to any two pinned tabs sharing a destination (chats, Concord
channels), which this covers too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:05:05 -04:00
Vitor PamplonaandClaude Opus 5 b7ef983bd8 fix(embed): no caret handle or Cut/Paste on a readonly field
Suppressing the keyboard for a readonly field was only half of it. The
selection UI still treated it as fully editable: a tap-and-hold raised the
insertion caret handle, and its toolbar offered Cut and Paste — on a field the
page will not let you modify. Cut appeared to work in the mirror while the page
kept its text, so the two silently drifted apart.

Native, on the same page in the full-screen WebView, gives a readonly field
selection handles and a Copy / Select-all bar, and nothing else: no caret
handle (there is no caret to place) and no editing actions.

Carry the flag into SelectionUiState so the overlay can reason about it:
`fieldReadOnly` gates the insertion handle (and with it the Paste/Select-all
popup that hangs off it), and the field toolbar drops Cut and Paste. Selection,
its handles, Copy and Select-all are untouched — that half is what native
offers and it works today.

`cutSelection`/`pasteClipboard` refuse on a readonly field too. The toolbar no
longer offers them, so this is a backstop, placed next to the ops so a future
call site can't reintroduce the drift.

Device-verified: readonly long-press selects with handles and shows exactly
"Copy | Select all"; a plain tap gives no keyboard and no caret droplet; an
editable field still shows all four actions and keeps its caret handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:51:42 -04:00
Vitor PamplonaandClaude Opus 5 37f9c5ad85 fix(embed): restore the keyboard on tab return, and none for readonly
Device testing on a tablet (SM-T220, Android 14) walked every text-field focus
path in the embedded tab. Two of them were wrong.

**The tab-return restore never fired.** `noteKeyboardOnLeave` sampled
`WindowInsets.imeAnimationTarget > 0 && isMirroringPageField()` inside
`onDispose`, on the assumption that the dispose runs before anything hides the
IME. It does not: by the time it runs, the nav transition has already snapped
the animation target to 0 *and* taken focus off the view, so both halves read
false and every tab was recorded as "left without a keyboard". Instrumented on
device, the leave was `keyboardUp=false mirroring=false imeBottomPx=0` for all
three nav-rail routes, so `pendingRestore` was false on every return and a tab
left mid-typing always came back with the keyboard down.

Ask the mirror what it *intends* instead of sampling the window at teardown:
`RemoteImeView.keyboardWanted` is set when we raise the keyboard and cleared
when the field blurs or the user puts the keyboard away, so it still reads true
while the view is being torn down.

Telling "user dismissed it" apart from "the tab went away" is what that clearing
needs, and there is no key hook for it — Android 13+ routes the IME's back
dismissal through OnBackInvokedCallback, so `onKeyPreIme` is never called (tried
first; it silently never fired and the tab over-restored). The two cases are
distinguishable by what else is true when the insets collapse, measured on
device:

    dismiss:     imeBottomPx=0  hasFocus=true   mirrors=true
    tab switch:  imeBottomPx=0  hasFocus=false  mirrors=false

so a collapse while we still mirror the field is the dismissal, and a switch
never looks like one — the focus loss lands in the same frame as the insets.

**A readonly field raised a keyboard that cannot type.** `isEditable` in the
shim never looked at `readOnly`, so the host took the field and showed a
keyboard whose keystrokes the page discards. Native, checked side by side in the
full-screen WebView on the same page, focuses a readonly field without a
keyboard. The field stays "editable" for selection (native offers handles and
Copy there); only the raise is suppressed, via one guard in `raiseKeyboard` so
the fresh-focus, tap-doorbell and tab-restore paths are all covered.

Verified on device, 27/27 checks: fresh focus raises for text/textarea/
contenteditable/email/number/password/search/tel and not for disabled or
readonly; BACK-dismiss then re-tap restores; re-tapping a field whose keyboard
is up keeps it; leaving mid-typing restores on return (~1s, 5/5 runs) while a
dismissed tab stays down; typing after either restore lands in the right field
at the right caret; page-background tap blurs; address-bar keyboard never arms
an embed restore; and the full-screen round trip leaves the embed IME working.

`tools/ime-test/keyboard.html` is the page those checks drive: every field type
plus a live focus readout and an event log that marks taps on an already-focused
field, which is the case with no DOM event of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 00:40:26 -04:00
Vitor PamplonaandGitHub c43339e92b Merge pull request #3891 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-10 18:38:39 -04:00
Vitor PamplonaandGitHub cdb8025306 Merge pull request #3890 from nrobi144/feat/shared-metadata-loading-v2
feat(desktop): per-visible metadata + reaction loading via shared finders in commons
2026-08-10 18:38:31 -04:00
Vitor PamplonaandClaude Opus 5 59b36592f3 fix(relays): seed relay flows from precached tags, not just the defaults
Follow-up to the previous commit, which seeded SearchRelayListState.flow and
IndexerRelayListState.flow with the curated default sets so `.value` is never
empty before the first async emission.

Seeding with the defaults fixed the "queries nothing" hole but was blunt: an
account with its own relays would briefly advertise the defaults instead. Seed
from the precached resolution instead:

    searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }
        ?.ifEmpty { null } ?: DefaultSearchRelayList

`cachedRelays` is the non-suspending sibling of `relays` — public tags plus any
already-decrypted private tags, and it never asks the signer, so it cannot block
or trigger a NIP-46 round trip from a property initializer. At login the note's
event is usually still null, so this resolves through settings.backupXxxRelayList
(restored from LocalPreferences) and an account with public relays gets its own
relays immediately. Only accounts whose relays are exclusively private still see
the defaults for the window, and they get a working set rather than nothing.

Same shape as the suspend normalizer, so all three arms still hold: no list →
defaults, list with zero relays → defaults, list with relays → those relays.

PrecachedRelayListSeedTest pins the property the refinement depends on — that
public relays are readable with no signer involvement — plus the empty-list arm
that hands over to the defaults, and a foreign-author read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 18:29:02 -04:00
vitorpamplonaandgithub-actions[bot] 422ae2d2d6 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-10 22:24:27 +00:00
Vitor PamplonaandGitHub b6eb610749 Merge pull request #3892 from vitorpamplona/feat/negentropy-want-id-predicate
negentropy: let a caller decline an id before the download REQ
2026-08-10 18:21:30 -04:00
Vitor PamplonaandClaude Opus 5 9ede348915 fix(relays): seed search/indexer relay flows with the defaults, not empty
Both SearchRelayListState.flow and IndexerRelayListState.flow are supposed to
never be empty: normalizeXxxWithBackup() substitutes the curated default set
both when the account has no kind:10007 / kind:10086 and when the one it has
decodes to zero relays (`?.ifEmpty { null } ?: Default…`). Every REQ assembler
that reads `.flow` relies on that.

The `stateIn` seed was the one value contradicting it. `flowOn(Dispatchers.IO)`
means the first real emission can never be synchronous with `stateIn`, so
`.value` was `emptySet()` until the collector ran — and for a NIP-46 signer
whose list carries private entries that wait is a remote decrypt round trip,
so the window is unbounded rather than sub-millisecond.

A reader landing in that window queries nothing at all. AmethystAppFunctions
(the system-assistant entry point, invoked cold) is the realistic case — it
does `if (relays.isEmpty()) return SearchProfilesResult.empty()` directly under
a comment asserting the flow "already resolves to a concrete relay set … or the
curated default set", which is exactly the invariant that did not hold yet.

Seeding with the defaults makes "never empty" true for the whole lifetime of
the flow. Only `.flow` changes; `.flowNoDefaults` still seeds empty, so the
editing and diffing paths (SearchRelayListViewModel, IndexerRelayListViewModel,
AddInboxRelayForSearchCard, TrustedRelayListsState, Account.saveRelayList's
diff) still see what the user actually configured.

Consequence, and the intended trade: a reader in that window now queries the
default relays instead of silently querying none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:57:12 -04:00
Vitor Pamplona 7041980996 negentropy: let a caller decline an id before the download REQ
negentropySync names every id the relay has that the caller lacks, then
fetches all of them. There is no point between those two steps where a caller
can say "not that one" — onEvent is the first hook, and by then the body has
already crossed the wire.

That is a real cost for a mirror. A store keeping only the newest version of a
replaceable event refuses every relay's older copy, and negentropy re-offers
those copies on every sync because they are genuinely absent from the local
id set. Measured on a downstream mirror against relay.damus.io, nos.lol and
relay.primal.net: three passes over kinds 0/3/10002 produced 5, 18 and 29
REPLACED rejections, the same events re-downloaded each time.

Adds an optional `wantId: ((HexKey) -> Boolean)?` to negentropySync,
negentropySyncOrFetch and negentropySyncFanOut, consulted for each id before
the REQ, plus a `skipped` count on the three result types. Default null keeps
every existing call byte-identical.

Three decisions worth stating:

- The gate runs on a whole reconcile round's ids, BEFORE they are chunked into
  fetch batches. Gating after the chunking keeps the batch count and shrinks
  every batch instead — at the density this exists for, a fetchBatch of 500
  becomes a hundred REQs of five ids each, turning a bandwidth saving into a
  latency regression.
- `skipped` is reported apart from both `downloaded` and `needCount`.
  needCount stays the honest protocol diff whether or not the caller fetched
  it, and without a separate number an operator cannot tell a predicate that
  does nothing from one that eats everything — both are silent.
- keep() returns an empty list, never null. A nullable return invites
  `gate?.keep(ids) ?: ids`, which reads as "no gate, keep everything" and
  means "everything was declined, so send everything". That elvis turned the
  fully-declining case into a full download during development; the
  non-null contract removes the trap rather than documenting it.

The gate does not cover a window handed to onUnreconcilableWindow: that is
drained over REQ, which names no ids before streaming bodies. Documented on
both the base function and the combinator.
2026-08-10 21:08:05 +00:00
Vitor PamplonaandClaude Opus 5 d3921cd7f5 fix(ci): unbreak iOS compile and the arm64 desktop smoke test
Two red checks on this branch, from two unrelated causes.

1. `test-quartz-ios` — AddressableAuthorRelayLoaderSubAssembler moved into
   commonMain still calling `synchronized(lock)`. That resolves from
   kotlin-stdlib-jvm with no import, so it compiles on Android/JVM and only
   fails at `:commons:compileKotlinIosSimulatorArm64`. Swapped to
   `KmpLock.withLock {}` (reentrant on every platform, and `withLock` is
   inline so `commit()`'s early `return` still works).

   The `verifyKmpPurity` gate missed it because it only forbade the
   `kotlin.jvm.Synchronized` *annotation*, not the bare call. Added
   `synchronized(` to the forbidden list in both :commons and :quartz so the
   next one fails in seconds instead of at the iOS compile step.

2. `release-deb-launch (ubuntu-24.04-arm)` — pre-existing infra break, not
   from this branch (same failure on other PRs since ~Aug 5).
   libskiko-linux-arm64.so needs libEGL.so.1 and the runner has no libegl1,
   so the app died at startup. create-release.yml already fixes this via
   scripts/add-deb-libegl-dep.sh; the smoke test never adopted it. Added that
   step, and switched the install from `dpkg -i` (which does not resolve
   dependencies) to `apt-get install ./x.deb` so the declared libegl1 is
   actually pulled in — this now exercises the same artifact release ships.

Verified: :commons:compileKotlinIosSimulatorArm64, both verifyKmpPurity gates
(and confirmed the new pattern fails when the bug is reintroduced),
:commons:jvmTest, :amethyst:testPlayDebugUnitTest for the assembler test, and
the .deb libegl mechanism end-to-end in an arm64 ubuntu:24.04 container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:40:15 -04:00
Vitor PamplonaandGitHub b259382652 Merge pull request #3888 from vitorpamplona/fix/concord-followups
feat(concord): close the CORD-05/06 invite lifecycle — Invite List, re-mint on Refounding, revocation
2026-08-10 11:14:13 -04:00
Vitor PamplonaandClaude Opus 5 70c53a8fcd fix(concord): make the invite-list writes actually durable
A high-effort audit of the branch found that the durability guarantees the
previous commits claimed were not the guarantees the code provided. Three of
these are in the code written to close the last review, and they defeat
exactly what those commits set out to fix.

`INostrClient.publish` returns Unit — it queues an event and never reports
acceptance; `publishAndConfirm` is the confirming variant. So every
`runCatching { publish(...); true }` was true whenever local signing worked.
That made minting's "record the link before handing out the URL" gate
decorative, and made revoke worse than decorative: it reported success for a
tombstone no relay stored, then recorded the kind-13303 tombstone, whose
merge drops the entry — destroying the only `signer_sk` that could ever
retire the link while the link stayed live. Both paths, and the Refounding
re-mint, now confirm.

`fetchAll` returns an empty list on cannot-connect / CLOSED / idle-timeout,
so "a relay served us and had nothing" and "nobody answered" were the same
observation. Reading the second as "no list yet" reintroduced, one layer
below, the wipe the null-vs-empty work existed to prevent. `fetchAllWithHooks`
gains a `doneOut` of per-relay terminal reasons plus `anyRelayServed()`, and
both clients now only treat an empty read as an empty list when a relay
actually reached EOSE.

The rest:

- `drainConcordRekeys` discarded the entry `adoptConcordRoot` now returns, so
  only the account that *launched* a rotation re-minted its links. An admin
  who was merely re-keyed left every link they had handed out on the dead
  root, and anyone stranded behind one could never recover — which is the
  branch's headline goal, holding only for the rotator.
- The join-time ban gate fetched the Control Plane from `bundle.relays`
  alone (stale metadata refuses a community we can plainly reach) with a
  single un-paged REQ (truncated at the relay's filter cap, so a missing
  older ban edition fails the gate OPEN, re-admitting the account it exists
  to refuse). Now unions in the relays that just served the bundle, and pages.
- `decodeOrNull` failed the whole document for one structurally incompatible
  entry. Since null now means "refuse to write", that converted the old
  silent data loss into a permanent write lock on a coordinate that never
  ages out. Unreadable entries are carried verbatim instead, so they neither
  block the account nor get dropped on re-encode.
- The list read took the newest event of any kind and then cast, so one stray
  event at the coordinate read as "unreadable" forever. Filters by kind first.
- The Refounding refresh did one full round trip per link, serially, inside a
  user-visible rotation. One pooled REQ over every link signer, then
  concurrent confirmed re-mints, classified per coordinate so one link's
  tombstone cannot decide another's status.

Verified on a tablet: mint, list, revoke and the cross-client refusal still
work end to end — and with the community relay killed, revoke now reports
"The link couldn't be revoked" and leaves the entry intact, where before it
would have claimed success and destroyed the key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 10:25:25 -04:00
nrobi144andClaude Opus 4.8 eaba40f2a6 fix: address PR review — narrow search-relay seam, Local providers on Android, bug + test fixes
Review findings from @davotoula:

1. Behaviour drift (real): the per-note event finder reused Account.searchRelays()
   (trusted + own search list), widening every missing-event REQ to trusted relays.
   Add a narrow UserFinderAccount.searchOnlyRelays() (= the pre-extraction
   account.searchRelayList read) and use it in FilterMissingEvents. Implemented on
   Account and DesktopIAccount.
2. Runtime trap: LocalUserFinder/LocalUserFinderAccount/LocalEventFinder error() when
   unprovided and were only provided on Desktop. Provide them on Android too, at the
   logged-in root (AppNavigation) from accountViewModel.dataSources() + .account, so any
   shared composable using the no-arg observeUser*/EventFinderFilterAssemblerSubscription
   overloads is safe on Android (the :napplet process never renders these).
3. Removed the redundant `.ifEmpty { DefaultSearchRelayList }` in Account.searchRelays()
   (SearchRelayListState.flow already applies that fallback) + its now-unused import.
4. Fixed a pre-existing shadowing bug on lines this PR touches: FilterByEvent's
   `note.replyTo?.forEach { parentNote -> }` used `note` in the body, so parent notes were
   never fetched — now uses `parentNote`.
5. Added a test at the layer where #1 lived: filterMissingEvents(cache, keys) fans a
   missing event to searchOnlyRelays + the follow/mine/search default, NOT the trusted
   relays that searchRelays would add.
6. Replaced an inline fully-qualified name in the test with an import (CLAUDE.md style).

Green: commons jvmTest + verifyKmpPurity, :amethyst compilePlayDebugKotlin, :desktopApp compile, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 14:51:19 +03:00
nrobi144andClaude Opus 4.8 fbe163e8f2 test+docs: commons finder test + document per-visible loading (Phase 5)
- EventFinderFilterAssemblyTest (commons jvmTest): filterMissingEvents batches one
  ids-filter per relay with sorted ids; ICacheProvider.checkGetOrCreateUser default
  tolerates a throwing/null getOrCreateUser via a fake cache.
- relay-client skill + commons/ARCHITECTURE.md: document observeUser*/
  EventFinderFilterAssemblerSubscription/observeNote* as the canonical per-visible
  loading entry points and the LocalUserFinder/LocalUserFinderAccount/LocalEventFinder seams.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 a867f9b2ca feat: wire Desktop to the shared per-visible metadata + reaction finders (Phase 4)
Re-apply the Desktop adoption on top of the commons finders (now on upstream's model):
- DesktopIAccount implements UserFinderAccount (connected-relays/nip65 relay hints;
  trustProvider/followerCountProvider = null, declaredFollowsByOutboxRelay = emptyMap —
  Desktop has no NIP-85 subsystem). followerCountProvider() is the new getter this model needs.
- DesktopRelaySubscriptionsCoordinator builds userFinder + eventFinder (RelayOfflineTracker).
- Main.kt provides LocalUserFinder/LocalUserFinderAccount/LocalEventFinder at both logged-in
  composition roots.
- Per-visible adoption across feed (FeedNoteCardBody), NoteCard (profile/thread/bookmarks/
  search/quoted), DM headers + conversation list, UserSearchCard (observeUserInfo),
  NotificationsScreen, thread replies — plus the fast index-relay warm-up on the feed.

Metadata + reactions now load strictly per on-screen user/note on Desktop, coalesced into
batched REQs by the shared finders.

Green: :desktopApp compile, :amethyst compilePlayDebugKotlin, :commons verifyKmpPurity, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 4d31cd7d25 refactor: move per-note event finder to commons on the new upstream model (Phase 3)
Move EventFinderFilterAssembler(+QueryState), the loaders (NoteEventLoader,
FilterMissingEvents, FilterMissingAddressables, AddressableAuthorRelayLoader) and
watchers (EventWatcher, FilterRepliesAndReactionsToNotes/Addresses) from
amethyst/reqCommand/event/ into commons/relayClient/event/, mirroring the Phase 2
user-finder move.

- EventFinderQueryState carries the narrow UserFinderAccount; DROPS AccountScopedQuery.
  Attribution stays inline via userFinderPubkeyHex; ExplainedFilter/SubPurpose tags
  (REFERENCED_EVENTS / ENGAGEMENT) preserved.
- cache: LocalCache -> ICacheProvider threaded into NoteEventLoaderSubAssembler + the
  FilterMissing* functions (which called the LocalCache singleton statically);
  getOrCreateUser is now nullable at these sites. Added ICacheProvider.checkGetOrCreateUser
  default; LocalCache.checkGetOrCreateUser now overrides it.
- SingleSubNoEoseCacheEoseManager moved to commons (account-agnostic accountPubKeyOf);
  its two amethyst callers keep attribution via a new amethyst subclass
  AccountScopedSingleSubNoEoseCacheEoseManager (ChannelLoader) or the plain commons base
  (NWCPaymentWatcher, which never attributed).

Android unchanged via EventFinderShims.kt (typealiases + AccountViewModel overload);
EventObservers.kt stays in amethyst. New commons EventFinderFilterAssemblerSubscription
(LocalEventFinder + (note) overload reusing LocalUserFinderAccount) for Desktop.
Updated the direct loader-function callers (search/hashtag/thread sub-assemblers + the
moved test) to import from commons and pass LocalCache.

Green: commons JVM + iOS purity, :amethyst compilePlayDebugKotlin, :desktopApp compile, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 983fc1aab2 refactor: move per-user metadata finder to commons on the new upstream model (Phase 2)
Move UserFinderFilterAssembler(+QueryState), the four sub-assemblers
(UserOutboxFinder, UserWatcher, UserReports, UserCards), FilterUserMetadataForKey,
and FilterReportsToKey from amethyst/reqCommand/user/ into commons/relayClient/user/,
plus the inner Account-free pickRelaysToLoadUsers overload into commons
PickRelaysToLoadUsers.kt.

Reconciled with upstream's re-architecture:
- UserFinderQueryState carries the narrow UserFinderAccount (Phase 1) instead of the
  full Account; DROPS AccountScopedQuery. The finder sub-assemblers extend the commons
  base managers and attribute inline via soleAccountPubKey — now sourced from
  UserFinderAccount.userFinderPubkeyHex — so upstream's per-account attribution +
  ExplainedFilter/SubPurpose tags are preserved unchanged.
- cache: LocalCache -> ICacheProvider; FilterUserMetadataForKey + pickRelaysToLoadUsers
  take an injected relayHints: HintIndexer instead of the static LocalCache.relayHints.
- UserOutboxFinderSubAssembler inlines the relay-tier union over the UserFinderAccount
  getters (behaviour-preserving vs the old Account-based outer overload).

Android unchanged: UserFinderShims.kt typealiases keep call sites (incl. reqCommand/event
EventFinder*) compiling; UserFinderFilterAssemblerSubscription + UserObservers stay in
amethyst (Account is-a UserFinderAccount). New commons UserFinderSubscription
(LocalUserFinder/LocalUserFinderAccount + overloads) and UserMetadataObservers
(observeUser*) added for Desktop. amethyst FilterFindFollowMetadataForKey's outer
overload now delegates to the commons inner one.

Green: commons JVM + iOS purity (verifyKmpPurity), :amethyst compilePlayDebugKotlin,
:desktopApp compile, spotless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 1511a8018b feat: add UserFinderAccount seam + implement on Account (Phase 1)
The narrow read-only relay-hint seam the shared per-user/per-note finders need,
so they can move to commons without the fat amethyst.model.Account. userFinderPubkeyHex
doubles as the attribution pubkey for ExplainedFilter.accountPubKeys (upstream's
subscription-attribution model needs only a pubkey, not the whole Account).

Adds followerCountProvider() vs the prior design — upstream's UserCardsSubAssembler now
reads trustProviderList.liveUserFollowerCount alongside liveUserRankProvider.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 5a245c9f58 refactor: add relayHints seam to ICacheProvider (Phase 2 prep)
The shared user/event finder filter functions read LocalCache.relayHints
statically to discover which relays likely hold a user's metadata or a missing
event. To let those functions move to commonMain, expose the HintIndexer
(a quartz type) on ICacheProvider. Implement it on Android LocalCache
(override the existing field), add one to DesktopLocalCache, and satisfy the
four commonTest stub caches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:09 +03:00
nrobi144andClaude Opus 4.8 89b2a273a0 refactor: move EOSEAccountFast to commons, KMP-purified (Phase 2 prep)
EOSEAccountFast is used by the user/event finder assemblers that will move to
commonMain. Relocate it to commons.relays with KmpLock instead of
synchronized(...) so it passes the iOS verifyKmpPurity gate. The old
amethyst.service.relays location keeps a typealias, so its 7 existing importers
are untouched. (SincePerRelayMap/MutableTime/EOSERelayList were already commons
typealiases.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:08 +03:00
nrobi144andClaude Opus 4.8 6e5f8c0fa0 docs: upstream reconciliation plan for shared metadata loading (v2)
Redo the commons extraction of the per-user + per-note finders on top of current
upstream/main, which re-architected the subsystem (AccountScopedQuery attribution +
SubPurpose/ExplainedFilter tagging). Key finding: attribution needs only a pubkey, so
the narrow UserFinderAccount seam survives and the finder query states can drop
AccountScopedQuery entirely.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 11:38:08 +03:00
Vitor PamplonaandClaude Opus 5 d27930fe76 feat(concord): manage and revoke your invite links from Android
Revoking existed only in `amy` after the last commit, so the app could hand
out a link it could never take back. This adds the Android half.

`Invite links…` in a community's overflow menu opens a screen listing every
link this account minted for it, read from the creator's own kind-13303
Invite List, each row offering Copy and Revoke. It shows only *our* links,
because a link's `signer_sk` is what authors its coordinate and only the
minting account ever held it — another admin's links are invisible here and
un-revokable from here. That is the protocol, not a gap in the screen.

Two deliberate choices:

The entry point is NOT gated on CREATE_INVITE, unlike minting. Revoking acts
on a key we hold rather than on the community, and gating it on the bit would
mean a demoted admin could no longer retire the links they had already handed
out — exactly when that matters most.

An unreadable list is its own state, never an empty one. Telling a creator
who came to kill a leaked link that they have no links would be a lie in the
one direction that costs them something.

`revokeConcordInvite` publishes the wire tombstone first and records the
kind-13303 tombstone second, for the same reason the CLI does: the entry
holds the only copy of the `signer_sk` the publish needs, and a merge drops a
tombstoned token's entry terminally. A failed list write is reported as
success because the link is already dead on the wire.

Verified on a tablet against a local relay, cross-client with amy: the screen
lists the two links the device minted (and not the one alice minted), the
confirm dialog revokes exactly one coordinate — flipping it to vsk=9 with
empty content while its siblings stay vsk=6 — the row disappears on reload,
and a link revoked from the UI is then refused by `amy concord join` with
`revoked` while the surviving link still joins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:57:23 -04:00
Vitor PamplonaandClaude Opus 5 fc3f181184 feat(cli): add amy concord revoke — retire an invite link (CORD-05)
The reading half of revocation already existed: `classify` has always
resolved a `vsk=9` tombstone to `Revoked`, and Amethyst's join honours it.
Nothing anywhere could *produce* one, so a leaked link could only be
outrun by a Refounding — rotating the whole community to retire one URL.

`ConcordInviteBundle.buildRevocation` emits the grave the spec describes
and Armada's `buildRevocationEvent` already publishes: kind 33301 at the
link's own `["d",""]` coordinate, empty content, `["vsk","9"]`, signed by
the `link_signer` secret. Empty content is the interop contract, not an
omission — there is nothing to encrypt when the point is that no bundle
key opens anything.

`amy concord revoke COMMUNITY TOKEN|URL` takes either the shareable URL a
creator actually has to hand or the bare token. It publishes the wire
tombstone FIRST and records the kind-13303 tombstone second, which is the
inverse of minting and deliberate: the list entry holds the only copy of
the `signer_sk` the publish needs, and a merge drops a tombstoned token's
entry terminally. Recording first and then failing to publish would leave
the link live with its signer gone and no way left to retire it. A failed
list write is recoverable by comparison and is reported rather than
swallowed.

Also fixes a revocation bypass in amy's own `join`, found while testing
this: it opened the first wrap that decrypted instead of classifying the
coordinate, so a relay still serving a stale copy alongside the grave
would have handed out a revoked link. It now resolves per CORD-05 §2 like
Amethyst does, and can say which of revoked/expired/unreadable/absent it
hit instead of reporting everything as `not_found`.

Verified end to end against a local relay: revoking flips the coordinate
to vsk=9 with empty content, the link is refused from that moment on, a
second revoke reports `already_revoked`, and a Refounding afterwards moves
the surviving link while leaving the grave alone — the first real proof of
the tombstone-skip in the refresh path, which until now had only unit
coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 00:30:09 -04:00
Vitor PamplonaandClaude Opus 5 a1f980babd fix(concord): make the invite failure messages visible in the dark theme
Found while proving the new ban gate on a tablet: the refusal reached the
view hierarchy but rendered as black pixels on the black background, so
the screen looked blank and the user was told nothing.

`ConcordInviteScreen`'s Column sits on the bare window background with no
Surface above it, so `LocalContentColor` is still Material 3's default
black. This predates the invite work and silently affects every state the
screen can end in — invalid, incompatible, revoked, expired, unreachable —
plus the "Redeeming invite…" progress label, which is why only the spinner
was ever visible while a join was in flight.

Verified on device: the message now renders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:40:30 -04:00
Vitor PamplonaandClaude Opus 5 eb8c812690 fix(concord): close ten review findings in the CORD-05 invite path
A high-effort review of the invite work found ten correctness bugs, nine
confirmed and one plausible. All are fixed here; the on-device pass on a
tablet proved the three that are observable through the UI.

The load-bearing one: the kind-13303 Invite List is replaceable, and both
clients merged a patch onto a base that silently degraded to EMPTY whenever
the read failed — an unanswered relay or a bunker signer declining one
decrypt was enough. Republishing that destroys every `signer_sk` it could
not read, and those secrets cannot be regenerated, so every outstanding
link is orphaned at a dead epoch. `decode` is now `decodeOrNull` and
`decrypt` returns null, so "I could not read it" is distinguishable from
"it is empty", and the write aborts rather than overwriting.

The rest:

- `join` is now ban-gated on both clients. A Refounding re-mints every
  outstanding link onto the new root, and an ex-member keeps the URL and
  its token forever, so the rotation meant to expel them handed them the
  new keys instead. Fails closed on an unreadable plane.
- Android's Refounding re-read the entry from `liveCommunities` straight
  after adopting the new root, but that flow decrypts asynchronously, so
  every link was re-minted onto the epoch just left. `adoptConcordRoot`
  now returns the entry it wrote.
- amy's refound folded the fresh bans locally and then never used them,
  re-draining from relays instead; a relay slow to echo them back would
  produce a new epoch whose roster never banned anyone.
- Link refresh rebuilt the bundle from scratch, stripping expiry, channel
  grants, icon and label; it now moves the link's own current bundle and
  changes only the epoch's key material.
- Refresh also re-posted over revocation tombstones, silently un-revoking
  a retired link.
- The 13303 coordinate is (13303, me, "") — one list per account — but was
  read and written on per-community relays, forking it into divergent
  versions that newest-wins then collapsed. Now account-outbox only.
- Minting returned the URL even when recording the link failed, handing
  out a link that could never be refreshed. It now fails closed.
- amy's refound had no equivalent of Amethyst's recipient cap, leaving the
  attacker-writable half of the union unbounded.
- amy's store dropped the banked epoch's `controlRoot` on the round-trip,
  losing the staff write key that rebuilds the anti-rollback floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 23:39:54 -04:00
Vitor PamplonaandClaude Opus 5 66d2777262 feat(concord): wire the Invite List into Amethyst; fix the QR quiet zone
Android half of the CORD-05 Invite List (kind 13303). Minting records the
link's `token` + `signer_sk` in the shared, self-encrypted list, and a
Refounding re-posts every live link it finds there at that link's own
coordinate, carrying the new epoch. Read-merge-write, never overwrite: two of
the user's devices minting concurrently would otherwise delete each other's
`signer_sk`, which is unrecoverable. Expired links are skipped — re-posting
one would only resurrect a dead URL at a live epoch.

Verified on a Galaxy Tab A7 Lite against a loopback geode, driving the real
UI:

  - tapping Invite publishes a kind-13303 authored by the device
  - Remove member → the Refounding publishes 5 control wraps + 1 rekey blob;
    `amy` follows it (epoch 0 → 1), and the removed member gets
    `no_blob_for_us` and stays behind
  - with a device-minted link in the list, the next Refounding logs
    "refreshed 1 invite link(s) to epoch 2" and the bundle at that link's
    coordinate is REPLACED in place rather than orphaned

Also fixes the invite QR rendering as a postage stamp. QrCodeDrawer's quiet
zone was a fixed 100px per side, which does not scale: at the dialog's 220dp
box that ate ~45% of the canvas, so a long payload drew tiny inside a large
white card. Expressed as the QR spec's 4-module zone it stays proportional,
and the code now fills whatever box it is given at every call site.

Note: OpenCV cannot decode this drawer's stylized modules either before or
after the change, so scannability was not machine-verified — the change only
shrinks excess quiet zone to the spec minimum and enlarges the modules, but a
camera check before release is worthwhile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:48:19 -04:00
Vitor PamplonaandGitHub a5507f9a4d Merge pull request #3889 from vitorpamplona/claude/paged-walk-termination-guards
quartz: make a paged walk terminate — floor the cursor at 0, stop when a relay ignores it
2026-08-09 20:40:13 -04:00
Vitor PamplonaandClaude Opus 5 293ffd0bc5 feat(concord): implement the CORD-05 Invite List (kind 13303), wire-compatible with Armada
The previous commit kept link secrets in amy's local store, which made link
refresh work but only for that one client. The spec already defines where
they belong, and Armada implements it, so this replaces the local field with
the real cross-client document.

Kind 13303, replaceable, NIP-44-encrypted to self — the creator's private
bookkeeping:

    { "entries":    [ { "token", "signer_sk", "community_id", "url",
                        "label?", "created_at", "expires_at?" } ],
      "tombstones": [ { "token", "community_id" } ] }

`token` is both the link's unlock secret and the merge key; `signer_sk` is
what lets any of the creator's clients re-sign at that link's addressable
coordinate. Armada types both the entry and the tombstone as
`[k: string]: unknown`, so unknown keys are contract: the codec preserves
entry-, tombstone- and document-level residue, and re-encoding never deletes
another client's data.

Merge is by token, read-merge-write rather than overwrite — the list is
replaceable and per-creator, so two devices minting concurrently would
otherwise destroy each other's `signer_sk`, which is unrecoverable. A token
tombstoned on either side stays dropped, so a stale device cannot resurrect
a retired link.

Registers 13303 in EventFactory (without it the kind deserializes as a plain
Event and every typed read fails), and points amy's mint and Refounding
refresh at the list instead of its own store.

Semantics were taken from the spec and confirmed against Armada's observable
behaviour — read for semantics only, never copied: Armada is AGPLv3 and
Amethyst is MIT.

Verified against a loopback geode: `concord invite` publishes an encrypted,
untagged 13303; a Refounding reads it back, refreshes the live links, and a
member with no role who never posted — unfindable by any rotation — recovers
epoch 0 → 1 through the link he already held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:50:56 -04:00
Claude 7ca4fbab33 Cover the dense-second step-past the new guard sits in front of
The step-past path had no test, and it is the one thing the ignored-cursor
guard could plausibly break: both cases reach the same `delivered == 0`
branch. They are told apart by WHERE the events landed — a dense boundary
second returns them AT the boundary, so `aboveBoundary` stays 0 while
`received` is 1 and the guard holds its fire; only a relay answering ABOVE
the boundary is not paging at all.

Scripted end to end: a second the relay's page cap can only ever return the
head of, the step strictly past it, and the empty EOSEd page below. Also
proves the documented cost is still paid rather than silently changed — the
unreachable tail of that second is lost, and `downloaded` says so.

`event()` grows a nonce so two events can share one `created_at`; the id was
derived from the timestamp alone, which collapsed them into one event and made
a dense second impossible to script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT
2026-08-09 23:48:56 +00:00
Claude 3b7ffcd06c Make a paged walk terminate: floor the cursor at 0, stop when a relay ignores it
`fetchAllPages` could not end against a relay that does not honour `until`.
Found in production on purplepag.es, which holds twelve `kind 10002` events
stamped `created_at = 0` and treats `until <= 0` as *no* `until` — so the page
below them comes back with its five hundred NEWEST events. None of those
matches the filter's own `until`, so the page delivers nothing, which read as
"the boundary second is too dense to page", stepped one second lower, and
asked the identical unanswerable question again.

Measured against the live relay: ~5.5 pages a second, 500 events fetched and
discarded on each, an EOSE on every single page, `until` marching one second
further negative every time, for as long as the process ran. A cold walk pulled
1,490,010 real events in ~10.8 minutes and then never returned, so the caller's
coverage was never recorded and the next boot re-walked all of it. Not a rate
limit: ~1,000 consecutive pages drew no NOTICE, no CLOSED and no throttling.

Two guards, both at the points where the cursor moves:

  - The cursor floors at zero. `created_at` is unsigned, so nothing can exist
    below epoch 0: a cursor that would step under it has reached the bottom of
    the time axis and the walk is DRAINED. `until = 0` is still asked — it is a
    legal query and the boundary re-fetch for epoch-stamped events — only going
    BELOW it ends the walk. This also keeps a negative `until` off the wire,
    which relays disagree violently about: measured across five, one CLOSEs the
    subscription with a parse error, three answer a NOTICE and then never EOSE,
    and one drops the bound and serves its newest events. The floor is applied
    on the advance path too, not just the step: `pageMinTs` is an event's own
    `created_at`, so one relay serving a negative timestamp is enough to drive
    the cursor under zero, and clamping rather than stopping would not help —
    such an event never equals the boundary, so it dodges the dedup and returns
    on every page.

  - A relay that ignores the cursor is UNPAGEABLE. When a page delivers nothing
    and every event it received was NEWER than the `until` it asked for, the
    relay is not paging at all and stepping one second lower just repeats the
    question. `aboveBoundary == received` is what tells this apart from a
    genuinely dense boundary second, whose events are AT the boundary rather
    than above it. UNPAGEABLE is deliberate and conservative: it proves nothing
    about what the relay holds, so no coverage claim can be built on a page the
    relay never really answered.

This second guard is the structural fix. Giving the filter a `since` does not
substitute for it: the step path decrements `until` without regard to `since`,
so a cursor-ignoring relay still walks from the window floor down to 0 — up to
~1.5 billion pages. A `since` only helps when the relay honours it, and then
only because the empty page arrives as a drain.

Three scripted tests cover both guards, and CursorTerminationProbe dials the
five relays that found this (opt-in, `-PprodRelayBench=1`, asserts nothing).
Against the live relays after the change: purplepag.es ends UNPAGEABLE in one
page and 2.4s with the cursor never going under 0, where it previously ran 244
pages to `until = -243` without ending; the other four still DRAIN unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPSzniNdvJxkhRsCe1QcyT
2026-08-09 23:38:18 +00:00
Vitor PamplonaandClaude Opus 5 409339b375 feat(concord): re-mint invite links on Refounding so stranded recovery fires
Closes the liveness half of A2. Recovery's whole premise is that the
community keeps re-minting its bundle at the SAME addressable coordinate, so
the link a stranded member already holds starts pointing at the new epoch.
Nothing did: `ConcordInviteBundle.mintLink` generates a fresh KeyPair and
token per call, and no client persisted `linkSignerPrivKey`. Every mint was a
new coordinate, so `recover` could only ever return `already_current` — the
mechanism was dead code, and an owner evicted by a rogue admin had no way
back.

The kind-33301 bundle is addressable and authored by the link signer, so
re-signing at that coordinate with the same token replaces what is there and
every holder of that link keeps working. Exposes that as
`ConcordActions.remintBundleAt`, persists the link signer + token in amy's
store at mint time, and has `concord refound` refresh every link it minted
for the new epoch.

Re-minting every live link is safe precisely because the security half is
already in: `refound` bans the removed members on the way out, and `recover`
reads the banlist of the epoch being LEFT, so a removed member's own recovery
is refused even though their link now resolves. That gate stops being
belt-and-braces here and becomes load-bearing — which is what the audit
predicted for any client that re-mints (Armada does).

Verified end to end against a loopback geode, both directions:

    bob joins by link, holds no role, never posts   (unfindable by a rotation)
    alice refound --remove <stranger>  → recipients=1, invites_refreshed=1
    bob rekey    → no_blob_for_us      (genuinely stranded)
    bob recover  → recovered, epoch 0 → 1   ← first time this has ever fired
    bob reads the community at the new epoch

    alice refound --remove bob         → epoch 1 → 2, invites_refreshed=1
    bob recover  → refused, reason "banned", still at epoch 1

Still open for the shipping client: Amethyst persists no link signer, so
A2 liveness remains open on Android. Doing it there means deciding where the
secret lives in the kind-13302 list, which Armada also reads — a wire-schema
call, not a code one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:06:39 -04:00
Vitor PamplonaandClaude Opus 5 0aa3adfc07 feat(cli): add amy concord refound + rekey — the rotation half of CORD-06
`refound` is the hard removal a ban cannot give: a ban only strips standing,
while the removed member keeps every key they ever held. Rotating the
`community_root` — and, since CORD-02 §2, a fresh `control_root` beside it so
a demoted staffer's retained secret dies with the epoch — is what actually
closes the room. The compacted Control Plane is re-sealed at the new epoch
and each retained member gets a rekey blob.

Authority mirrors Amethyst exactly: `hasPermission`, never
`effectivePermissions`, so a banned BAN-holder cannot launch one; the owner
is never a valid target; and removal takes the same rank rule as a ban
(CORD-04 §3) — an admin cannot Refound a peer admin out.

The recipient set reaches past the roster to the Guestbook AND the authors of
every channel message we can decrypt, because a member who only ever posted
holds no role and files no Guestbook motion — building the set without them
silently expels them. It is still a floor, not a census.

`rekey` is the receive half, and without it `refound` was actively harmful
from the CLI: a retained member's blob sat on the relay unopened, so a
Refounding launched from amy stranded every other amy member. It authorizes
the rotator against the roster of the epoch being LEFT and fails closed.

Verified end to end against a loopback geode — the full cycle, which was not
previously expressible from the CLI at all:

    alice refound --remove <stranger>  → epoch 0 → 1, recipients=2
    (bob is kept because he POSTED, holding no role — the author harvest)
    bob rekey                          → epoch 0 → 1, same root + control_pk
    bob sends, alice reads it at the new epoch
    alice roles                        → the removed member is banned

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:48:28 -04:00
Claude b7f55d8697 chore: add a runtime perf probe for the embedded vs full-screen WebView
The embedded tab and the full-screen browser are the same WebView in the same
`:napplet` process with byte-identical WebSettings, so a site whose JS feels
slower in the embed is being slowed by the host, not by its configuration.
`perf.html` measures which host effect it is: page visibility (a page Chromium
treats as hidden gets ~1Hz timers and no rAF), raw CPU throughput (the renderer
inherits its scheduling class from whichever process hosts the WebView — the
embed's is a plain bound service, the full-screen one is top-app), forced-layout
cost, rAF rate, long tasks, and input-delivery latency measured from the
platform's own event timestamp.

Open the same URL in both hosts and compare the summary line; the README says
what each divergence points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-09 22:44:46 +00:00
Vitor PamplonaandClaude Opus 5 4022a6a5da feat(cli): add amy concord recover for stranded-recovery (CORD-05/06)
Completes the CLI's Concord receive path. A Refounding carries only
`(newRoot, newEpoch, rotator)` and no recipient list, so a member simply
left out of the rekey receives nothing and sits on the dead epoch forever
while everyone else moves on. There is no message to miss, which is why the
rekey drain cannot help: the only way back is the invite link the membership
was joined through, since the community keeps re-minting its bundle at the
same addressable coordinate.

amy never stored that anchor, so recovery was impossible in principle. Adds
`inviteRef` to the stored record, populated on `join` (bare, domain-agnostic)
and carried through `import` — backstopped by what we already held, because
a list entry without one must not clear ours or the NEXT exclusion becomes
unrecoverable.

Recovery is an explicit verb rather than Amethyst's timer sweep, so it stays
deterministic and scriptable. Each community reports why it did or didn't
move: `no_invite_ref`, `bad_invite_ref`, `no_live_bundle`, `banned`,
`already_current`, `control_plane_not_folded`, or the epoch it advanced to.

The ban gate is the part that matters (A2 in docs/concord-soft-ban-audit.md):
a removed member keeps the link's unlock token forever, so without it this
walks them straight back into the epoch they were rotated out of. It reads
the banlist of the epoch being LEFT — the last plane we can still fold — and
fails closed: a plane that will not fold yields no verdict and is skipped,
never recovered.

Verified against a loopback geode: a current member gets `already_current`,
a community with no anchor gets `no_invite_ref`, and a member banned at the
current epoch is refused with `banned`. The merge-forward itself is quartz's
`ConcordStrandedRecovery` (already unit-tested); it is not exercised live
here because amy cannot perform a Refounding to strand anyone with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:37:05 -04:00
Vitor PamplonaandClaude Opus 5 3153942bac feat(cli): let amy adopt the Control Plane write key a Grant delivers
A promotion to staff delivers the `control_root` inside the Grant edition
itself (CORD-04 §3). Amethyst drains that on its Concord revision tick, but
that logic lived only in `AccountConcordActions`, so `amy` could hold a
rank it could never write under: the fold seated it as staff and every
moderation verb still refused with `forbidden`. That is also why #3873's
delivery path shipped without a CLI test — the harness could not accept a
promotion.

Extracts the decision into `commons` as `ConcordReceive`, pure and shared:

- `deliveredControlRoot` — the whole fail-closed check (are we staff by our
  OWN fold, does a Grant carry a wrap, does it open under the pairwise key,
  name this epoch, and derive to the `control_pk` we already hold).
- `withAdoptedRoot` — the entry rewrite a base rotation produces, banking
  the leaving epoch's address for the anti-rollback floor.
- `isAuthorizedRotator` — the ban-aware rotator check.

Amethyst now calls the shared versions (no behaviour change; its persist +
publish and Guestbook re-announce stay put). amy adopts during the Control
Plane drain every moderation command already performs, since it has no tick
of its own, and returns the refreshed record so a freshly promoted staffer
can pass the secret on in its own Grant.

Adoption is local to amy's store on purpose: Amethyst republishes the
kind-13302 list so a user's other devices follow, and doing that here would
mean rebuilding and signing the whole list from the CLI.

Verified end to end against a loopback geode: bob is refused before the
promotion, alice promotes him, bob's stored `control_root` is blank, his
next command adopts and persists it, and his BAN lands and is honored by
alice's independent fold. A role edition he lacks MANAGE_ROLES for is still
dropped on fold — possession remains a spam gate, never authority.

Still Android-only: `recoverStrandedConcordCommunities`, which needs invite
re-resolution over the network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 18:03:28 -04:00
Vitor PamplonaandClaude Opus 5 05a331068f test(concord): pin that a banned member's typing heartbeat is dropped
The soft-ban audit's A4 shipped with a send-side guard and a receive-side
filter, and the receive-side filter — the only one that binds a modified
client — had no test.

Bans first, so the assertion exercises the filter rather than an entry that
was seated before the ban, and checks the filter is targeted rather than a
blanket mute. Mutation-tested: removing the isBanned check in
ConcordCommunitySession.ingestTyping fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:51:39 -04:00
Vitor PamplonaandGitHub e7d71d28fb Merge pull request #3858 from dskvr/codex/chase-nip5d-naps
Align napplet host with current NIP-5D and NAPs
2026-08-09 17:36:44 -04:00
Vitor PamplonaandClaude Opus 5 a0528c5745 Merge branch 'main' into codex/chase-nip5d-naps
Brings the NIP-5D napplet-host alignment up to current main, which had moved
98 commits ahead of the branch point (d3bd7a45b9). No conflicts: only one
commit on main touched napplet files in that window, and it was a broad
suspend-chain refactor rather than napplet work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 17:02:43 -04:00
Claude a0f4328f2a fix: keep the embed re-focus ping payload-free and re-seed via resync
Audit of the previous commit found three problems in it.

The `ime.refocus` sent on every tap carried the field's full editing state,
so a tap in a long textarea put 40KB on the wire per tap (145B before), and
its geometry made the page mirror the whole field into a hidden div and force
a synchronous layout — measured at ~3.5ms per tap on a 40k-char textarea,
doubling the cost of every tap in a field. Split the message in two: taps ring
a payload-free `ime.wantkb` doorbell, and the host answers it with the
`ime.resync` it already had — but only when it no longer mirrors the field, so
the common "keyboard was dismissed, tap to get it back" case is one small
message and no round trip. Per-tap payload is now constant (~180B) and the
per-tap CPU cost is back at parity with before the fix.

The "am I already hosting this field" check read `hasFocus()` alone, so a
focus that lingers past `clearFocus()` (a lone focusable in the hierarchy can
take it straight back) would have skipped the re-seed and shipped the previous
tab's text to the page on the first keystroke. Track mirroring explicitly.

The keyboard-restore mark was armed by any keyboard up at tab-switch time,
including one belonging to the browser's own address bar; require that the
mirror actually holds it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
2026-08-09 19:38:50 +00:00
Vitor PamplonaandGitHub 384d5219ea Merge pull request #3886 from vitorpamplona/claude/push-notification-grouping-lbd13e
fix: stop reposts from being bundled with the always-on service notification
2026-08-09 15:20:15 -04:00
Vitor PamplonaandGitHub 2ecbb66cdb Merge pull request #3885 from vitorpamplona/claude/concord-soft-ban-vulnerability-rbjjet
fix(concord): close the soft-ban authority holes (audit A1–A4, B1, B2, B4)
2026-08-09 15:14:29 -04:00
Claude 59994a7847 fix: mark a notification dismissed on swipe, mark-read and inline reply
The 25s enrichment window re-posts a notification every time metadata for it
lands, and postStandard/postConversation only skip that when
NotificationUtils.wasDismissed says the user is done with the event. Only one
path ever recorded that: reading the note in-app. Swiping the notification
away, hitting "mark as read", or replying from the tray all just cancelled
the notification id, so the enricher happily put it back seconds later — and
kept a relay subscription and a wakelock open for it until the window
elapsed.

Every notification already carries a delete intent and its actions target the
same receiver, so thread the event id through them and mark it dismissed
there. Replies mark it only once the send succeeds, leaving a failed send free
to enrich and retry.

Also, in the same area:

- Pin the group summary's timestamp to the child's event time. It defaulted
  to "now", and the summary is re-posted on every enrichment re-render, so
  the group kept re-sorting in the shade while the user was reading it.
- Make the childless-summary scan a single pass. Now that every child ships
  with a summary the active list is about twice as long and the pairwise scan
  grew four-fold. Deciding what is a child by the summary flag instead of by
  comparing ids also fixes the case where a child's id equals the summary's.
2026-08-09 17:32:42 +00:00