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
Until now the CLI bunker auto-approved every request (it hosts the
operator's own key, so the pairing secret was the only gate). That left
two NIP-46 signer behaviors the harness couldn't exercise: a signer that
*rejects* disallowed ops, and a signer that asks a human live.
Add two opt-in gates to both `amy bunker` and `amy bunker connect`:
- `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the
listed ops; anything else is rejected. Fully scriptable/headless. This
is the server-side mirror of the client's `--nostrconnect --perms`, so
an interop run can now test a client against a rejecting signer.
- `--interactive` keeps the bunker listening and prompts `y/N` on the
terminal for any op the policy doesn't already allow, so the operator
approves/rejects each request live. TTY-guarded (errors on a piped
stdin), default-deny, prompts serialized by a mutex because the service
dispatches requests concurrently. Composes with `--perms` (auto-allow
the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable
policy.
Neither flag → unchanged auto-approve behavior. Thin assembly: the perms
parsing and request→op mapping are reused from commons
(`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already
unit-tested there.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
The Amethyst signer now honors a nostrconnect offer's `perms=` for
informed consent, but the CLI client had no way to emit one — so the new
consent flow couldn't be driven from `amy`, the project's interop-test
harness.
Add a `--perms` flag to `amy login --nostrconnect`, threaded into the
offer through quartz's NostrConnectURI (which already builds/parses the
param). Also keep the parsed perms on NostrConnect.Offer and surface a
client's requested perms in `amy bunker connect`, so an operator can see
what an app-side signer would be asked to pre-grant (the CLI bunker still
auto-approves the operator's own key — perms is not a gate there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Give the user visibility into what the signer is doing:
- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
requests (method + kind + client + ok/denied), fed from the service's
onServiced hook (enriched to pass the full BunkerRequest so the event kind
is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
like "Signed an event (kind 1)", green/red status dot, relative time) and a
"Signing as npub1…" line so it's clear which account is the bunker.
onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
NIP-46 lets the remote-signer (transport) key differ from the user's identity
key. Previously the bunker advertised and wrapped everything with the identity
key, so anyone watching the inbox relays could see kind-24133 traffic addressed
to the user's real npub and infer "this npub runs a bunker".
Now each account gets a dedicated, persisted transport keypair:
- NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a
`transportSigner`; BunkerRequestProcessor keeps the identity signer for the
actual sign/encrypt/decrypt and answers get_public_key with the real npub
(disclosed only to a connected client, over the encrypted channel).
- The host mints + persists the transport key lazily (accounts that never
enable the signer mint nothing), advertises it in bunker:// and the
nostrconnect ack, and listens p-tagged to it.
- AccountSettings/LocalPreferences persist nip46TransportKey so the advertised
address stays stable across restarts.
Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55
(Amber) accounts no longer round-trip the external app for envelope crypto —
only the genuine signing request does. A new test asserts get_public_key
returns the identity, never the transport key. Unreleased feature, so no
migration needed. The CLI bunker keeps using the operator's own key (dev tool).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop
with NostrConnectSignerService + BunkerRequestProcessor, and route bunker://
/ nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is
unchanged (the CLI bunker still hosts the operator's own key and auto-approves
every request via a small CliAuthorizer that only checks the pairing secret);
this removes the duplicated protocol logic now that it lives in quartz.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
Adds a thin `amy geochat` verb over the quartz + commons geohash-chat code so the
interop path is exercisable headlessly (and against a real Bitchat client):
`send` builds/signs/mines/publishes a kind-20000 message with the per-geohash
ephemeral identity; `listen` holds a live subscription (kinds 20000/20001 are
ephemeral, so relays broadcast but don't store them) and reports messages plus
distinct present pubkeys; `keys` shows the derived per-geohash pubkey.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172JoMccseEKenyWan6txWV
The rename-echo assertion was flaky: firing two GCE commits (set-image + rename)
3s apart races whitenoise's per-epoch processing, so the second commit sometimes
lands as "unprocessable" and the name never propagates — a false negative.
The interop itself is confirmed working: in a harness run whitenoise's own log shows
`background_sync_group_image_cache_if_needed` parsing amy's image extension and
attempting a Blossom fetch (404 only because the harness sets the image without
uploading a blob), and test_09 (whitenoise messaging in the same group) passes right
after the image commit — both impossible if the image extension had been rejected.
Rewrite test_17 to the robust form: A sets the image, then sends ONE application
message at the post-image epoch; whitenoise can only decrypt it if it applied the
image-bearing commit, so wait_for_message B receiving it is direct proof the extension
parsed. Also skip cleanly when A is no longer a member of the group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
whitenoise-rs became a workspace (core `whitenoise` crate at root, wn/wnd moved to
crates/whitenoise-cli) and grew native --discovery-relays / --default-account-relays
flags, which broke the headless harness's patch/build assumptions.
- Repath the mock-keyring patch to crates/whitenoise-cli/src/bin/wnd.rs and match the
new main() (env-gated Whitenoise::initialize_mock_keyring_store(); the fn is pub
under the integration-tests feature).
- Drop the discovery-env / defaults-env patches: start_daemon now passes the native
--discovery-relays / --default-account-relays flags instead.
- Build wn/wnd via `cargo build -p whitenoise-cli --features whitenoise/integration-tests`
(the binaries left the root crate; integration-tests brings in the mock keyring).
- The skip-unprocessable-retry patch still applies (root crate, 19-line offset).
Enables the end-to-end test_17_group_image_commit (amy sets a group icon -> whitenoise
must still process the commit) added in the previous commit to actually run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Add `amy marmot group set-image <gid> <file> [--server URL]` and
`amy marmot group clear-image <gid>`: encrypt the avatar with the MIP-01 v2 scheme
(interoperable with mdk/whitenoise), optionally push the ciphertext to Blossom signed
by the keypair derived from image_upload_key, and commit the image fields into the
group's NostrGroupData extension. Thin assembly over quartz
(MarmotGroupImageEncryption) + commons (updateGroupMetadata) per the CLI rules.
Add headless interop test_17_group_image_commit: A (amy) sets a group image on a group
whitenoise is a member of, then renames. The rename is a later MLS epoch, so wn can
only observe the new name if it first applied the image-bearing GCE commit — proving
the image extension stays parseable on mdk (which rejects trailing bytes). This is the
end-to-end guard for the group-icon interop fix.
Note: the marmot interop harness currently can't build current whitenoise-rs — its
patches target the pre-restructure `src/bin/wnd.rs` layout, but whitenoise-rs has moved
to a `crates/whitenoise-cli/` workspace. Refreshing those patches is separate harness
maintenance; the interop correctness is otherwise covered by the pinned-mdk source
analysis and the MarmotGroupImageTest wire regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
Two roster gaps against the reference client (Armada):
1. Moderators showed as "Admin" and role definitions were often empty. The
displayed roles came from ConcordCommunityState.roles, which was built from the
RAW structural fold heads — so a rogue higher-version edition on a role's
coordinate (e.g. marking the Admin role deleted) corrupted or emptied the roster,
and even when present the UI collapsed every role-holder to a single "Admin"
badge. Now state.roles comes from the authority-gated resolver
(AuthorityResolver.roles(), exposed alongside rolesFor()), and ConcordMembersScreen
renders each member's actual most-privileged role name (Admin / Moderator / custom).
2. Member count was a fraction of the real one (e.g. 10 vs ~44). CORD-02 §5: "an
author seen publishing is observably present, auto-included even if their Join
never arrived." The roster only counted Guestbook joiners + the privileged roster,
omitting the bulk of members who never post a Join. ConcordCommunitySession now
tracks observedAuthors from every decrypted channel message and folds them into
allMembers() and the roster.
Also: amy's `concord roles/grant/ban/...` now register the control-plane stream key
before draining (like `channels`/`read`/`send` already do), so the mod verbs aren't
served an empty fold on NIP-42-gated relays — used to ground-truth the resolved roles.
Verified via amy against live Soapbox: `concord roles` now returns Admin (pos 1) and
Moderator (pos 2) instead of []. quartz + commons concord suites green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`wot` claimed the whole web-of-trust concept for what is actually a cheap
single-hop metric — the count of your follows who also follow X. The real
computed web of trust is `graperank`. Renamed the command (and WotCommand ->
FofCommand) to `fof`, reframing its KDoc/usage away from "trust," and kept
`wot` as a deprecation alias that warns and points at both `fof` and
`graperank`.
Also documents the command for the first time: `amy wot` had no --help block
and no README row. Added both (help block + three README rows for
get/list/sync), so the follows-of-follows score and its relationship to
graperank are now discoverable. JSON output shape is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Audited every graperank verb/flag in the code against the three doc
surfaces and fixed the drift:
- Main.kt `--help`: rewrote the GrapeRank block to match the terse house
style (one line per command, key flags on continuation lines) — it had
grown to ~68 lines of prose. Now ~33 lines, reordered into pipeline
order (crawl -> score -> publish, then rank/status/refresh, then the
provider/operator discovery group), with the per-verb timeout-semantics
prose dropped (it lives in the KDoc). Verbs and primary flags verified
against the dispatch and each function's arg reads.
- README: added the missing `graperank crawl` and `graperank score` rows
(stage 1 and 2 of the pipeline — crawl had no table row at all, score
was only mentioned inline) and labelled the three pipeline stages.
No stale references remain (no `graperank sync`, no removed publish/
bench flags, `operator providers`/`update` only as documented aliases).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
`graperank sync` is gone — `crawl` is the only spelling. Because the dispatch
treats any non-verb first token as an OBSERVER, this exposed a latent leniency
in the shared resolver: decodePublicKeyAsHexOrNull's fallback runs Hex.decode
without a length check, so a short bech32/hex-ish word like `sync` decodes to a
bogus few-byte "pubkey" instead of failing — turning `graperank sync` into a
silent network crawl over garbage. Context.requireUserHex now requires the
resolved value to be exactly 64 hex chars (a pubkey is always 32 bytes), so a
mistyped OBSERVER/USER on any amy command errors cleanly (exit 2) instead of
silently scoring/fetching against a garbage key. `graperank sync` now returns
`bad_args` rather than crawling.
Also drops the never-built `graperank compare` idea from the roadmap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
- New `graperank status`: read-only local inventory with no network, no
signing, and no side effects — WoT record counts in the store (the "do I
need to crawl again?" answer), reachability-cache size + newest-record
age, operator/service-key state, and persisted card + retraction counts
per observer. Reads the reachability cache through a throwaway signer so
a fresh machine's status never lazily creates the operator master.
- Flag consistency: --relay-concurrency and --concurrency are now accepted
interchangeably on `graperank refresh`, `graperank publish`, and
`relay probe` (each keeps its documented spelling as canonical), and the
help text states what --timeout means per verb (per-REQ drain 10s for
crawl/score, idle watchdog 30s for refresh/publish, per wave 15s for
probe, drain 8s for rank/register/providers).
- `graperank sync` now prints a deprecation warning before running crawl —
the name points at the wrong concept since the negentropy record refresh
became `graperank refresh` — and is removed from the docs; removal comes
in a later release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Concord relays gate a plane's kind-1059 wraps behind NIP-42 and serve them
only to a connection authenticated AS the plane's derived stream key — the
member is neither the wrap's author (the stream key) nor its recipient (a
throwaway ephemeral key), so an account AUTH is refused and every plane REQ
came back empty (no channels, no messages), even though the community folded
its name.
Mirror the app's per-plane AUTH (60475c10c0) in the CLI:
- Context.registerConcordStreamKeys(relays, secrets) records the derived
control/channel stream secrets, scoped to the community's relays.
- The RelayAuthenticator provider now signs one kind-22242 per registered
stream key alongside the account AUTH — locally, from the raw derived key
(NostrSignerSync), never the account, so no user identity is exposed.
- The concord channels/read/send verbs register their control + channel
stream keys before draining/publishing.
- drain() gains pendingOnAuthRequired: an auth-required CLOSED keeps the relay
pending instead of terminal, so the post-auth subscription re-fire delivers
the events rather than the one-shot drain returning empty. The concord verbs
opt in; all other drains are unchanged.
Verified end-to-end against the live Soapbox community (relay.ditto.pub /
relay.dreamith.to): `amy concord channels` now folds the name + 9 channels
consistently and `amy concord read` returns messages. `channels` also emits
the folded icon/banner/description pointers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- `graperank update` -> `graperank refresh`: the verb says what it does
(refresh the WoT record kinds from each author's outbox) and stops
colliding with the sync/crawl naming tangle. `update` stays as an alias.
- New `graperank unregister PROVIDER [--service KIND:TAG] [--relay URL]`:
the missing inverse of `register` — removes matching entries (public +
private) from the account's kind:10040 and re-publishes it; without
narrowing flags every entry for that provider key is dropped.
- `graperank operator providers` -> `operator keys`: it lists the
observer -> service-key map, and the old name collided with
`graperank providers` (the kind:10040 read). `providers` stays as an
alias; the JSON key `providers` becomes `keys` in both the listing and
`operator status` (breaking, matches the rename).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
The census probes the whole known relay universe and feeds the shared NIP-66
reachability cache (kind:30166) that every reachability-aware command reads —
it was never graperank-specific, so it moves from GrapeRankCommand to
RelayCommands as `amy relay probe`. `amy graperank probe` stays as an alias,
and flags, JSON output, and behaviour are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
Every `amy graperank` / `graperank score` run now reconciles its result into
the local event store as NIP-85 kind:30382 cards signed by the per-observer
service key (cutoff --min-rank, default 2): changed ranks are re-signed,
unchanged ones skipped (no event-id churn), dropped targets retracted with a
kind:5 the store applies on insert. The store is the source of truth, so the
score of a run is durable and reusable instead of ephemeral stdout.
New verbs complete the crawl -> score -> publish pipeline:
- `graperank publish [OBSERVER] [--relay URL[,URL...]]` — transport only:
one NIP-77 up-only reconcile per operator relay over the provider key's
kind:30382 + kind:5, converging the relay to the local set (deletions
propagate, lost cards restored, full-set publish fallback for relays
without negentropy); also refreshes the observer's kind:10040 pointer.
- `graperank rank USER [--provider P] [--refresh]` — the consumer side:
newest card per provider from the local store, with a relay drain on miss.
GrapeRankPublisher (quartz) is reworked accordingly: reconcileAndPublish is
replaced by reconcileLocal (sign+insert+retract against the store) and
syncToRelays (NegentropyStoreSync up-only + blastPublish fallback), with a
commonTest covering upsert/skip/retract and re-carding a retracted target.
BREAKING (--json / flags): `--publish`, `--publish-limit`, `--publish-relay`
and `--bench-sign` are removed from the score command. Its JSON drops
published/publish_rejected/deleted/delete_rejected/skipped_unchanged/
publish_truncated/published_kind/published_to/publish_error/observer_10040/
bench_signed/bench_sign_ms and gains provider_pubkey/min_rank/cards_total/
cards_signed/cards_unchanged/cards_retracted/cards_ms. Publishing moved to
the new `graperank publish` verb.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WSzVX9RoUxyV3nT3dfc56
The relay-auth fix added an `interactive` flag to
RelayAuthenticator.signWithAllLoggedInUsers; update the desktop and cli
implementers (which don't prompt) to the 3-arg lambda.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverts the Kover coverage aggregation (introduced in dca345212b) from every
module, the root aggregation block, the Sonar coverage import, the version
catalog, and BUILDING.md. With the plugin present the 16 GB CI runner OOM-killed
during the Android test+build job even with Kover opt-in disabled; removing it
entirely clears the OOM. No other CI changes — the diff versus main is exactly
the inverse of dca345212b.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Apply Kover (Apache-2.0, build-time only) to the KMP/JVM modules and aggregate at the root
- include amethyst playDebug in the aggregated Kover coverage