Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
Validated against the production relay wss://amethyst.communities.buzz.xyz
by joining and running a full DM round-trip. Adds the join primitive and
fixes the interop gaps that live testing surfaced.
- quartz: BuzzInviteLink — parse `https://<host>/invite/<token>` (relay-signed
base64url payload → community/role/expiry). A Buzz invite is NOT a NIP-29
code; it is redeemed over HTTP against the tenant host. Unit-tested with a
real token; rejects the Concord `/invite/<naddr>#…` shape (no collision).
- cli: `amy buzz join <invite-url>` — the real 3-step claim: GET /api/join-policy,
POST /api/invites/accept-policy, then NIP-98-signed POST /api/invites/claim.
Proven live (status: joined, role: member).
- cli: Context.publish now authenticates-then-retries on an `auth-required`
relay (warm the connection with a pendingOnAuthRequired REQ, then re-publish)
— the write path had no NIP-42 handling, so every Buzz write was rejected.
- cli: Buzz reads (dm list / read / console / personas) use the auth-aware
drain (pendingOnAuthRequired).
- cli: `dm open` surfaces the relay's synchronous OK `response:{channel_id}` —
the authoritative DM channel id (the relay assigns it; it is not polled).
- cli: `dm list` rewritten to the relay's actual discovery — kind-44100
member-added notifications (#p=me) filtered to the kind-40099 `dm_created`
channels. The deployed relay does NOT emit kind-41001.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
A Buzz DM is a relay-authoritative NIP-29 group whose h/id is a
relay-generated UUID, so its timeline reuses the whole relay-group chat
stack unchanged. This adds the missing discovery + product layer:
- commons: BuzzDmRegistry — process-wide registry fed by LocalCache from
the relay-signed DmCreatedEvent (41001) and per-viewer DmVisibilityEvent
(30622); tracks conversations (channel id -> participants/relay) and the
viewer's hidden set. Unit-tested.
- LocalCache: record 41001/30622 into the registry on consume (was
store-only).
- Account: openBuzzDm (41010), hideBuzzDm (41012), addBuzzDmMember (41011).
The relay assigns the channel UUID and confirms via 41001 — we never
mint it.
- Android: BuzzDmListViewModel (two-phase fetch: discover 41001/30622 #p=me,
then fetch each DM's 39000-39003 roster so the shared composer's member
gate passes), BuzzDmListScreen (inbox), BuzzNewDmScreen (publish 41010,
await the 41001, jump into the shared RelayGroupChatScreen). Reached from
a Direct Messages card on the Workspaces tab.
- CLI: amy buzz dm list/open/hide/add-member, mirroring buzz-cli.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Thin assembly over quartz + commons (no new protocol in cli/):
- buzz post RELAY GID <text> — publish a kind-40002 stream message (h-scoped)
- buzz read RELAY GID — drain the recent human-visible timeline (9/40002/40099)
- buzz attest AGENT — sign a NIP-OA OwnerAttestation offline, print the auth tag
- buzz console [--relays] — drain kind-44200 turn metrics (#p=me), NIP-44-decrypt,
and aggregate via the shared commons AgentFleetAggregator
- buzz personas [--relays] — list my kind-30175 personas (newest per slug)
Join/leave/create reuse 'amy relaygroup' (Buzz workspaces are NIP-29 groups). Wired
into Main dispatch + usage; README command table + ROADMAP updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
Addresses review findings from a merge-time audit.
- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
carry `clone` with the same spec shape but still emitted repeated single-value
tags and read only the first value — so the exact interop bug this branch set
out to kill was still live for PRs, both directions (ngit keeps only the last
repeated tag; we lost every URL after the first from ngit's multi-value tag).
Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
on the wire + GitNip34InteropTest + CLI harness (40 checks).
- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
author check meant anyone could publish a kind-1632 and make someone else's
issue render closed. Now filter statuses to the repository owner (from the
status's own `a` tag), declared maintainers (from the cached announcement), or
the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
on main; this branch made the CLI/Android divergence visible.
- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
stdin on an interactive TTY (amy is non-interactive — error instead). The local
`git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
CLI.
Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Findings from a second review round (two independent reviewers), with fixes:
Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
account outbox/bootstrap (general relays); NIP-34 events live on the repo
announcement's advertised relays (often GRASP/git-specific), which general
relays don't mirror — so `amy git issues <repo>` with no --relay could return
empty. Now fetch the announcement once and read from queryTargets ∪ its
advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
→ pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
(`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
(was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).
Write path:
- **`git init` silently reported success when the 30618 state publish failed**
— its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
relays) now warns to stderr instead of reporting silent success.
Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Findings from a review pass over the git-parity branch, with fixes:
- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
(interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
boundary commits, not the true root, so the repo would be announced under a
different cross-fork identity than ngit computes. Now: detect shallow clones
and omit the euc with a warning to pass `--earliest-commit`; on full clones
derive the deterministic `--first-parent` mainline root instead of an
arbitrary `tail -1`.
- **`git issues|patches|prs` silently truncated and mis-derived status** on
active repos: one single-page `drain` pulled items AND status events under a
shared cap, so status events (newer, more numerous) could crowd items out of
the window and the close-status that determines an item's state could fall
outside it → a closed item read as open. Now paginate the items
(`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.
- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
the OS (a chatty command can no longer fill its stderr pipe and hang the
stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
while draining stdout, so a patch larger than the pipe buffer can't deadlock.
- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
bytes; `GitRepositoryEvent.clones()/webs()` dedupe.
The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.
Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.
- Repository announcement `clone`/`web` were emitted as REPEATED single-value
tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
known tags — so multi-URL repos silently lost every URL but one in both
directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
BOTH the spec form and the legacy repeated form, so old events still parse.
(`relays`/`maintainers` were already correct multi-value tags.)
- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
PRs already include — a maintainer watching `#p` wouldn't see them. The
builder now adds it (fixes both the CLI and the Android issue-creation path,
which both passed an empty notify list).
- Patch / PR / PR-update `r` tags carried the `"euc"` marker
(`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
filter matches either shape, so this is a spec-compliance/byte-parity fix.
`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.
Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Close two more ngit/nak parity gaps:
- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
Shells out to `git` like `git init`, since it operates on the local checkout.
Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.
Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.
This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.
Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.
Read-only: pushing git objects back to clone/GRASP servers stays out of scope.
Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.
Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.
New sub-verbs (all thin assembly over quartz's `nip34Git` builders):
- `git state` — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch` — kind:1617 patch from `git format-patch` (--file or stdin),
with --root/--root-revision, --commit, --parent-commit,
and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
(aliases `merged`/`resolved` for applied); applied carries
--merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
newest authoritative (owner/maintainer/author) status event,
with --open/--applied/--closed/--draft/--status filters
- `git thread` — one item plus its status timeline and comments
Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.
Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.
Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:
- assignable roles are `roles().filter { myRank < it.position }` — the fold
drops a grant whose granter does not strictly outrank every assigned role, so
offering one at or above our own position would publish an edition that every
client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
already folds the whole rule (hold the bit, not banned, target isn't the
owner, strictly outrank) and makes self-promotion fall out for free.
Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.
The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.
`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.
Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:
v0 owner (grant mods)
v1 owner (grant admins) <- fold stopped here, forever
v2 MIDTIER (escalation, correctly rejected)
v3,v4,v5 owner orphaned, unreachable
`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.
Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.
So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.
The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.
Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.
Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.
The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.
Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.
**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.
Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".
`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.
**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.
Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
private messages **with Alice**". It never degrades to nothing —
cached name, else a shortened npub. Knowing *whose* messages is a
categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
preview, as documented. It is a local operation and nothing is exposed
until approval. Failure, blank and hang all collapse to an explanatory
string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
`DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
The dialog's primary button becomes "Always allow for Alice" with the
broad option demoted. Because the ledger stores an opaque op key, no
persisted decision migrates and the storage format is untouched.
Scoping decrypt per counterparty *instead* would have been worse than
the bug: a DM client would prompt once per conversation, training users
to approve everything. A narrow option beside the broad one gives
granularity without the prompt explosion.
Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.
Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.
Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.
Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.
Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.
Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.
The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.
A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.
Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.
DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.
Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:
- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
10x the idle window, delay()-watchdog: cancellable and virtual-time
testable). The pure idle window was unbounded when a relay trickled
events forever — sandboxed napplet queries, set -e fetches, and
marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
timeout scope (an expiring window could cancel verifyAndStore
mid-write and silently drop a received event); the timeout is armed
only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
unsynchronized events/seenIds mutation from concurrent socket
threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
(synthetic no-response entries no longer render as 'relay rejected
your message' in app callers); results built by pure associateWith;
shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.
CLI:
- publishGuard: all-transport failure exits 124 as timeout; rejected/1
is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
--help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
shadowing a real npub/NIP-05/hex); stderr color requires a
positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
graperank publish/followers/register no longer silently drop
malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
conversions, all strict; offer/debit --timeout > 3600 rejected with
a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
the marmot reactions harness; printUsage drift (offer pay --with,
profile --clink-offer, search --kind).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.
timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).
Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.
New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:
- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
errors, and silent timeouts now survive as PublishResult(accepted,
message) per relay instead of dying in a debug log. The existing
boolean APIs delegate unchanged. Silent relays are reported as
'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
RawEventSupport.ackFields(ack) is the one canonical projection every
publisher emits: published_to (urls) + rejected_by as
[{relay, reason}] — 'why didn't it post' now answers itself, in
partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
signed an event (feed/search/dm/message list items), 'pubkey' an
identity being described; profile show and outbox add the bech32
npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
(dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
size_bytes); the text renderer drops the fragile bare-'size'
heuristic and colors stderr progress independently of a piped
stdout.
- Error details are sentences everywhere (not bare gids); dead Result
class removed from the quartz publish accessory.
Docs updated (DEVELOPMENT output conventions, README rejected example).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.
- README: sections for search, zap (incl. --with auto-pay), podcast20,
nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
a 'Which chat system?' comparison table; output section rewritten for
the new contract (exit-code derivation, rejected, unknown-flag
errors, -- terminator, per-command --help); layout diagram fixed for
the SQLite default + operator/ + concord.json; the bunker nak-interop
claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
bunker, search; zap partial), rows added for relaygroup/geochat/
concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
documented, no-prompts carve-outs, refreshed architecture tree +
command template (USAGE/route(help=)/rejectUnknown/publishGuard),
SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
mis-spliced marmot row repaired.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Seven end-to-end recipes (own relay + NIP-86 admin, NIP-46 bunker both
directions, Marmot group chat, NIP-60/61 Cashu wallet, NIP-5A nsite
publishing, GrapeRank provider pipeline, scripting patterns) so the
docs teach jobs, not just verbs. Reflects the new contract: per-command
--help, alias resolution in user slots, published_to/rejected errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).
Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Same contract as the A-D sweep, applied to the E-M command families:
- rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args)
- publishGuard on single-event publishes (total rejection -> non-zero)
- USAGE constants + route(help=...) / --help fast-paths; graperank's
full sub-verb set (including the previously undocumented 'followers')
and 'key validate' + the --pw alias are now documented in-binary
- geochat --relay is a strict comma-list (bare positional relays kept);
geochat listen default limit 500 -> 50
- graperank update/probe aliases now print deprecation notes
- git --identifier accepted as alias of --d
- marmot message list defaults to --limit 50 (0 = everything)
- dm-style guards for marmot message send/react/delete and group edits
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Applies the new CLI contract across the A-D command families:
- rejectUnknown() after flag parsing, so typo'd flags fail with bad_args
instead of silently no-oping
- publishGuard on single-event publishes: total relay rejection now
exits non-zero ('rejected') instead of 0
- per-group USAGE constants wired into route(help=...) and --help
fast-paths on flat commands; previously invisible sub-verbs
(blossom media/report, the concord moderation set, the whole cashu
surface, NIP-86 method list) are now documented in-binary
- dm send/send-file parse flags before positionals (flags may appear
anywhere); dm list/await accept a positional USER as alternative to
--peer; dm list caps at 50 by default
- concord create: --relay is canonical (--relays kept as alias)
- cashu receive resume: deprecation warning pointing at 'complete'
- offer/debit: --timeout is seconds (was raw milliseconds)
- Identity.fromBunkerUri now delegates to quartz NostrConnectURI
parseBunker instead of duplicating the URI parsing
BREAKING (--json): unknown flags and malformed numeric/relay/author
flag values now exit 2 where they were previously ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
- Output.error now derives the exit code from the error code
(bad_args -> 2, timeout -> 124, else 1), so every
'return Output.error(...)' site honours the documented contract.
Previously ~225 bad_args sites exited 1 while the docs promised 2,
and two timeout paths (nostrconnect wait, namecoin lookup) exited 1
instead of 124.
- Args: literal '--' ends flag parsing (escape hatch for values that
start with '--'); intFlag/longFlag reject non-numeric values instead
of silently using the default; requireFlag/positional no longer
double-print to stderr; new rejectUnknown() turns typo'd flags into
bad_args failures; new 'help' detection.
- route() understands --help/-h/help (prints group usage, exit 0) and
names the expected verbs on an unknown sub-verb.
- Unknown or missing top-level subcommand now emits a proper bad_args
error (JSON-aware under --json) plus a one-screen verb list instead
of dumping the full 400-line usage.
- RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit
entries that do not parse are now bad_args errors; previously an
unresolvable --author was silently DROPPED and the query ran with a
weaker filter than requested. New shared publishGuard() reports
'rejected' (exit 1) when every relay refuses an event.
- runCli() seam extracted from main() plus an 'amy.home' system-property
override of DEFAULT_ROOT so the new JVM test suite can drive the CLI
in-process; first contract tests: ArgsTest, ExitCodeContractTest,
JsonContractTest (NIP-19 vector goldens).
BREAKING (--json): error code pow_timeout is now timeout; exit codes
for bad-argument failures move from 1 to 2 as documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
Audit fixes for the Blossom client:
- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
matrix so the Main-thread sync collector and IO-thread delete/mirror
actions can't lose each other's writes; add refreshJob de-dup so two
quick refreshes can't interleave; rethrow CancellationException; bound
the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
animation still has content to draw after the state clears.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the
full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD
check → download-and-verify-hash → list → cross-server mirror → delete. Verified
green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which
exercises the whole quartz/commons/CLI Blossom stack over the wire.
Server-side write rejections (whitelists, rate limits, payment) record as SKIP,
not FAIL — only a broken client contract (bad descriptor, hash mismatch,
unparseable list) fails — since public servers gate anonymous writes very
differently. Documented in cli/tests/README.md; state dir gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
Extends Blossom support toward a full client on both the CLI and the mobile app.
Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
`server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders
Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping
CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
and `report` verbs; auth tokens are scoped to --server
Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
BUD-01 HEAD), delete, mirror-to-missing, and report actions
Tests: quartz URL/auth/descriptor/payment parsing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
`amy graperank followers <observer>` (no --relay) assembles the relay universe
via allKnownRelays, which read the reachability cache through ctx.reachability —
and that lazily derives the monitor key from the operator master, forcing a
passphrase prompt (or failing on a fresh machine) purely to READ kind:30166
records. The follower crawl signs nothing, so this defeats its anonymous path.
Read the reachability snapshot with a throwaway signer instead (snapshot() only
reads; the signer is used solely for writes) — the same trick `graperank status`
already uses to stay side-effect-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
crawl and score used Context.open, which requires an account solely to default
the observer to the logged-in user — but neither uses account keys: crawl never
signs, and score signs cards with the machine-level operator key (~/.amy/operator/,
independent of any account). Switch both to Context.openOrAnonymous and require
an explicit OBSERVER when running anonymously, matching `graperank followers`.
A machine acting as a GrapeRank provider for arbitrary observers no longer needs
a personal account (the operator key still needs its SecretStore passphrase, as
before — that's the reachability monitor + card signer, a real secret).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but
fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and
stops paging once it's reached — so the crawl silently capped at ~500 followers
per relay (verified live: relay.damus.io returned exactly 500 for a
many-thousand-follower observer).
Leave the filter limit null by default so pagination walks the whole result set
(the same observer now returns 12,823 followers from damus alone); Config gains
`maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
- private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
(desktop: private bookmarks always empty);
- NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.
Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.
Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].
Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A refounded Concord community (CORD-06 rotates community_root + bumps the
epoch) keeps its pre-refounding messages under the prior epoch's derived
Chat Plane stream key. The client only ever fetches the current epoch, so
older history is invisible and the feed says "All caught up".
Add the diagnostics to reach it:
- `amy concord import` — fetch this account's own kind-13302 list, decrypt
it, and upsert every community WITH its heldRoots (the prior-epoch access
roots Amethyst persists across Refoundings). Decrypts against the account
identity, not signer.pubKey (which for a bunker is the ephemeral transport
key, not the self-encryption peer).
- `amy concord read <community> <channel> --epoch <n> [--root <hex>]` — read
a prior epoch's Chat Plane; the root auto-resolves from the stored
heldRoots when --root is omitted. Output includes the epoch + derived plane.
- StoredCommunity.heldRoots persistence.
Verified live against Soapbox #nostrhub: epoch 0 (a held root) returns 7
messages the app never shows; epoch 2 (current) returns 2 — reproducing the
gap and confirming heldRoots-walking recovers the history.
Design for the in-app fix (walk heldRoots on the read side) lives in
commons/plans/2026-07-17-concord-epoch-walking-backfill.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`amy login bunker://<pubkey>?…` was persisting the pubkey embedded in the
bunker URI as the account identity. That pubkey is the remote-signer /
connection key (Amber, nsec.app, nak all mint a dedicated one), not the
user's identity key — so every "my events" fetch queried the wrong author
(no kind-10002/10050/13302 found, empty timelines).
After saving a provisional identity, connect the bunker and call the
NIP-46 `get_public_key` RPC (already implemented on NostrSignerRemote),
persisting the returned user pubkey as the account identity while keeping
the bunker's remote key in Identity.bunker for RPC addressing. Best-effort:
falls back to the URI pubkey if the bunker can't answer. Mirrors the app's
NostrConnectLoginUseCase, which already stores the verified pubkey.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outbox model can't find an observer's followers — you don't know a
follower exists until you've seen their kind:3, so you can't route to their
outbox first. FollowerCrawler casts a wide net instead: it asks as many relays
as possible for kind:3 lists that #p-tag the observer, paging each relay past
its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier,
keeps only lists that genuinely tag the observer, dedups by id, and
group-commits to the store.
Each follower's list is a full contact list, so persisting it also enriches the
graph a later `graperank score` builds — every follower becomes a FOLLOW edge
into the observer.
CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from
the reachability-cache live set + every kind:10002/30166 relay in the store +
the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no
signing) when given an explicit observer. Tunable via
--relay/--page-limit/--timeout/--relay-concurrency/--insert-batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:
- `followers` — the number of the target's followers whose own score clears a
threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
follow), matching the `hops` field on Brainstorm's ScoreCard.
New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4