bba304fec0cc29791b373c40f317f815d0af74d1
755
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bba304fec0 | Merge branch 'mantra' into claude/project-feature-plan-b58ab2 | ||
|
|
f46a7c627d |
test(subgroups): stop a ceremony-queue test throwing into another test's name
`./gradlew :composeApp:jvmTest` failed `NavigationRoutingTest > an account already on
the device does not stay on the loading screen` with `UncaughtExceptionsBeforeTest:
There were uncaught exceptions before the test started`. **"Before the test started" is
the tell.** `runTest` reports exceptions that reached the default handler before it
began, so the name on the failure is whichever `runTest` ran next -- not the one that
threw. `NavigationRoutingTest` passes alone and the subgroups work never touched it.
The thrower is `CeremonyQueueRoutingJvmTest`, and the chain is five links with no
branch in it:
1. `awaiting()` builds a `ChatMessageListViewModel` on the bare
`NO_OP_NOSTR_REPOSITORY` and calls `initiate()`.
2. `initiate()`'s second line is `scheduleSynchronization()`.
3. That launches on `Dispatchers.IO` and asks `MemberProfileSync` who cannot be named.
4. The fixture's `room()` gives every `LocalParticipant` a `profile = null`, so the
answer is never empty and the next line always runs.
5. `queueNegentropySynchronizeRequest` is one of the **forty-one**
`TODO("Not yet implemented")` bodies on that no-op, out of forty-five members. It
throws in a coroutine nobody awaits, four times a run -- once per test in the class.
Fixed the way this file already fixes it for `DkgRepository`: an `object : X by NO_OP_X`
with the one member overridden. The comment above it carries the chain, because the next
person to see `UncaughtExceptionsBeforeTest` will be reading the name of the wrong test.
**Verified under the condition that reproduced it.** It is order-dependent, so a
filtered run is not evidence -- the culprit passes alone, and the pair passed too. The
full suite under `--rerun-tasks` failed reproducibly before and is 982 of 982 after.
**Not fixed here: the landmine itself.** Sixty `TODO()` bodies sit across four no-op
repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on
`MarmotRepository`, 1 of 1 on `SearchRepository` -- and seventeen jvmTest files build
view models on the Nostr one alone, so this will recur; the reporter had already hit it
through `getProfileWithPublicKey`. It is not a sweep, though: "no-op" means something
different for each member, the return types vary from `Unit` to `Flow<Int>`, and a
blanket empty body could make a test pass vacuously where it currently fails honestly.
Sixty judgement calls about shared code do not belong in a commit about one flake, so it
is flagged for its own change.
982 jvm tests, 0 failed, under `--rerun-tasks`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1a6034dadb | Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4 | ||
|
|
9be3e01d8b |
fix(subgroups): hand an adoption's outcome back on the main thread
"Add a parent group", then tapping any group, crashed outright:
IllegalStateException: Method setCurrentState must be called on the main thread
at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded
...
at NavController.popBackStack
at SelectAdoptingGroupScreen.kt:153
at SelectAdoptingGroupViewModel$offerTo$1.invokeSuspend
`offerTo` does its work in `viewModelScope.launch(Dispatchers.IO)` and called
`onDone` from inside it. The screen's callback called `onNavigateBack()` straight
through, which is `navController.popBackStack()` -- and navigation moves a back stack
entry's lifecycle, which is main-thread-only. Nothing in the chain switched.
**The dispatcher is the view model's business, not every caller's**, which is the
shape the app already uses in a dozen places and in both of the files this one was
modelled on: `SelectSubgroupAdminsViewModel.confirm` wraps its navigation in
`withContext(Dispatchers.Main)` and `AddArtifactViewModel` launches its `onSuccess`
there. A `finish` helper does it here, with the crash written above it so the next
person to add a callback to this class sees why. `ChatRoomDetailViewModel.acceptParent`
gets the same treatment: its only caller shows a snackbar, which happens to be
main-safe already, but a callback contract that is "safe if you are careful" is the
same bug waiting for a second caller.
**And the screen no longer navigates away on success**, which fixes a second thing
found on the way to the first. The snackbar was launched in
`rememberCoroutineScope()` -- this screen's own -- and then the screen popped, so the
message was racing its own scope's cancellation; `rememberNotifier` exists for exactly
that hazard and was not being used. Staying put is the better answer anyway, because
the list is the confirmation: the row just asked leaves it, since a certificate now
stands for the pair and `refuseAdoption` says so. The way out is the back button, which
a pushed screen has to have regardless. `offerTo` reloads before handing back so that
is true by the time the message appears.
The outer `scope.launch` around `offerTo` goes too -- it never suspended, and wrapping
a main-thread call in a launch was part of what made the threading hard to see.
No test covers this and none of the 1,416 would have. It is a dispatcher boundary
between a view model and a navigation callback, which is to say it lives in the one
layer this work had never run: compile-and-test verified, never driven through the app.
That was named as the risk when the feature landed, and this is it.
454 common tests, 962 jvm tests, `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3cd9001c27 |
Merge branch 'mantra' into claude/project-feature-plan-b58ab2
Eighteen commits of subgroup adoption and key-ceremony work, and one collision
that was the point of writing Phase 0 down.
**Schema.** `mantra` took 21 for `GroupKeyStateParent` -- a room's parentage as a
row, so it can have two -- as a manual `MIGRATION_20_21` that copies before it
drops. Projects had taken 21 on this branch. Neither had shipped, so projects
renumbered to **22**, an `AutoMigration(from = 21, to = 22)` on top of theirs;
`schemas/21.json` is now theirs and `22.json` is the two together, 58 tables.
docs/project-feature.md records it, including that Phase 0 told us to check for
exactly this and checked the sibling fork rather than the base branch.
Three conflicts. `MantraDatabase.kt` wanted both entity sets and both
migrations, and their v21 contributes only a comment there since the entry is
manual. `ProposedEventTest.kt` and the schema file were additive either way.
Nine files auto-merged, and `ChatMessage.kt` was the one to watch: they reshaped
the `SubgroupBirthCertificateEvent` arm while this branch added three project
arms to the same `when`. Git saw no conflict and the compiler agrees.
**One test fails, and it arrives with the merge rather than from it.**
`NavigationRoutingTest` reports "uncaught exceptions before the test started",
which is leakage, not its own failure: `CeremonyQueueRoutingJvmTest` (new in
|
||
|
|
93f8ab313c |
test(projects): cover the three view models and the query nothing asked about
The feature's logic was tested from the events down to the chronicle and not at all above the repository. Four gaps, each one something no other layer would catch. **AddProjectViewModel.** A project and its contents go out as one batch whose memberships carry the project's id -- which is a hash over the group's key at the room's path and does not exist until the proposal is made, so the screen hands over a function of the signed lead. Nothing downstream notices if that function returns the wrong thing: a membership naming another project is a valid event, signs cleanly, and lands as a row pointing elsewhere. Eight cases assert the batch's shape against a recording repository that signs the lead the way a finished session does, including that the creator is the member filling the form in -- there is no field for it and nothing else to check it against. **AddToProjectViewModel.** The candidate filtering is the only thing stopping a duplicate membership, because a duplicate is a valid event with its own id: the keys are satisfied, the signature is good, the row gets written. A wrong filter would spend a quorum saying what the group has already said and look like the feature working. Ten cases, including that the two halves filter independently. **FrostSigningViewModel's resolver.** Unresolved, the signing screen asks a member to agree to "a4f1be55d0c3". Seven cases through the same `summarize` call the screen makes: a membership reads as the artifact and label, a stranger falls back to a hash rather than an invented name, the room owning its own work says nothing, and a project being signed in the same batch resolves to nothing -- which is why a membership says the version first. **getProjectsByTranslationArtifactVersionId** had no test at any layer while its artifact-side twin had one from the start. That asymmetry is what a copied join hides. Two notes for whoever writes the next view-model test. These view models hold state in `mutableStateOf` and initiate on `Dispatchers.IO`, so there is nothing to collect and the call returns before the work is done -- hence `awaitValue`, polling in real time for the same reason `NavigationIdentityRoutingJvmTest` does. And `NostrRepository.NO_OP_NOSTR_REPOSITORY.getProfileWithPublicKey` is a `TODO()`, so a resolver that asks it about anybody throws on a background dispatcher and surfaces as a timeout rather than a failure anybody can read; these read profiles from the seeded database instead, which also gives the honest answer for a stranger. 983 jvmTest, 447 testDebugUnitTest, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b6c3e3ddc0 |
test(subgroups): pin what addresses is worth, and when one ceremony is the whole question
Two surfaces the last three commits added and left resting on indirect coverage. Both are decisions rather than plumbing, and neither had a test that would notice it changing. **`SubgroupBirthCertificateEvent.addresses` had none at all.** Its sibling `certifies` has nineteen, several of them real FROST forgeries, because a certificate is the whole of what a subgroup relationship is. `addresses` was then added *beside* it as a deliberately weaker reading -- no derivation, no signature -- and exercised only through a view model, which is the wrong place to learn what it is worth. The invariant worth having is the one a future reader can get wrong: **an event can pass `addresses` and fail `certifies`.** Two cases say so in the two ways it happens -- an unsigned proposal, which is exactly what the ceremony screen holds and asks about, and a forgery authored and signed by the child's own quorum, which is anybody at all. A third asserts `certifies` implies `addresses`, so the weaker one cannot drift into being the stricter one while both are called "which child is this about". The other two are the case the predicate exists for: two subgroups of one parent. A sibling's well-formed proposal is not addressed to this child, *and is* addressed to the sibling -- told apart rather than both refused, which a predicate that merely failed closed would also satisfy. Checked by giving `addresses` a signature check: the two "and certifies nothing" cases fail, the other three do not. **`hidesOtherCeremonies` had none either**, and it decides where a tap in the transcript goes. `CeremonyQueueRoutingJvmTest` pins both of its guards separately, which is the point of four cases rather than two: - one ceremony owed -> open it. Drop the `size > 1` and this fails. - two owed -> the list, from either tap, because neither is the whole of what the room is waiting on this member for. - a *finished* ceremony's line -> open it, even while two others are owed. This is the restraint, and dropping the "is itself awaiting" clause is what it catches: a reader tapping history is asking to see history, not to be handed somebody else's queue. - an abandoned one is never owed, even when `pendingApproval` says it is. Each regression was reinstated separately and each is caught by exactly one case. One thing the empty expectation needed care over, and it is the same trap a test in `26d305bc` fell into: a watcher that writes once cannot be waited for by its own empty value, because empty is indistinguishable from not having run. The abandoned case waits on a clock instead and says so. **`hidesOtherDecisions`, the proposal counterpart this mirrors, still has no test.** It predates all of this and is left alone, but the asymmetry now runs the other way and is worth knowing about. **Still untested, and deliberately so rather than overlooked:** the three entry points into the ceremony list -- the "Key ceremonies" row on the group's details, which exists to be reachable in a *Marmot* room where "Shared key" is not, the standing notice handing back null for several, and the transcript line's routing. Those are desktop compose-ui tests over `ChatRoomDetailScreen` and `ChatTranscript`, both of which stand up considerably more than anything here does. 982 jvm tests, 459 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
26d305bc14 |
fix(subgroups): a parent's second subgroup can be certified, and sign its own key state
Both rungs after a subgroup's ceremony run in the **parent's** room. The birth certificate is the parent's signature, so it is signed where the parent's key is; the key state is signed over the parent's room because the room it is *about* does not exist yet -- that is the whole point of signing it first. So a parent making its second subgroup has two certificate signings and two key-state signings in one room, and the ceremony screen read them out by event kind alone. `observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind match is "the newest of that kind in this room" -- which is some other subgroup's. The screen for one child followed another child's progress. **The display was not the damage.** Both rungs disable their button while a signing of that kind is running and un-failed: ```kotlin enabled = … && (certificateSession == null || certificateFailed) ``` and a *completed* signing is un-failed forever. So once a parent had certified one subgroup, it could never be asked to certify another: the rung sat there disabled with no way out, and `proposeBirthCertificate` returned early on the same reading. The key state blocked the same way, and `createAdminGroup` is gated behind it, so the second subgroup could not be made at all. **It did not need two subgroups.** Adoption signs a key state in the child's own room (`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the first subgroup it tries to make. What makes this a half-finished job rather than an oversight is that the *signed* facts were already right. `isCertified` goes through `observeBirthCertificate`, scoped by session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own comment records this exact lesson being learned once already: "A ceremony room holds more than one ceremony the moment a subgroup's admins are the whole group." The in-flight lookups never got the same treatment, and the comment above the key-state watcher still asserted the premise that had stopped being true -- "the room a ceremony runs in signs nothing else". **The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent` carries `FrostKeyTag` from the moment the proposal is assembled, so `parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s null branch now clears `keyStateSession` too, which it could not before because the field was not its to clear. **The certificate is matched on the child it names, through a new `addresses`.** `certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real signature check by the parent's quorum, and the thing being matched here is a proposal that quorum has not signed yet -- that is what the rung is waiting for. `SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d` tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says outright that nothing may decide a lineage on it. The three values it reads come out of `assembleTags` together, so they agree by construction rather than by agreement. Correctness is not at stake in either: these two fields only decide which progress bar is drawn and which button is live. Whether the parent actually agreed is `isCertified`, which does check the signature. **`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline. Three callers now, one of them new, and the no-op repository answers null -- which is also the honest answer for a ceremony that has not finished, and the reason all three resolve it per emission rather than once. **Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug reinstated rather than only against the fix.** Two assert that the screen follows its own ceremony's signing when a sibling's is newer. The third is the permanent block, and it is driven in two emissions on a hot flow for a reason worth recording: written as a single emission it asserted "still null" and **passed against the bug**, because at that moment the watcher had not started. Nothing is proved by a field that is null before anybody has looked at it. **One test from the commit before this had to be rewritten, and the change is this commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving by parking the session watcher inside `pendingApproval` while the key-state watcher wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the session collector, after the park -- so the setup could not happen. The claim was untouched by that, so it is now made against the session watcher versus the room watcher, which are genuinely concurrent: both collect before either writes, neither waits on the other, and the field they contend over is `roomHasCeremony`, which is the one that decides whether the offer to start appears. Re-confirmed failing against the lost update reinstated. 973 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2fb8d6921b |
fix(projects): let a membership's foreign key violation out, so the sweep retries
A project's memberships are foreign keys into both the project and the version they name, so a chronicle page carrying a membership whose project is in a *later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly this: it catches the violation, counts the payload `failed`, and `sweep` re-runs every page until failures stop falling. Phase 3 broke that for projects by catching the violation inside the apply arm. `applyInnerEvent` then returned normally, `applyPage` counted the payload applied, the sweep saw nothing left to do, and the membership was dropped -- silently, permanently, on the device a chronicle exists for. The catch was added so that `FrostSigningManager.applySignedEvent` would not report a succeeded session as failed. That reasoning was already satisfied: `applySignedEvent` wraps the call in its own try/catch. And no sibling arm catches -- a translation chapter naming an absent chapter throws today -- so the catch made projects the only kind a chronicle could not retry. It is gone. Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a boundary falling between a project and its memberships is the ordinary case for any room with a chapter's worth of chunks in it. `pages arriving out of order still converge` already modelled this -- one page per payload, delivered backwards, comparing every row count on both devices -- and passed anyway, because `rowCounts` did not look at the project tables. It does now, which is what makes that test the one that would have caught this. `ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the retry in miniature: refused while the project is missing, accepted once it is there, same event both times. 1404 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3668b39575 |
feat(projects): add to an existing project, and names instead of hashes
Phase 7c of docs/project-feature.md, and the last of the phases this plan builds. 8 and 9 -- removal, and projects that span rooms -- stay deferred. The resolver comes first because the screen depends on it. A membership event carries two 64-character ids and nothing else, so proposing one on its own means every other member is shown "sign a4f1be55d0c3" and asked to agree. That is not a question anybody can answer, and the plan said not to ship this screen without the fix. So `FrostSigningViewModel` resolves what the proposed events refer to -- the profile behind a creator or an owner, the artifact and label behind a version, the name behind a project -- and hands `summarize` a lookup. Synchronous, because two of the four call sites are composables and cannot await a database; pre-resolved in the view model for the same reason. Only what this device already holds: a key with no profile and an id with no row are left out and fall back to a short hash, which is truthful rather than a gap. A project's own id still resolves to nothing while it is being proposed, since nothing has applied it yet. That is why a membership's summary says the version first. `AddToProjectScreen` is the simpler half of founding a project: the project exists, so its id is known and there is no lead and no batch -- one event, one session. Candidates are filtered to what the project does not already hold rather than shown and refused, because a duplicate membership is a second event with its own id and nothing downstream would stop it; it would just cost a quorum to say what the group has already said. One at a time, by radio button rather than checkboxes. A quorum per thing added is the grain this app signs at, and a multi-select here would quietly be a batch whose items a member has to weigh separately anyway. M3 budgets met. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a14ea3d9d3 |
feat(dkg): every key ceremony a room holds, pending first
A room holds more than one ceremony as a matter of course, and has done since a subgroup's ChillDKG moved into its parent's Marmot room: the parent's own, long finished, plus one for every subgroup the group has ever made. Nothing in the app could name the second of them. `DkgRitualRoute` without a session id shows the room's *newest*, which is routinely the finished one with the subgroup's request buried underneath it. The group's details reached only that. The transcript could name one, by scrolling to the line it was about -- which for a subgroup means scrolling past whatever else the room has been used for, and is impossible for a ceremony whose line never arrived. **`DkgRitualListScreen`, deliberately the same shape as `ProposalListScreen`.** One row per ceremony off `observeSessionsForChatRoom`, split into "waiting for you" and "everything else". Split rather than sorted, for the reason `ProposalListUIState` gives: the rest of the list is what the room has done, these are what it is waiting on the reader for, and they are a different kind of thing to read. The finished ones are kept, because how this group came to hold the keys it holds is the other half of what the screen is for. **What "waiting on you" means is two things, and they are owed by different people.** An approval gate this member cannot delegate, and a finished ceremony whose room only its coordinator can create -- a ceremony reaching COMPLETE finishes nothing on its own, and nothing else in the app would tell the coordinator so. Both are asked of `DkgRepository` rather than re-derived, the same readings the transcript's standing notice and the ceremony screen make, so the three cannot disagree. An abandoned ceremony is asked neither: there is no step left to approve and no key to build a room from, and a row claiming otherwise would send a member to a screen with nothing on it. **A row says what the key is for before it says where it has got to**, because a room holding two holds them for different groups and a row that only reported progress would leave a member guessing which. A subgroup is named by its proposal, so there is a third branch for the member who arrives before that name has -- it still says which of the two kinds of key this is. The quorum is on the row too: a ceremony fixes it and it cannot be changed afterwards. **Three entry points, two of which were gaps.** `ChatRoomDetailScreen` gets a "Key ceremonies" row in *every* room, including the Marmot ones the `mlsGroupState` check keeps "Shared key" out of. The two are different questions and both are kept: "Shared key" offers a room a key of its own, which only a NIP-17 room can want; this lists the ceremonies a room *holds*. A parent's Marmot room has never had a door to its subgroups' ceremonies at all. The transcript's `CeremoniesAwaitingYouNotice` says "3 key ceremonies need you" and then opened `ceremonies.first()` -- answering a different question from the one it asked, and leaving the other two exactly as hard to find as before. It now opens the list whenever it stands for more than one, which is what the signing notice beside it already did. A ceremony line in the transcript routes to the list when it cannot name a session -- a line written before chat rows carried one -- or when the ceremony it names is not the only one waiting on this member. `hidesOtherCeremonies` mirrors `hidesOtherDecisions` exactly, including its restraint: only for a ceremony that is itself waiting on them, because a line about a finished one is history and a reader who taps it is asking to see that, not to be handed a queue. **An empty list offers the way out of itself**, rather than ending in a blank page that sends a member back to the group's details to find the other door. It only offers to ask; the ceremony screen still decides whether the group is in a position to hold one. `DkgRitualListViewModel` is a `MutableStateFlow` from the start rather than a `by mutableStateOf`, for the reason the commit before this one found. **Tests, 6 cases in 2 files.** `DkgRitualListViewModelJvmTest` covers the three decisions nothing else can check: that a ceremony wanting something is at the top even when its date is not, that only the coordinator is told to finish setting up a finished one, and that an abandoned ceremony is waiting on nobody even when both lookups say it is. `DkgRitualListScreenJvmTest` renders it and checks the thing that is only true once drawn -- that a row opens the ceremony it names rather than the room's newest, which is the whole reason the screen exists -- plus the two headings and the empty state's offer. **Not done.** Nobody has looked at this screen running; the render tests prove it composes, sections and routes, which is not the same thing. 970 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4e6ca0a080 |
fix(dkg): stop the key ceremony screen offering to start the ceremony it is showing
"Start key ceremony" is not a button, it is the whole no-ceremony screen -- the
explainer, the quorum picker and the FAB together -- so anything that leaves
`DkgRitualUIState.Loaded` saying there is no session puts that offer in front of a
member watching one run. Two unrelated things did, and they fail differently: one
flashes, one sticks.
**The state went up before the rows were read.** `initiate()` published a bare
`Loaded(localChatRoom)` -- session null, `roomHasCeremony` false -- and *then* started
the two flows that answer both questions. A cold open is the only time those queries
are slow, and it is exactly when a member arrives from the transcript's notice, so the
offer was drawn over a running ceremony for as long as they took. Both answers are a
row lookup, so they are now read with `first()` before anything is published and the
state goes up already correct. The flows still run; they only keep it current.
**Seven flows were folding into one `var ... by mutableStateOf`.** The ceremony, the
room's other ceremonies, the ceremony's messages, the key state, whether the key state
is signed, the certificate and the certificate's signing -- each of them a read, a
`copy` and an assignment, every one emitting on Room's own executor. Two emissions
arriving together silently dropped one: whichever read first wrote last, over the top
of the other's change.
The field that went missing most visibly was `session`, copied back to null by a
watcher that had read the state before the ceremony landed in it. That is not a flash.
The session flow only re-emits when the `DkgSession` table changes, and a ceremony
waiting on a member who has not opened the app changes nothing, so the screen sat there
offering to start the ceremony it was showing.
`MutableStateFlow` with every write through `update { }` -- a compare-and-set that
retries on a loss, so a change can only ever be folded into the state that is current.
The one rule it imposes is that the block re-runs, so it must be pure: `pendingApproval`
is a suspending database lookup that used to sit *inside* the `copy`, and it now
happens above the call. `updateLoaded` carries the `as? Loaded ?: return` that all
twenty call sites used to write by hand.
**Two knock-ons.** `canStartRitual()` and `quorumRange()` were read from the composable
and are now passed the state, because reading a flow's `.value` inside composition
observes nothing -- the offer would have been decided once and never revised. Passing
the rendered state is also more correct while `ScreenStateTransition` still holds the
outgoing one. `DkgRitualScreen` and `DkgApprovalScaffold` collect with
`collectAsState()`.
**The button itself is right and stays.** Creating a robust group already opens the
ceremony -- `SelectChatRoomTypeViewModel.createNip17ChatRoom` calls `proposeRitual`
immediately after `createNip17ChatRoom` -- and this screen's offer is the deliberate
fallback for the case where that failed (`keyCeremonyNotStarted`) and for rooms created
before it existed. What was broken is when it appeared, not that it exists.
**Tests.** `DkgRitualStartOfferJvmTest`, two cases, and each was checked against its own
regression reinstated rather than only against the fix. The first asserts the *first*
`Loaded` already knows the room is running a ceremony, with a deliberately slow query so
the window is proved gone rather than hidden. The second forces the lost update to
happen rather than racing into it: the session watcher is parked inside `pendingApproval`
while the key-state watcher writes, and the write that follows must not carry the stale
copy over it.
**Not done.** `HomeViewModel`, `ChatMessageListViewModel`, `ProposalListViewModel` and
others still declare `by mutableStateOf` in a view model constructor. They are only at
risk where more than one flow folds into one state object; none was audited here.
964 jvm tests, 454 common tests, `m3Audit` meeting every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
54249e3c4f |
feat(projects): a member can ask the group to sign a project into being
Phase 7b of docs/project-feature.md -- the write path. The room's "Create project" button now opens a form instead of `ImplementationPendingRoute`. Nothing is created here. What goes out is a proposal, and the project appears on every member's device at once -- authored by the room's own key -- when enough members sign. That is the difference from submitting one: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social; a signature is the group saying it, and it takes a quorum to say. One session over the project and its contents. The memberships cannot be built by this screen, because each carries the project's id and that is a hash over the group's key at the room's derivation path -- it does not exist until the proposal is made. So the project leads and the memberships are derived from it, the same shape `AddArtifactViewModel` uses for an artifact and its first version. The creator is not a field: it is whoever is filling the form in, the view model already has it, and a picker would only let somebody credit the wrong person for something the group is about to sign. The owners are a field, pre-filled with this group. Most projects never touch it and that is why it is pre-filled rather than hidden -- a group making something for somebody else needs the question asked, and a group that is not needs to see the answer it is giving. Two guards, both because the alternative is silent: an entry that is not 64 characters is refused on the way in, since `ContributorTag.parse` would drop it and the owner would vanish between the form and the signature; and the last owner cannot be removed, since `ProjectEvent.build` refuses an empty set and a button that throws is worse than one that is off. A project with no contents is allowed, unlike an artifact with no version. An artifact with no version is inert because nothing can attach to it; an empty project is a name waiting for work. M3 budgets met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3912631b2e |
feat(projects): a room lists its projects, and one can be opened
Phase 7a of docs/project-feature.md -- the read path. The write path and the
screen for adding to an existing project follow.
The room's project section was already there as a stub: a heading, a permanent
"No projects exists in this group", and a button to
`ImplementationPendingRoute("Add new project")`, under a `// TODO: Add
projects...`. That is what this replaces, so the strings it was already using
are reused rather than added beside near-identical ones -- which is also how
the duplicate `projects` entry I first wrote got caught.
`ProjectDetailScreen` shows the contents and then who the project belongs to.
The second half is the point of the screen as much as the first: the creator
and the owners are claims the group put its signature to, nobody else checks
them, and the signed event is the only place they exist. So they are read here
after the fact, resolved to profiles in the view model -- a composable cannot
await a database, and these are a handful of keys read together.
An owner that is the room's own key reads "This group" rather than printing the
room id back at a member standing inside it, and sorts first, so the ordinary
single-owner case is one unremarkable line and an outside owner is the one that
stands out.
M3 budgets met: spacing off the scale, strings in the catalogue in sentence
case, `NavigateBackButton` on a pushed screen, `readableContent()` on the
content root, all four states through `ScreenStateTransition`, every icon with
a decided description.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9bd4216867 |
feat(projects): the repository reads a project and what is in it
Phase 6 of docs/project-feature.md. Six methods on `MantraRepository`, with `DatabaseMantraRepository` behind them and `NO_OP` returning empty for previews. The two contents queries return the versions rather than the join rows. No screen wants a join row, and the DAO resolves it in one indexed query instead of leaving an N+1 in a view model. Both inverses are here too -- which projects a version is part of -- because an artifact's own screen is where "what is this for" gets asked. Nothing in `MantraDao`. That path -- `addDialect`, `addArtifactVersion`, `rumorOf`, `submitToGroup` -- is the old submit flow, reached now only from its own test, and every entity a member creates is proposed for signing instead. An `addProject` there would be a second way to create a project that bypasses the quorum, which is the thing this feature is for. No query by creator or owner. Both are columns and a screen that wants them has the row; `getProjectsOwnedBy` would be a query with one caller and an index to go with it, and a room's project list is short. Add it when a screen asks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5fac2fc7b7 |
feat(projects): a late member is told what the room's work is for
Phase 5 of docs/project-feature.md. Three kinds appended to `ChronicleEvent.APPLY_ORDER`, which is all the mechanism needs: `CHRONICLABLE_KINDS`, `applyRank`, `inApplyOrder` and `isChroniclable` all derive from that one list, and `signedEventsOf` already filters on it. Appending is not arbitrary. A membership is a foreign key into both a project and a version, so it has to apply after everything it can name, and putting the three on the end gets that right by construction -- everything a project groups is already above it. A page in the wrong order is a refused insert on the one device with nothing to fall back on. Then four existing tests failed, and each was telling the truth. Three of them compare what a seeded room chronicles against CHRONICLABLE_KINDS itself, so a kind added to the set and not to the seed fails them. That is the point of writing them that way, so the project went into the canonical seed in both chronicle suites rather than into a helper beside them. The fourth is the one worth reading. `every chroniclable kind has a round-trip test above` asserts that every kind a chronicle carries can also be rebuilt from its row -- and projects deliberately cannot, because the rebuild exists for rooms older than schema v13 and projects arrive at v21. So the meta-test now excludes them explicitly, and the gap is pinned where it actually bites: `a project is only ever chronicled off the record` holds a project whose `GroupSignedEvent` is missing and shows the row is there and the chronicle is not. That is reachable -- `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed -- and it is worth knowing is possible rather than worth preventing. `the rebuild alone reaches everything but the projects` pins the other side, so anyone adding a `toProjectEvent` fails a test that tells them where the comment explaining why is. Also corrected a stale count in the allowlist's own comment: it said six of thirteen nip30303 kinds reach a signing session while the list below it had eight. Eleven of sixteen now, with `SubmissionEvent` named among the exclusions since it is the envelope rather than the work. 1403 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9430396e5a |
feat(projects): say what a project is to the member being asked to sign it
Phase 4 of docs/project-feature.md. Three branches in `ProposedEvent.summarize`, or members would be asked to sign "Event of kind 30330". The project's branch shows the creator, and that is not decoration. The creator tag is the group attesting that X started this; the creator signs nothing, the room does, so a member could propose a project crediting somebody else and a quorum that does not read would sign it. Nothing checks this and nothing can -- the only mitigation is that the claim is in front of the person agreeing to it. `summarize` gains two optional parameters. `chatRoomId` is read by one branch and only to leave something out: a project owned by the room it is in is the ordinary case, and saying "owned by this group" every time is how a member learns to stop reading the line that matters when somebody else is on it. All four call sites already had the room, so this is threaded rather than plumbed -- including through `WhatIsBeingSigned`, which took the events and not the state. `resolve` is the seam for turning keys and ids into names, synchronous because two of the four call sites are composables and cannot await a database. It defaults to resolving nothing, which is honest rather than finished: a membership event carries two 64-character ids, so without it the summary is a truncated hash. That is tolerable in the batch that founds a project -- the project's own line says what is happening and the memberships read as its continuation, and the project id is unresolvable there anyway since nothing has applied it yet. It is not tolerable for a membership proposed on its own, which is Phase 7's second screen, and that is where the resolver gets filled in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e97735e47 |
feat(projects): apply a signed project, and refuse the three ways it can lie
Phase 3 of docs/project-feature.md. Three arms in `ChatMessage.applyInnerEvent`, which is where every nip30303 kind lands whether it arrived over the wire or was signed locally -- `FrostSigningManager` reuses this dispatch rather than repeating it. A project can now become a row; nothing proposes one yet. The project writes a chat line naming the project and not its creator. On a group-signed row `senderPublicKey` is the session's coordinator, so "X started the project" would be the transcript guessing at something the event states exactly. The memberships write no line, the way a version writes none beside its artifact: they arrive as later items of the batch that founds the project, which has already said so, and one added later is covered by the "The group signed ..." line `FrostSigningManager.announce` already writes. `fromProjectEvent` refuses an event with no name, no creator or no owners. The third refusal is the one worth having a test for and it is not about malformed events at all: it is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES` looks like from here. The role parses as null, `owners()` comes back empty, the signature still verifies and nothing is logged, so the refusal is the alarm. `ProjectApplyJvmTest` reproduces it with a role deliberately not in the set. A membership whose version or project this device does not hold is refused by the foreign key, and the refusal is caught here rather than left to escape. `applySignedEvent` swallows throwables because the signature is valid either way, so an escaping one would report a session that succeeded as a session that failed and tell the group to abandon a ceremony whose signature is good. No call path wraps `applyInnerEvent` in a transaction -- checked, all three -- so catching it does not poison one. 1381 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7afda9cf80 |
feat(projects): three tables, schema 20 -> 21
Phase 2 of docs/project-feature.md. Still nothing that writes a row: this is the shape, the keys and the queries each screen will want. A project's contents are real foreign keys into this room's own rows, with CASCADE, like every reference in this family. That is the one-room scope decision made load-bearing rather than documented: a membership naming a version this device does not hold fails at insert, loudly, instead of landing as a row pointing at nothing that every later read has to be defensive about. Projects that span rooms take those keys away again -- Phase 9, following `parentChatRoomId`, which is deliberately not a foreign key because a dangling value is the normal state there -- and that is a migration rather than something to carry unused until then. `MantraProjectDaoJvmTest` asserts the refusal, which is also what says Room has foreign keys switched on at all. The owners are a comma-joined sorted string with an `ownerPublicKeySet()` accessor beside it, which is what `DkgSession.participantPublicKeys` already does with a set of keys, sort included and for the same reason. A fourth table would buy querying by owner, which no screen does. A set stored as text is a shape that rots, so the accessor is tested against spacing, empty entries and the single-entry case rather than the happy path. `fromProjectEvent` refuses an event with no name, no creator or no owners rather than filling the gap from local knowledge -- which is exactly the failure the tags exist to prevent. The empty-owner refusal does double duty: an owner list that comes back empty is what `ROLE_OWNER` missing from `KNOWN_ROLES` looks like from here, so the guard against a malformed event is also the alarm for that bug. No `toProjectEvent()`. Those eight rebuilds serve rooms whose work predates `GroupSignedEvent` at v13; projects arrive at v21, so every project this app will ever hold is on file as a signed event. Adding three more would be adding to a mechanism with a removal checklist already written for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2d0a5ce50f |
feat(projects): three kinds, a creator tag, and the owner role the parser needs
Phase 1 of docs/project-feature.md. Nothing emits these yet -- no screen, no repository, no apply arm -- so this is three kinds the app can build and parse and nothing else. The kinds are 30330-30332, not the next free number in the nip30303 band. That band is not merely full: 30310, 30311 and 30312 are double-booked with the ceremony family, survivable only because none of the three nip30303 kinds has a producer. 30317-30319 is the ceremony family's growth room. So projects start after the highest kind in use. A project names people two ways because they are two shapes. One creator, so `CreatorTag` -- a single-value tag like every other field here, and multi-character on purpose: NIP-01 indexes single-letter names, so a `p` tag is an address, and a broadcast project should carry its creator as data rather than as a recipient. Several owners, so `ContributorTag` with `ROLE_OWNER`, which is what that tag is for. `ROLE_OWNER` goes into `KNOWN_ROLES` in this same commit, which is the whole reason that line is first. `ContributorTag.parse` drops a role it does not know -- rightly, since a stale relay hint would otherwise render as a role -- and the consequence for an unregistered one is that the tag parses, the role comes back null, the string stays inside the signed event id, the event still verifies, and the project lands owned by nobody. Nothing is logged. `ProjectEventTest` has the three-line test that would say so. Two other things that fail quietly, both in `ProjectEvent.build`. `addUniqueValueIfNew` rather than `addUnique`, because `addUnique` keeps one tag per *name* and three owners would come out as one. And sorted, because the id is a hash over the tags as emitted and a multi-select hands its selection over in no particular order -- the same reason `DkgSession.participantPublicKeys` sorts. A membership is its own event rather than a line in a list the project carries. The only list-shaped kinds in this family are the three contributor lists, which are precisely the ones nothing applies; and one quorum per decision is the grain this app signs at, which a replaceable list would lose. `membershipsOf` mirrors `ArtifactVersionEvent.initialVersionOf`: built from the project *after* the session has authored it, since the id it carries is a hash over the group's key at the room's path and no screen can know it beforehand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e15d31b832 |
feat(subgroups): ask a group to adopt this one, and accept the offer when it comes
Phase 5 of docs/subgroups-multiple-parents.md, the UI. Phases 1-4 were dark; this is the flag, and it is the button. A group can now be given a second parent from the app. **Two affordances, on the child's own detail screen, under the parent cards.** Both belong there and not on the adopting group's screen: the child is what acquires the relationship, its quorum is what decides, and it mirrors the parent screen's existing "add subgroup". `SelectAdoptingGroupScreen` is the only new screen, because the acceptance needs none -- by then there is something to accept and a card does it. Its list is the **profile's own rooms** and cannot be anything else: a certificate is proposed inside the adopting group, so a candidate has to be a room the key in the chair can act in. `adoptingCandidatesFor` filters through `refuseAdoption` itself rather than through a copy of its rules, so screen and manager cannot drift. An empty list is a fact about this member rather than a failure, and the empty state says the reason outright -- a group cannot be offered as a parent by somebody who is not in it, so the remedy is a person rather than a button. **Tapping a row asks; it does not adopt, and the words have to carry that.** The snackbar says "asked that group to adopt this one", and the screen hands straight back to the room rather than going anywhere new. The offer card then says a group is a subgroup of another *only once its own quorum says so*, because rendered as a fact about the lineage it would be the consent gap reopened from the other end -- the one Phase 3 closed in the transcript. **The accept button is offered to whoever opens the screen and can act**, not only to the member who asked, for the reason docs/subgroups.md gives for the ritual screen's rung: every step after a ceremony is open to any admin holding a share. After it, the screen reloads rather than crossing the offer off -- the offer leaves the list when the state is signed and filed, which is a quorum away, so the card stays until the group has actually agreed. `canAddParent` wants a key state as well as a share, which `canAddSubgroup` does not: the certificate carries this group's threshold key and the acceptance re-announces that very state, so a room that has agreed nothing has neither half to work with. Hidden rather than disabled, the way the subgroup entry is. The picker is a pushed screen, so it takes an `onNavigateBack` and puts `NavigateBackButton` in its app bar's leading slot -- the rule `CLAUDE.md` gained while this was still a note, and the desktop target has neither a system back nor an edge swipe to fall back on. `offersTo` reads every certificate this device holds in **any** room rather than asking this one, because a certificate about this group is signed in the other group's room and filed against it -- asking this room would never find it. Each is checked by `classify`, and parents already in the lineage drop out, since an offer that has been accepted is not an offer. Ten strings in the catalogue in sentence case, spacing from `MaterialTheme.spacing`, colour from roles, `Decorative` on every icon beside a label, and the notifier read outside the handler with the caller's scope -- an acceptance is shown while the screen reloads around it. `inComparableGroups` stays private to `ChatRoomDetailScreen`: the upstream commit that moved it to the key extensions is in the fork's tree and not this one, and a second copy to add three spaces to an id is not worth having. **What is reachable and what is not.** Adding a parent and accepting one are both wired. `SubgroupManager.proposeDisavowal` is built, tested and has no button -- dropping a parent is a destructive-looking act that wants a confirmation step, and it belongs in its own change rather than bolted onto this one. 454 common tests, 962 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b54ff61eb6 |
feat(subgroups): adopt an existing group, accept a parent, and stop claiming one
Phase 4 of docs/subgroups-multiple-parents.md, the operations. Two signings and no
ceremony, which is the whole of why this is cheap: the key exists, so there is no
ChillDKG, and the room exists, so there is nothing to create. Still unreachable -- no
screen offers any of it yet.
```
1. birth certificate, in the adopting group -> its quorum signs the child's id
2. key state, in the child itself -> the child's quorum re-announces,
carrying every certificate it accepts
```
**The order is forced and it decides who is deciding.** The key state carries the
certificate, so the certificate has to exist first -- which means the parent commits
first and the child answers second. Step 1 is an offer; step 2 is the acceptance, and
it is the only half that settles anything. Picking a parent is not choosing on the
child's behalf, and the wording of every message here says so.
**`proposeAdoption` is the founding certificate with the child already there.** Same
kind, same six checks on the other side, same room it has always been proposed in. What
differs is that the child's id and threshold key are read off its key state rather than
derived from a ceremony this device may not hold. The roster and the name are the
child's *current* ones -- on a founding certificate they are the founding roster
because there is nothing else they could be, and here the adopting group's admins are
being asked about a group that exists, so what they can weigh is who runs it now. Still
frozen the moment they are signed, and still never read as a live roster.
**`proposeAcceptance` carries the resulting set, not the addition.** The footgun is
real: a key state is replaceable and the newest wins *wholesale*, so a state naming
only the new parent would silently drop every existing one. The current lineage is read
off the row here rather than taken from the caller, and `SubgroupLineage.plus` is what
makes an addition an addition. It signs in the child's **own** room, which it can
because the room exists -- `propose`'s first branch holds and `signingPath` resolves off
the child's own key state, neither of which founding can do.
**`proposeDisavowal` is the same act with the set the other way**, and it revokes
nothing. A certificate is a signature and exists forever; what this removes is the
child's half of the link, which is the half that decides, since a lineage is what the
child's quorum most recently declared. The parent's own list does not follow --
`subgroupsOf` reads that group's certificates -- and the comment says so where somebody
will wonder.
**`refuseAdoption` is seven refusals and the picker is the same function.**
`adoptingCandidatesFor` filters the active profile's rooms through the guard itself
rather than through a copy of its rules, so screen and manager cannot drift. The list is
the profile's own rooms and cannot be anything else: a certificate is proposed inside
the adopting group, so a candidate has to be a room the key in the chair can act in, and
`getChatRoomListByUserPublicKey` is keyed by identity. An empty answer is a fact about
the member rather than a failure -- the remedy is a person, not a button.
Two refusals are new in kind. "This group has not agreed what it signs with" is the
overlap requirement surfacing: the certificate carries the child's threshold key, which
only the child's members hold, so a would-be parent with no sight of it cannot certify
anything. And a cycle check, because a lineage is a DAG now rather than a tree -- with
one parent nothing was ever tempted to recurse. `isAncestorOf` is depth-bounded and
visited-set guarded; it is advisory, since a device sees only the lineages it holds
states for, and the real protection is that an adoption needs the child's own quorum.
Fifteen tests. Every refusal once, including the cycle and the picker's filter, and then
the three operations by reading the proposed event back out of the signing session --
which is what catches the wholesale-replacement footgun: accepting a second parent has
to carry the first, and disavowing the only one has to leave a state that claims none
rather than one that claims nothing readable.
`parentWith` is now one case of a `marmotRoom` helper taking the id and the key, since
adoption needs two real group rooms at once rather than one; it keeps its own doc
comment and the warning it carries about `nostrGroupId` lengths, which is still the trap
it was. `certificate` gains a parent id so a sibling group can sign one.
454 common tests, 962 jvm tests, `m3Audit` meets every budget.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
e81659bf3b |
feat(subgroups): say what a key state proposal is asking, and tell a child it has been offered a parent
Phase 3 of docs/subgroups-multiple-parents.md, the two consent surfaces. The note calls the first of these the one change in the design that is not plumbing, and it is the only one that would have shipped a member a question they could not read. **`ProposedEvent`'s 30326 arm showed a derivation path and a ceremony id.** That was right while a key state was proposed once, at founding, by the coordinator who chose its lineage: the only question was which key the room signs with. A key state can be re-announced now, and that is exactly how a group is adopted -- its own quorum accepts a parent by signing a state that names one. A member approving that is deciding whether their group should answer to another, and `m/9420/0/0 · a1b2c3d4e5f6` answers a question nobody asked. The proposal is their only chance to check, since after it a quorum has spoken. So the parents lead when there are any, counted when there is more than one, in the lineage's canonical order so two devices describe one proposal the same way. The certificates are already in the tags the signature covers, so this costs a reader nothing it does not hold. What the summary cannot say is which parents are being *dropped*: that is a difference against the row this device holds and a summary is pure, so it belongs to the screen, and there is a comment where somebody will look for it. **And the child's room now hears the offer.** `ChatMessage`'s 30329 arm assumed a certificate in a room was that room's own act, because at founding it always is. An adoption signs the certificate first, in the adopting group, and the child's admins had no notice of it at all -- the first thing they would see is a proposal wanting their signature. Founding at least shows them the intent, since `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group; adoption has no ceremony and so no hint. `SubgroupManager.classify` is the decision, pure and needing no database -- `certifies` checks a signature against a room id, and a room id is a public key, so an offer can be believed by a device that has never held the offering group's room. Two true things a certificate can be, and the room it arrived in is the whole of the difference: this room signed it about another, or another signed it about this one. A third room's business is nothing here whoever signed it. `TYPE_SUBGROUP_ADOPTION_OFFERED` writes a line and files nothing. It cannot file anything -- `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with no row for the parent could not store the event -- and it does not need to, because the certificate reaches these devices for real inside the key state that accepts it. The wording carries that: a subgroup of theirs *only once this group agrees what it signs with again, naming them*. An offer rendered as a fact about the lineage would be the whole gap reopened from the other end. **A test found a real one while writing this.** A group can sign, truthfully by every check `certifies` makes, that it is its own parent: author, subject and named parent one room, the id deriving from the key, the signature verifying. The self-parent guard started out inside the offer branch, where it never ran -- the child branch catches such a certificate first and reads it as an ordinary child, which would put a group in its own list of subgroups. It is refused ahead of both branches now, and the test is named for what it pins rather than for the branch it was written against. That case predates this commit: `subgroupsOf` would have listed it today. Eleven tests. Six for `classify` in `SubgroupBirthCertificateEventTest`, which is where the three real quorums already are and where every case it decides is a question about a certificate; four for the summary in `ProposedEventTest`, including a state claiming a lineage it cannot state, which must read as the key alone rather than guess. One icon arm in `ChatTranscript`, because a subgroup type missing from that mapping renders as a chat bubble and looks exactly like a member having said it. 454 common tests, 947 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eba973331b |
feat(subgroups): schema 21 -- a parentage is a row, so a room can have two
Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of two now verifies and is filed, and nothing in the app creates one until the adoption flow lands. `GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group could only be founded with a parent, never adopted by a second. Those are `GroupKeyStateParent` now, one row per parent, and nothing about what they mean changed in the move -- including that the parent side is never a foreign key onto `ChatRoom`, for the three reasons that entity's comment now carries. **The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate per child per parent", which `SubgroupLineage.of` already refuses to break; the key is what makes it a property of the store rather than of a reader remembering. The cascade points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must not outlive the state that claimed it, or a room recreated at the same derived id would inherit a parentage nobody signed for it. **`replace` rewrites the lineage rather than merging it, and that is why it is a transaction.** A key state is replaceable and the newest wins *wholesale*, so the set of parents it names is the set. That is what makes a disavowal expressible at all -- a room drops a parent by re-announcing without it -- and merged rows would leave the dropped parent surviving the statement that dropped it. Deleting before inserting inside one transaction also stops a reader seeing a state beside somebody else's parents: were the writes separate, a concurrent read could land between them and get a state with the old lineage, which looks exactly like a true answer. An older state still changes nothing, now including its parents. **`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one signed statement.** `lineageOf` checks every parent and drops the whole state if any one certificate fails -- the all-or-nothing rule generalised, for the reason it was written: keeping the provable half would file a group as having one parent when its own members see two. Each link is checked on its own, which is what lets a lineage be assembled a parent at a time, and there is a comment where somebody will look for the check that is deliberately absent: nothing requires the certificates to agree on the set. An earlier certificate cannot name a later co-parent, so requiring agreement would make every adoption cost a fresh quorum in every group that had already certified the room. The child's key state is the sole authority. **`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified value, written by `MarmotGroupCreation` and read in production by nothing at all -- the live answer went through `GroupKeyState`. Keeping it would mean a column holding *one* of several parents, which is worse than unused: a cache that is wrong. Its one query, `ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it; the comment left in its place says where the two live answers are. `create`'s `parentChatRoomId` parameter threaded through four files to reach the write that is now gone, so it goes too rather than sitting dead. `MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established, and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead. `BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since `stateFrom` has always dropped such a state rather than filing it, but this is the one moment the data is reshaped rather than re-derived and a half row copied forward would become a parent whose certificate is the string "null". Better to leave it behind -- the state keeps saying what the room signs with, and loses a lineage it could never prove. `parentOf` is `parentsOf` and returns a list, through the repository to `ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because they are a set rather than a path -- neither is "the" parent and the order says nothing about which came first. Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in both directions, a child with two parents appearing in both their lists, a re-announcement that drops a parent and one that clears the lineage entirely, an older state changing neither the state nor its parents, and the primary key collapsing a repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes "two parents are kept, each with its own certificate" -- it needed a third real group, since the earlier draft's second parent was the child certifying itself -- beside a new case where one bad certificate of two drops the whole state. 444 common tests, 937 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0690932252 |
feat(subgroups): a lineage is a set, and the tags that carry one in pairs
Phase 1 of docs/subgroups-multiple-parents.md, the wire. It ships dark: the event can now state a lineage of any size and nothing in this build produces one of more than one, because the row that would hold it does not exist until the next phase. A room's lineage was never really a field. `GroupKeyState` carried one parent and one certificate because a group was founded with a parent and never gained another; the moment it can be adopted, "the parent" is a set that had only ever held one thing. **`SubgroupLineage` is that set, and it holds the two invariants a caller cannot be trusted to remember.** No parent twice -- two entries naming one group is one parent asked twice, and `of` refuses rather than folding them, because each carries its own certificate and picking one would be this device deciding which of the parent's statements counts. And one canonical order, sorted by parent id, so two devices assembling the same lineage from the same certificates emit the same bytes. `plus` and `minus` are the two edits an adoption and a disavowal make; `minus` returning null is "nobody's child" rather than a failure, and revokes nothing -- a certificate is a signature and exists forever, and what it removes is the child's half of the link, which is the half that decides. **The pairs are adjacent, and that is for other people's builds.** A reader that predates this takes the first `parent_group` and the first `birth_certificate` off the tags and checks them against each other. Emitted as `id, certificate, id, certificate` it gets one real parent and that parent's own certificate, verifies them, and files a true but partial lineage -- one parent, correctly. Emitted as all the ids and then all the certificates it would pair the first id with a certificate that may belong to another parent, fail `certifies`, and drop the **whole state** -- losing the row that says which ceremony's share signs for that room, which is a worse outcome than not knowing about the second parent. The sort is what makes the pairing deterministic rather than incidental. **`parseLineage` counts each half twice, by tag name and by successful parse.** A half that is present and broken reads as absent through a parser, and only the broken kind would slide the pairing along by one and pair an id with somebody else's certificate. So an uneven number of halves, a half that does not parse, and a repeated parent are all refused, and `claimsParentage` -- unchanged, reading tag names alone -- is still what tells "claims nothing" from "claims something unreadable". **Two places refuse a second parent for now, and say so rather than truncating.** `propose` checks the lineage is one before it builds a row that can only hold one, and `parentageOf` drops a state claiming more. Taking the first of two would file a lineage the group did not sign and leave the device believing a group has one parent while its own members see two -- the same reasoning the existing all-or-nothing rule is built on. Nothing emits a second yet, so neither fires except on a state from a newer build, which is exactly the case that wants refusing rather than guessing. `SubgroupParentage` is untouched: still one pair, still unchecked, still the thing a lineage is made of. Nineteen tests. `SubgroupLineageTest` is new and pure, with no crypto at all -- the certificates in it are deliberately nonsense, because whether one is good is `certifies`' question and is asked on every read rather than at the door; it pins the two invariants, case-insensitivity on both, and that a certificate rides along with its own parent through the sort. Five go to `GroupKeyStateTest`, and the one worth keeping if any of this is rewritten asserts the *tag order* and then that the first id and the first certificate an older reader would take are a matched pair. 443 common tests, 933 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4899dcbca7 |
feat(ui): a member on the room's screen opens the profile preview
The member rows on the chat room detail were the last "Profile detail" stub in the app: each pushed ImplementationPendingRoute. They now push ProfilePreviewRoute by the member's public key, which the nav host already serves, so a member arrives with the cached profile at once and the relays refreshing it, and the bar's action opens the chat that exists with them or starts one. The preview rather than the profile detail, because the detail is keyed by a kind 0's event id and a member whose profile has not arrived yet -- the "LOADING..." row MemberProfileSync exists for -- has none. The public key is the one thing every row has. A test pins both members opening the same screen, the named one and the one still named by its key. Replayed onto Mantra by docs/curated-to-mantra.md: ChatRoomDetailScreen.kt: the ImplementationPendingRoute import this commit removes is kept -- upstream's file had no other use for it once 808a3459 dropped the library, dialects and projects sections, and Mantra's projects section, which it kept, still routes "Add new project" through it. A semantic conflict rather than a textual one: the pick applied cleanly and the compiler found it; GroupSignedWorkFixtures.kt: the test this commit adds composes the screen against the fork's fixture of that name, which went with lines B, D and H -- it is recreated here reduced to the room alone, under the same names, so the test lands byte for byte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@272b631633 |
||
|
|
e66c174b4f |
feat(ui): the preview's action is the app's extended FAB
curated gained ExtendedFab while the preview was being built: every screen's bottom-bar action, with an enabled that borrows the disabled colours, marks the node disabled and drops the press. The preview had chosen a Button for exactly the reason that widget removes -- a FAB had no disabled state -- so after the merge it was the one screen whose primary action was shaped somewhere else. Now it is that FAB in a bottom bar, the indicator in its icon slot while the key package is still owed, and the reason line stays in the body above it. Hidden, bar and all, for a read-only identity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@80735df86a |
||
|
|
883bf1c84c |
refactor(ui): one extended FAB for every screen's primary action, with a gap in it
Twenty screens end their bottom bar in an `ExtendedFloatingActionButton` --
twenty-one buttons, one screen having two -- and eighteen of those were built
from the content-lambda overload with an `Icon` directly followed by a `Text`.
That overload puts nothing between the two, so the icon touched the label on
every one of them: visible in any render, and unnoticed because every FAB in
the app looked the same. Thirteen of the eighteen also carried the same
twenty-line block for looking disabled, because M3 gives a FAB no `enabled`.
**`ExtendedFab`, once, in widgets/buttons.** A label, an icon slot, `enabled`
and `onClick`. It draws `space150` between icon and label, which is the 12dp
M3 names `ExtendedFabEndIconPadding`; it borrows the disabled colours every
other button in the app uses, marks the node disabled so a screen reader does
not announce a button it is happy to press, and drops the press -- so the
thirteen call sites lose their `if (!canX) return@…` guards along with the
block and the `buttonColors` each of them declared. An icon slot rather than
an image, because two screens put a progress indicator where the icon was
while the action they started is out, and they still do.
**Not M3's `text`/`icon` overload, which would have spaced them for free.**
Its source wraps the label in `clearAndSetSemantics {}`: the label leaves the
merged semantics tree, a screen reader is told only what the icon says, and a
test cannot find the button by what it says -- five screen tests do. The
three FABs that were on that overload move to the widget too, and two of them,
Home's "New chat" and "Start the key ceremony", had a decorative icon beside
the cleared label and so no accessible name at all until now.
`ReadOnlyEntrancesJvmTest` had gone to the unmerged tree to find "New chat"
for this reason; the habit is harmless and stays. Every icon's description is
as it was, since several tests reach their FAB by it.
`ExtendedFabJvmTest` pins the decision: found by its label in the merged tree,
the icon before the label with a gap between, and disabled both announced and
refusing the press. The two docs that recorded the old state -- the
conformance account's "left alone" and the npub plan's unmerged-tree note --
each say what superseded them.
Verified with :composeApp:m3Audit (all budgets met, 0 dp literals in spacing
positions), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest
(1088 tests, 3 new).
Replayed onto Mantra by docs/curated-to-mantra.md: AcceptCuratedSuggestionScreen.kt: deleted, as this commit deletes it upstream; AddGroupPostScreen.kt: deleted, as this commit deletes it upstream; EditGroupCuratedSchemaScreen.kt: deleted, as this commit deletes it upstream; EditGroupNostrProfileScreen.kt: deleted, as this commit deletes it upstream; EditGroupRelaysScreen.kt: deleted, as this commit deletes it upstream; GroupCuratedEntriesScreen.kt: deleted, as this commit deletes it upstream; ProposeGroupEventScreen.kt: deleted, as this commit deletes it upstream.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@ca6c16beb0
|
||
|
|
b3379f8117 |
feat(groups): broadcast every accepted entry from one button, each to where its own would go
The entries screen had one "Broadcast" under each card, and each opened `BroadcastGroupSignedEventScreen` for that one entry: a group that has accepted twenty entries into its lists sent them with twenty trips through the relay picker. This is the other button. "Broadcast all", an extended FAB in a bottom bar, sends every entry on the screen, newest first, one after another, and says under each card what its relays answered. Behind no gate, like the button under each card: sending what the group has already signed takes no share of the key. **Each entry goes where its own broadcast screen would have started.** The list that screen seeds -- `defaultRelaysFor`: the group's General write relays or the app's publish set, plus the list's own relays, minus what the group has blocked -- is what a member who reads nothing and presses the button sends to, and this button is that member with twenty entries. The group's relay lists are read into the loaded state for it; the list's relays come off the entry, which carries its schema, so the rule is handed that one schema rather than all of the group's. Nothing is shown before the press, which is the difference from the one-event screen and the reason that screen stays: a refusal is answered there, where the relay can be changed and the send tried again. **One entry at a time, on purpose.** The rows fill in top to bottom, so the member can see where the batch is; and a public relay sent twenty events in one burst answers "rate-limited" to most of them, which would make the button worse than the presses it saves. Each entry's send has the same ceiling as a single broadcast and the batch has no other, since a long list is meant to take a while. It goes to the pool directly rather than through the queue, for the reason `BroadcastGroupSignedEventViewModel` gives, and it is only as durable as the press: leaving the screen ends it where it is. **The send is shared rather than copied.** What a relay's answer becomes -- an OK, a rejection in the relay's words, a failure, or no answer when the whole send runs out of time -- was decided inside `broadcast()` on the one-event view model. It is now `sendToRelays` on that model's companion, beside `defaultRelaysFor` and `wireEventOf`, and `broadcast()` keeps only the state around it; the thirteen tests on it pass unchanged. The words for an answer moved the same way, out of the relay row and into `RelayOutcome.label()`, which both screens now use. **What the member is told.** A line above the cards -- "Sending entry 3 of 12…", then "Accepted by every relay for 11 of 12 entries." -- persistent rather than a snackbar, because it is the answer the member came for and a snackbar would be gone before the last card had been read. Under each card, "Accepted by n of m relays." in the one-event screen's words, and one line per relay that did not say OK, named with its reason, because "blocked: not on the allow list" is something a member can act on and a count of two out of three is not. An entry with nowhere to go -- everything the seed would name blocked -- is marked so rather than sent to nobody. The FAB looks disabled while a batch is out, and a second press is refused. `GroupCuratedEntriesViewModelJvmTest` pins where each entry goes, that the next waits for the last to settle, what the answers become, and the refusals; the screen test drives the button end to end and checks that each answer sits between its card and that card's own broadcast. The shared fixture entry now carries the `a` root a real entry has -- without it the seed rule could not find the list's relays, which the first run of these tests found. Verified with :composeApp:m3Audit (all budgets met), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest (1085 tests, 7 new). Replayed onto Mantra by docs/curated-to-mantra.md, in part: only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@d2a4298d25 |
||
|
|
2adc503613 |
feat(chat): the preview's button knows whether it can be pressed
Phase 4 of docs/npub-profile-preview.md. Phases 1-3 moved the twenty-second dead end one screen later: a person found on an indexer, a press, and InitiatingNewChat waiting for a key package that someone who has never opened Curare will never have published. The preview now reads the answer before the press. A room that already exists opens; a key package that is here enables "Start new chat"; one that is not, once the relays have had their time, disables it with the reason under it and a try-again that asks the DM relays alone. The existing-room check was a loop with a TODO inside initiateNewChat; it is ChatRepository.findDirectMessageRoom now -- a room whose members are exactly the two keys, or the one key when they are the same -- read by both screens and pinned by a test on an in-memory database. The old size-one branch would have opened any room the peer sat in alone, whoever they were. Both halves are observed together, as the chat screen does: a key package arriving after the profile touches a different table, and a collect on the profile alone would never see it. A room is read once up front, because a key package is meant to be used once and the relays need not hold a fresh one for someone you already talk to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@d649098504 |
||
|
|
5ba888d9f9 |
feat(ui): the new-chat dialog opens the person, and no longer starts the chat
Phase 3 of docs/npub-profile-preview.md, and the user-visible change: "Direct message via npub" now shows who this is before anything is created. The dialog's button reads "Find profile" and pushes ProfilePreviewRoute; the chat is started from there. The field keeps what it parsed rather than whether it parsed. Its check and its decode used to be two different functions, which is how "nostr:npub1…" passed the one and failed the other, closing the dialog with nothing to show for it; CredentialParser.npubOrNull is the sign-in decode exposed for an address field -- an nsec is null there, because a field asking whom to write to must never take a secret for an answer -- and the enabled button and the route it pushes read the same key. The nip05 branch still lands on the placeholder, and the test says so. "Start chat" goes with its only reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@923b59ec32 |
||
|
|
39c056a2f2 |
feat(ui): the profile preview screen, reachable by nothing yet
Phase 2 of docs/npub-profile-preview.md. ProfilePreviewRoute is keyed by public key, because that is all the entrance has, and ProfilePreviewScreen draws the four states over a constant bar: the npub above the indicator while the relays are asked, the person -- avatar, name, nip05, the whole of the about, the npub that was pasted -- and under them "Start new chat", which hands over the route the dialog pushes today and creates nothing here. Not found is the sign-in screen's sentence with a try-again; a read-only identity is shown the person and nothing to do, and ReadOnlyEntrancesJvmTest gains the row. Registered in the host with the room replacing the preview, as it replaces the profile detail. No button navigates to it until Phase 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@8973c0058e |
||
|
|
f84ae13394 |
feat(chat): the lookup behind a profile preview, and the sync it shares with the chat screen
Phase 1 of docs/npub-profile-preview.md. No screen yet: a view model that finds the person behind a pasted npub and says when it has stopped looking, over the row the device already holds -- cached first -- and two asks of the relays. The three-kind filter the chat screen queued privately is now DirectMessagePeerSync.requests, and the kind 0 alone goes to the indexers as profileRequests: a key pasted from elsewhere has lived elsewhere, and a three-kind filter sent to an indexer comes back with the kind 0 and nothing else. "A placeholder is not a profile" was spelled out twice as a comparison against GENESIS_AT; it is Profile.isResolved() now, and both sites read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@37c6f7eabb |
||
|
|
679d5249b2 |
fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room
Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's follow-up, written natively before the several-profiles line reaches users. ChatRoom is keyed by the group id alone, and the MLS state, the DKG sessions and the FROST shares all hang off the room; before several profiles on a device the second local member could not exist, and after it a user can invite their own second profile into their own collective. What the DAO did with that Welcome was already a refusal, by accident: the welcome branch of indexNostrEvent checks for an existing room before it writes one, so the joiner's state never overwrote the holder's -- but the branch could not tell a redelivered Welcome for our own room from a Welcome for a room another profile holds, logged "Chat Room already exists" at warning level for both, and told nobody. The invitee never joined, the inviter's device believed the invite had landed, and the failure had the exact shape of "the other device never got it". The branch now distinguishes the two by the room's userPublicKey. The same profile's redelivery stays the quiet no-op it was. Another profile's Welcome is refused with a warning naming both keys, and a membership line is written into the room that exists -- the holder's, because it is the only room on the device the group has and the holder is the one who can act on it: leave, or tell the inviter which profile to invite instead. The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the transcript draws it as a notice rather than a bubble, with the error tint and a PersonOff icon since the person and not the invite is what it is about; its content is a whole sentence naming the inviter, the invitee and the holder, resolved at write time the way the other membership lines are. The invitee's key package bundle is left unconsumed, as the branch always left it; a second Welcome for the same package arrives here again and writes a second line. The test is two devices for real: an MLS group on the inviter's database, a key package whose private keys the invitee's database holds -- the fixture now returns the bundle beside the public package, and marmotKeyPackageFor is a projection of it -- and the Welcome the invite queued, sealed the way sealGiftWrapPayload seals one and stored through storeNostrEvent under the invitee's key. Three cases: the group held by another profile is refused, its row and MLS state untouched, the line in the room naming all three and the bundle unconsumed; the same profile welcomed twice writes nothing; and with no profile in the way the Welcome is a join, which is what shows the fixture is real. jvmTest 868 -> 871, both compilers clean, every audit budget met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
72b9d3d2ed |
test(identity): several profiles, both ways, and a boot
Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a row, through the real view models on an in-memory database with a real key store. A is made from a phrase through the create view model and the seed writer, the way Landing makes the first profile: both files written, and listed once as a profile with a wallet attached, under the wallet's id, with the credential's key. A is opened with a node behind it -- a PhoenixBusiness that was never started, which is enough to make it one the switch has to stop. B's npub is signed in from inside through the sign-in view model; the switch stops A's node, B is open and read-only, the machine routes it from its placeholder, and nothing was ever started for a public key. A kind 1 queued for A while B is open waits three seconds unsigned; back to A, and it goes out, with nothing more stopped since B had no node. C is created from inside through the bare-key writer: a key, not a second wallet, routed, with no node, and the switch to it stops A's node again. C and B are forgotten through ForgetIdentity with the view model's writers, the default is cleared and the selector reset: A is listed alone, its credential is the only entry left in the file, B's and C's account rows went with them, and A itself cannot be forgotten because a wallet is attached. Then a fresh view model over the same directory, which is what a cold boot is: the repair finds nothing to do, A is listed from its credential rather than derived, and StartupChoice opens it without asking. A navigation state is matched to a profile by whichever step it is at -- UnsignedProfile, UnqueuedProfileSynchronization, UnannouncedProfile through the event the broadcast request names, ProfileLoaded -- because with the notary running, A's account moves from unsigned to announced-pending while the test looks, and what the test asserts is whose account it is, not which step it reached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@88577bb5c3 |
||
|
|
8edb52fff3 |
feat(ui): leaving one profile among several
Phase 7 of docs/multiple-profiles.md. Small, because the forget sequence was built right. Sign out of a read-only identity and "forget this key" for a bare key both run ForgetIdentity and then the tail -- re-list, clear the default since Phase 3, show the selector with what remains or Landing if nothing does -- and that is the right behaviour with several profiles as well; nothing in the sequence changes. A profile with a wallet attached answers WalletAttached from Phase 1 and is offered neither exit: its sign-out row stays the pending route, for the reason both sign-in plans gave, and it is now also the only way such a profile can leave, since the repair would write its credential back. What changes is that the user is no longer stuck behind it -- "switch profile" is the row above. The not-found screen learns about the others. It offers "try again" and, for a read-only identity, "use a different key", which forgets it; with another profile on the device it now offers "switch profile" as well, for every kind, pushing the switcher -- which has a back button, so the screen is still there if the user changes their mind. The identity that was not found stays listed; forgetting it remains a separate decision. The nav host passes the callback only when the listing holds more than one. Tests: ReadOnlyEntrancesJvmTest -- with another profile on the device the not-found state offers the switch beside the read-only exit and it calls through once; a signing identity that was not found gets it above the set-up form; with one profile there is nothing to switch to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@9bb340042d |
||
|
|
72afe8aa40 |
feat(sync): a fetch queue per identity, and the inbox reopened on activation
Phase 6 of docs/multiple-profiles.md. The one data problem, closed from both ends. The two queues that fetch -- SynchronizeNostrEventRequest and NegentropySynchronizeRequest -- gain a nullable ownerPublicKey, the identity that asked, because what a fetch brings back is opened with the fetcher's key. The pumps ask for their own: the head of the queue among this identity's requests and the ones nobody owns, so that a request another identity queued waits for that identity. Rows from before the column read back null, meaning "the device's", which is what they were, and any identity may drain them. The broadcast queue deliberately gets no owner: a signed event is anyone's to carry, and holding A's outgoing message until A is opened again would be a delivery failure the user would never be told about. Room version 20, an AutoMigration for two nullable columns, with the schema export committed beside its predecessors. The stamp is the repository's, not the call site's. Eighteen view models and two DAO paths queue requests, and every one of them does so as the active identity -- a screen cannot queue anything as anyone else. DatabaseNostrRepository takes the identity flow at construction, which meant moving the view model above the repositories in the nav host, and stamps every request it queues where the caller stamped nothing; a negentropy request carries its owner into the REQ it becomes. The DAO's own requests -- the placeholder-profile syncs it plants while indexing, and the participant syncs of a Marmot join -- stay unowned, on purpose and against the plan's sketch: they fetch public kinds that need no key to open, and any profile that is open may as well fetch them. The other end: wraps that were fetched under the wrong key -- before this, or by a read-only identity whose nsec was pasted later, the case the npub plan's DAO guard left with the words "the key that opens this one may be signed in later" and no code behind them. storeNostrEvent never indexes an event it already holds, so a wrap that arrived under the wrong key stayed closed for good: the live subscription re-received it, the DAO saw a known id, and returned. NostrDao.reopenInbox finds every wrap addressed to the active key that no seal names and runs each through indexNostrEvent again under the right key, one transaction per wrap so that one that cannot be opened rolls back its own changes and the next is still tried; one pass, since wraps do not depend on one another. It runs from the sync pumps' collector once per activation, for an identity that can sign, before the live inbox and the broadcast pump are launched -- so the sweep and the live subscription are not opening the same wrap at once; both are idempotent by event id. Found on the way: GiftWrapSeal.giftWrapMessageId, a foreign key to the wrap a seal came out of, existed and was never written, so nothing in the database could say which wraps had been opened. decryptGiftWrapSeal now sets it. A seal from before reads back null, is swept once, and comes back with the link -- the reindex is idempotent, and persistInboundChatMessage already files a message it has once. Tests: ReadOnlyGiftWrapDaoJvmTest -- a wrap for us stored under another profile's key is stored and not opened, a re-delivery under our key changes nothing, the sweep opens it once and finds nothing the second time; the other profile's sweep and a read-only pair open nothing; a wrap stored read-only is opened once the key is here. OwnedRequestQueueJvmTest -- A's request is offered to A and not to B, nobody's to both, oldest first; the repository stamps what it queues with the identity that is open, nothing when none is, and keeps an owner a caller named. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@759199f2af |
||
|
|
ccb9942a18 |
feat(identity): adding a profile from inside, as a key and not a second wallet
Phase 5 of docs/multiple-profiles.md. The two entrances, and the two things
that were only safe with one profile.
Under the list, on the switcher and on the startup selector alike, two rows:
"sign in with a key", to the sign-in screen, and "create a new profile", to
the create screen. Both push a screen that has a back button. Landing is not
one of them -- it has no back button and clears the stack on its way out,
because it is the first-run screen, and pushing it from inside would make it
a pushed screen on some days and a root on others. A device with one profile
can now be given a second, which it could not: Landing showed only when the
device held nothing.
A profile created from inside is a bare key. CreateProfileScreen generated
twelve words behind its form -- a seed, a node, a wallet, and at the NIP-06
path the key that becomes the profile -- which is right for the first
profile on a device, the one the wallet will belong to, and wrong for the
second: a user who wants another profile has not asked for another
Lightning node, another set of channels, or a second phrase with funds
behind it. The screen now takes a NewProfileWriter and does not know which
it was given: seedProfileWriter from Landing, bareKeyProfileWriter from the
switcher, thirty-two random bytes written the way a pasted nsec is.
CreateProfileRoute carries the choice. The view model writes the secret
first, then the six bootstrap events for the key it derives, then hands the
id to the tail -- so the events are only ever queued for a key the device
holds, and they are in the database before the tail activates the identity,
an ordering the sign-in screen documents as load-bearing and the create flow
used to get away with by luck. The tail gains the popUpTo(0) the sign-in
tail has. A second wallet is not offered from inside; a pasted recovery
phrase still brings its own.
"End this" goes. It wiped the database -- every profile's rooms, MLS state,
key packages and queues -- from the screen whose purpose is to add a
profile; it was only ever a development exit, and with several profiles on
a device it was a way to lose the others. wipeDatabase leaves the view model
and stays on the repository for the tests that use it.
A key already on the device is refused with the id it is listed under.
WriteNostrCredentialResult.AlreadyExists and WritingSeedState.Error.
SeedAlreadyExists carry it -- the wallet's id where a seed derives the key
-- and the sign-in screen's error state gains one action, "switch to it",
which is the sign-in tail with that id. From Landing "already on this
device" was the whole message; from inside it is a sentence with an obvious
next step, and a duplicate paste becomes the fastest switch in the app.
Found on the way, and fixed in the jvm actuals: DataStoreManager caches one
GlobalPrefs per process behind an unsynchronised check-then-set, and
DataStore refuses a second instance over the same file. Two first calls at
once -- the listing on IO and a sign-in's write, or the startup screen on
Main -- could both see the empty cache and both build one, and the loser
threw "multiple DataStores active for the same file" at first use. The test
for this phase hit it one run in three. JvmGlobalPrefs is now the one way
the jvm target reaches the prefs, under a lock; Android goes through the
Application's single instance and never raced; the library's own callers
come at node start, when the cache is long populated.
Tests: AddProfileFromInsideJvmTest, through the real view models on an
in-memory database -- A open, B's nsec committed and switched to, and back,
both accounts intact with their own kind 0; A's key pasted again refused
with A's id; a profile made through the bare-key writer lists as a bare key
with the kind 0 first of its six bootstrap rows, none signed yet, and no
node ran. SignInToProfileViewModelJvmTest and IdentityWriterJvmTest assert
the ids the three refusals carry. ProfilesScreenJvmTest: the two rows call
their callbacks and switch nothing, under the caption that the open profile
stays. CreateProfileScreenJvmTest: the state that carried "end this" is
composed and the words are not there.
Replayed onto Mantra by docs/curated-to-mantra.md: CreateProfileViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (
|
||
|
|
5654e4624b |
feat(ui): the switcher, showing the nostr profile and what the device holds for it
Phase 4 of docs/multiple-profiles.md. One pushed screen, and the startup selector brought up to match it. ProfilesRoute, reached from the profile tab's row -- "Switch profile", where it said "Change account" and went to the pending screen -- and drawn by ProfilesScreen: a top app bar with a back button, and WalletsSelector as its whole content with the open profile above the divider and the others below. Tapping another row is switchToIdentity and nothing else; the observer restarts the app as that identity through its lock gate, so the screen is never around to see the result. Tapping the open row does nothing. Three states, said so: loading while the list is read, which in practice is never seen; an unreadable key file, through ErrorState with a retry that re-lists; and the list. Empty cannot happen while something is signed in and the screen does not pretend it can. One column at every width. The row is offered to every kind: a read-only identity can leave for another profile the same way a signing one can. ProfilesViewModel joins the flows the app already holds -- the listing state, the identities, the active identity, the wallet metadata -- to the profile rows for the listed keys, through NostrRepository.observeProfilesOf, one map that moves when any of them does. Nothing in it writes. What a row shows changes. WalletsSelector showed "Default name" over a random emoji for every row, because nothing in this app writes the wallet metadata's name, and two profiles side by side both called Default name and told apart by a bech32 string is not a switcher. The row now takes the nostr profile -- humanReadableNameOrPubkey over ProfileAvatar, the widgets the profile tab draws itself with -- with the npub on the second line, and falls back to the npub over the metadata's emoji for a key the device has no kind 0 for yet: one just created, or a read-only one that was never found. And it says what the device holds for the profile before the tap: "Wallet" for a seed attached, beside the "Read only" that was already there, and nothing for the plain case of a bare key. It is the difference between a profile that can be signed out of and one whose sign-out is a wallet question, and between two profiles with the same name. The startup screen draws the same widget and gets the same map through rememberProfilesOf, so both lists look the same. The selector's dead globalPrefs parameter goes with the rewrite. Tests: ProfilesScreenJvmTest, on the unmerged tree, with a wallet-attached A open, a bare B and a read-only C -- A by its nostr name with its npub and "Wallet" under it, B and C by their npub with only C labelled, no "Default name" anywhere, the open one drawn above the others; tapping A calls nothing and tapping B calls the switch once with B's id; an unreadable key file is an error whose retry is counted; the bar has a back button. ReadOnlyEntrancesJvmTest asserts "Switch profile" for both kinds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@25464595f4 |
||
|
|
73a0e5b9c2 |
feat(identity): which profile opens, on launch and after a switch
Phase 3 of docs/multiple-profiles.md. The startup screen learns to agree with the switch. The table that picks what to open is lifted out of the screen's remember into StartupChoice.resolve, where it can be read and tested, because its order was the whole decision and it was wrong in the one way a switch would hit: "!startWalletImmediately -> null" sat above "desiredWalletId != null", and nothing ever set the flag back to true. So after the first visit to the selector, every sign-in on that device landed on the selector instead of the profile it had just signed in. Two rows move: what a switch or a sign-in named goes above the user's earlier "show me the list", since it is the more specific instruction; and the single-identity row drops below it, since when both are set they name the same thing. The default is written. GlobalPrefs.getDefaultWallet has been read by startup since Phoenix and saved by nothing in this app, so a cold boot with two profiles was the selector every time with no memory of which one was open. setActiveIdentity now saves the id it activates -- every activation goes through it, startup for all three kinds and the two tails through startup -- so the default is always the profile most recently open. The two forget tails clear it, since the default is the one memory of an identity that would otherwise outlive it. Both writes are wrapped: a default that could not be recorded is a selector on the next boot, not a crash now. The screen reads the saved value as a default only when it names a wallet, which is the one thing left to decide at the call site. The startup screen's literals go to the catalogue, and "wallet" goes with them except in the one place a wallet is what is starting: "Starting wallet" is shown from StartupViewState.StartingBusiness, which only the branch with a wallet attached reaches, and stays. The rest become "Preparing profiles", "Opening profile", "Decrypting", "Loading preferences", "Unlock to continue" and "Could not load the profiles on this device"; the selector's title is "Choose a profile", and select_a_wallet goes. Tests: StartupChoiceTest in commonTest, one case per row and the two orderings this phase is for -- a desired id opens even after the user once asked for the list, and one identity with the list asked for still shows it. IdentitySwitchJvmTest gains the cold boot: activate A then B, and a fresh view model over the same directory resolves B without asking; forget the default, and it resolves nothing. Found on the way and recorded in the test: DataStoreManager caches the global preferences once per process, bound to whichever directory was current when the first test in the JVM asked -- the same trap the nsec plan recorded for user preferences -- so the test reads the default through the view model's own instance rather than one of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@00fc996995 |
||
|
|
84ac84ccaf |
feat(identity): a switch that tears down what it should
Phase 2 of docs/multiple-profiles.md. The transition, made correct, with nothing yet calling it from a screen: the view model, one manager, and three one-line actuals. switchToWallet becomes switchToIdentity, and does what a switch is: remember which one, set startWalletImmediately back to true -- a switch is the user saying which one, and the flag was only ever the user saying "show me the list"; nothing set it back before because nothing could switch -- clear the active identity, and stop the node of the profile being left, if it had one. The identity is cleared before the node is stopped, so that every collector that could reach the business is cancelled before the stop runs. The test is business != null, not the kind: whether the profile being left has a node behind it is the fact, and the kind is how it currently comes to be true. stopPlatformBusiness is an expect beside updateBusinessActiveInUI, for the reason that one is: BusinessManager is a per-platform object. Its three actuals call stopBusiness, which existed on every platform and nothing called. The manager is injected into the view model as a function so that the branch can be pinned without a node. RelaysSocketManager.observeActiveUserId becomes a child of collectLatest -- the shape the pumps and the notary already had. It used to keep one job per pubkey on its own scope and cancel only the job for the pubkey being started, which meant a switch left the previous identity's observer running: two observers feeding updateRelayPools, and the one pool following whichever relay list emitted last. The map goes; a null identity closes nothing, since the pool is shared by pumps a null identity has already cancelled, and the next identity's list replaces it through changeRelays as it always did. The scope is injectable and relayUrls is exposed, both for the test. The sign-in and create tails keep their own navigate beside the observer's, now with a comment saying why: the navigation state is a StateFlow, an update to a state equal to the current one emits nothing, and the explicit navigation is what guarantees the stack moves even on the day the state does not. Tests: IdentitySwitchJvmTest, through the real SovereignWalletViewModel with a real notary and navigation machine on an in-memory database -- A open and a kind 1 queued for A is signed; switch to B, the identity clears, the machine goes to startup, B is activated the way startup does and routed from its account; a kind 1 queued for A now waits three seconds unsigned, and goes out when A is back. Leaving a profile with a PhoenixBusiness behind it -- constructible without a node, since everything in it is lazy -- stops that node once; leaving nothing, or a bare key, stops nothing. RelaysSocketManagerSwitchJvmTest over a fake relay repository and fake sockets: after the switch the pool holds B's relays, a re-emission of A's list changes nothing -- the line that fails against the old observer -- and an identity whose list is still empty leaves the pool as it was, which is the behaviour updateRelayPools has always had for an empty list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@9b3a19278f |
||
|
|
d957ee8de5 |
feat(identity): a seed's key is a credential, and the seed is the wallet attached to it
Phase 1 of docs/multiple-profiles.md. No library change: the file format, the encrypted writer and the manager all exist, and the app already wrote the credentials file from the seed writer -- to delete a public entry a seed superseded. This changes what it writes there, and what the listing believes. Until now a seed's nostr key was never in nostr-credentials.dat. It was derived from the words at listing, to know which npub to show, and from the running node at activation, to know which key to sign with -- so a seed-backed profile existed only as a derivation, and the app had to start a Lightning node to find out who it was. Both sign-in plans made "one key, one file" an invariant, and it was the wrong one: it said a wallet is a profile. Now the credentials file is the list of profiles and a seed is a wallet attached to the entry its key derives. writeMnemonic writes two things, credential first: a Secret for the derived key under its x-only pubkey, replacing a public entry where there is one, and then the seed. Credential first because a crash between the two leaves a bare-key profile the phrase completes, which is a valid thing to hold and says the model out loud -- the profile exists, then a wallet is attached to it. The one refusal it drops is the phrase of a key held as a bare secret, SeedAlreadyExists under the nsec plan: the device did not have that wallet, so this is the profile acquiring the wallet that derives it. The entry stays a secret for the same key, the id becomes the wallet's, and the bare key's preference files go with the old id -- the profile's preferences are the wallet's now, fresh, which is right since there is a new secret to back up. The same seed twice is still refused, by wallet id, and is the only way a profile with a wallet attached is offered its phrase again. SeedCredentials.reconcile is the repair for every seed already on a device, beside migrateFromNostrKeys in listIdentities and shaped like it: a named, idempotent write, one file write for however many seeds are missing, nothing at all on a device with none or one already repaired. A failed write is a result, not a throw, and carries the map that was read: the listing goes on with it and merge derives the key of a seed that has no credential, so a seed this could not repair is still listed. A failed write must never hide a wallet. The plan had the repair running before the credentials file was read; it runs after, and hands the listing what it returns, so the file is decrypted once -- the doc now says so. StoredIdentity.merge inverts: the credentials are the list, and each seed is attached to the Secret its key derives -- listed once, as Mnemonic under the wallet's id, carrying the credential's key -- where before the seeds were the list and a secret for a seed's key was listed twice under two ids. Mnemonic gains privateKey. A Public for a seed's key is skipped with a log line, since the next repair upgrades it; a seed with no credential is listed by derivation, with a log line. IdentityKind.Mnemonic's doc changes to what the kind now means: the name records the attachment, not the source. setActiveWallet takes the StoredIdentity.Mnemonic and builds the identity from the credential's key, with the node's derivation as a cross-check -- a check(), because a node disagreeing with the credentials file is the one corruption worth refusing to run under, and it cannot fail for a file the repair wrote. The node still starts for a profile with a wallet attached: not for the key any more, but for what startNewBusiness does besides -- metadata, preferences, last-used build, and on Android the channel watcher a restored Phoenix phrase may need. Making it lazy is now one branch and is named as its own decision. forgetNostrCredential refuses a second thing: a key a seed derives. The seed would derive it again and the next repair would write it back, so a forget that succeeded would undo itself. NotACredential becomes WalletAttached in the writer's result and ForgetIdentity's outcome, since that is now the only reason a signing profile cannot be forgotten -- a key not in the file at all can, since the repair, only be a seed's key the repair could not write. The two sign-in docs' tables each gain a row pointing here for the invariant this supersedes. Tests: SeedCredentialsJvmTest against a device from before -- seed.dat written directly, no credentials -- writes exactly what is missing in one write; a device already repaired is not written to again, checked by the file's bytes since a rewrite would carry a fresh iv; a public entry for a seed's key is upgraded; bare keys are untouched; and a write that fails, with the key store locked, is reported with the map that was read and the wallet is still listed. IdentityWriterJvmTest drives the callback-shaped seed writer through a CompletableDeferred on a real Main dispatcher, since it reports after a real one-second delay: a phrase writes both files, its nsec and npub are then duplicates and its forget is WalletAttached; the same phrase twice is refused; the phrase of a bare key attaches, under the wallet's id, with the bare key's preferences gone; the phrase of a key held read-only attaches and the entry becomes a secret. StoredIdentityJvmTest lists a secret-plus-seed once with the credential's key, a seed without a credential by derivation, and a public entry for a seed's key as the wallet only. Not under test: the activation's cross-check, because a PhoenixBusiness cannot be built without a node. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@3008137e3f |
||
|
|
776455ecb0 |
refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable
Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this
morning, go again this afternoon: the group's nostr profile (kind 0), its four relay
lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it
publishes (31889), the suggestions it reads (31888) and the entries it accepts
(31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings
(`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`,
`GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight
kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings,
and every test that came with them. A translation collective has no list to curate
and no reason to describe itself under a key no member controls, and five rows
saying so on every group's screen were five rows about somebody else's product.
**The pasted-event proposal goes with them, because it was theirs.**
`GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list
kinds and 31889 -- "the kinds the group's screen has a place for", in its own words --
so with the five rows gone it would have refused every paste. Keeping it as a
generic "sign any event" was considered and rejected: the screen's whole argument
was that a member goes to the row to see the event landed, and there is no row. The
`ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing
fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that
was about code this repository had before the pull.
**Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its
route, view model, state and `BroadcastButton` are kept, on the decision that a way
to send a group-signed event to relays is worth having against the day something
wires a button in -- the button's only call sites were the five removed screens.
Two things had to change for it to compile against a tree with no relay lists:
`defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the
General list it preferred, the Blocked list it filtered by and the schema relays it
widened to no longer exist; and `relayUrlOrNull` moves into the view model's
companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says
"the relays this app publishes to" rather than "where the group has said it lives".
Its two tests are rewritten around the new seed: the screen test answers for the
first two seeded relays and counts the rest as asked, and empties the list by hand
to see the empty state, since the seed always has something in it. Deleting
broadcast outright -- the tidier tree -- was the recommendation and was declined.
**Every pre-existing file is back at its pre-pull content plus the kept lines'
hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`,
`NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`,
`ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state,
and `m3-title-case.py` -- were touched by no kept commit and are restored from
|
||
|
|
c53a6f77cc |
test(identity): the preview end to end, with nothing signed, and the upgrade
Phase 7 of docs/npub-sign-in.md. NpubPreviewRoundTripJvmTest is the nsec round trip with the repository made real: an in-memory database under the app's own DAOs, because the assertion this exists for is about what is not in it. An npub is signed in the way the sign-in screen does it, listed the way startup does, activated as a read-only identity, and handed to NavigationViewModel: it lands on UnqueuedProfileSynchronization. The kind 0 the relays would answer with is indexed through the read-only key pair: ProfileLoaded. And with the real NotaryViewModel watching the same rows for longer than its key package delay, nothing was signed -- the only unsigned row for the pubkey is the placeholder, still at genesis; nothing is queued for a signature; no key package bundle; no broadcast request; no node. The contrast that makes those assertions worth having: the same harness as a signing identity does sign -- the notary makes a key package bundle within its delay. Without it, "nothing was signed" could be true of a harness in which nothing can be signed. Then the upgrade: the nsec of the key held read-only signs in over it through the same view model, under the same id, leaving one credential that is now a secret, one listed identity, and one account -- the second sign-in planted nothing. The full jvm suites pass: 988 in the app, 159 in the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@315e93315a |
||
|
|
786ac15959 |
feat(identity): two exits for an identity that holds no key
Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors. ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out -- the credential out of the file, the metadata hidden, the account rows gone, in that order so a failure partway leaves the credential on disk rather than an identity the selector lists but nothing can open. Since the credentials file the first step is the same call for both credential kinds, so it is one function; a mnemonic identity is refused at the first step, because removing a seed is a wallet question this does not answer. The nsec view model calls it now and keeps only its own state. Sign out on the profile tab does, for a read-only identity only, what its colour has been promising: a confirmation naming the npub -- this device holds no key for it, so there is nothing to lose; the profile stays on the relays -- then the identity leaves the device and the nav host's tail re-lists and clears the active identity, which shows the selector or, if this was the last one, Landing. It is the first real sign out in the app. For the other two kinds the button keeps its pending route. SignOutViewModel owns the confirmation and the in-flight state; SignOutDependencies bundles what the screen needs so that a caller with none of it -- previews, tests -- passes nothing. The not-found screen had, for a read-only identity, no exit: Phase 5 hid the set-up form (a kind 0 has to be signed), the profile tab is not reachable before ProfileLoaded, and a user whose npub was found on no relay could try again for ever. So a third action, shown only where the second is not: use a different key, which is the same sequence behind the same dialog, reached from the other end of the identity's life. Tests: the sequence and where it stops, the view model's states around it, and the two screens composed as each kind -- the profile's sign out asking first and naming the npub, the not-found screen offering the form to one kind and the other key to the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a586b7c116 |
||
|
|
f5a6f74731 |
feat(ui): what a read-only identity is shown, and what it is not
Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a product. One question, asked in one way. LocalCanSign is a composition local for the snackbar host's reason: screens do not receive the identity, MetadataEventDetail -- where follow and send message live -- is several composables below anything that could be handed more, and a parameter threaded through twenty-six lists is forgotten in the twenty-seventh. ProvideSigningCapability sits once above the navigation suite. The default is true rather than an error, the one way this differs from the snackbar host: the provider cannot be forgotten per screen, so the only things composed outside it are previews and tests. And it is a capability, not a kind -- "can this identity sign?", not "is this an npub?" -- so that a remote signer is not a fourth value in every when. The inventory, hidden rather than disabled because the empty state beside each says why: HomeScreen's New chat in both layouts, its sheet and its npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit profile and send message (the "follows you" state still shows -- a fact, not an action); ActiveProfileScreen's key package management and key recovery; ShareProfileScreen's re-broadcast, which signs nothing but queues a copy for the finder relays, and a read-only identity puts nothing on a relay; SocialPreconditionScreen's invite and view invites, pending today and writes when they exist; and the not-found screen's set-up form, since a kind 0 has to be signed. Everything else that writes is behind a chat room, which a read-only identity can never open. The Messages tab, for a read-only identity, is an EmptyState where the rooms would be -- one column at every width, since a two-pane layout is a list beside a detail and there is no list -- whose message says which absence it is and whose action is the upgrade: Sign in with the nsec, which opens the same screen as landing's, hits the credentials file's upgrade rule, and comes back through startup with rooms in it. ChatRoomListViewModel is not composed, so the inbox sync and the MLS negentropy it would queue are not queued. Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens under each value of LocalCanSign and looks for the controls by text, on the unmerged tree so that "does not exist" is not vacuous. The M3 audit's budgets hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@e31033e857 |
||
|
|
4c04d3b12b |
feat(sign-in): an npub, recognised, confirmed as read-only, then written
Phase 4 of docs/npub-sign-in.md. The field accepts a third thing. CredentialParser recognises npub1... and nostr:npub1... as SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix that name a point on the curve -- the counterpart of the isValid() a secret is checked with, since not every x coordinate has a point above it. PublicKeyOnly goes, and its string with it; an npub that does not decode, or names no point, is InvalidKey. Hex stays a secret: a private key and an x-only public key are the same size, the confirm step shows the derived npub so a public key pasted as hex is visible for what it is, and guessing would never fire when it mattered, since an x coordinate is almost always also a valid scalar. The test pins the vector's own public key, pasted as hex, landing on a different npub. The confirm step's third arm says what the user is about to get and not get, once, before the choice: this profile and the people it follows; messages closed and nothing sent; paste the nsec later to open it. The landing caption, the field's label and its placeholder name the third input, the last with "to look around" for a user who does not know the word read-only. The view model takes the third writer the way it takes the other two and commits through one shared write-and-map. signInToProfile is idempotent by pubkey. Until now nothing called it twice for one key -- the writers refused a second sign-in before it got there. Pasting the nsec of a key held read-only is the first path that signs in a pubkey whose account already exists, and a second placeholder would be two kind-0 rows for one pubkey, two accounts disagreeing about which is this one. A repository test signs in twice and counts one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@bfad1f39a2 |
||
|
|
6532bfc45f |
feat(identity): list, start and read as an identity that holds no key
Phase 3 of docs/npub-sign-in.md. listIdentities runs the migration from nostr-keys.dat before its read -- there rather than inside the reader, so a listing that writes is a named step and not a surprise, and from init, before anything else touches either file. An old file that cannot be read is left where it is and reported the way an unreadable credentials file is, so the user sees an error rather than silently losing every imported identity. The selector says "read only" under the npub of a public-key identity, before the tap: two identities can share an avatar and a name, and only one of them will let the user send a message. Identity.toKeyPair is now the one place a quartz KeyPair is built from an identity. With a secret it is the pair as before, the public key a cross-check; without one it is quartz's read-only constructor. What it must never be is KeyPair(privKey = null) with nothing else -- that is quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that from a forwarded pair, which is why the helper has the trap on it. The synchronization view model runs the two pumps that read for every identity and the two that write only for one that can sign. Stated as a rule because Phase 5 leans on it: a read-only identity never sends anything to a relay, not a signature and not a copy. The broadcast pump would find nothing, and the live subscriptions are the gift-wrap inbox and the group-membership follow, both fetching what this identity cannot open. One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a wrap addressed to a key the pair does not hold is kept, event and wrap, the way someone else's mail already is. storeNostrEvent is @Transaction and indexes inside it, so before this the unseal ran with a key nobody has, threw, and the throw took the event with it while logging as a decryption failure. Verified both ways: the new DAO test fails on exactly that case with the guard removed and passes with it, and the same wrap opens once the pair holds its key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@7db863e392 |
||
|
|
d5bd248364 |
feat(identity): one credentials file, with a read-only kind and the upgrade in one write
Phase 2 of docs/npub-sign-in.md: the library half is lightning-kmp-app 01962f3 (claude/nostr-credentials), bumped in here; this is the app half. nostr-keys.dat becomes nostr-credentials.dat, one typed entry per public key -- a secret, or only the public key -- so that a profile signed in to read-only and the same profile with its nsec pasted later are one entry in one file. StoredIdentity gains NostrPublic, whose id is the one its secret would have (hash160 of the x-only key), and merge reads the typed map. It keeps the one precedence it needs: a public entry for a key a seed already derives lists the wallet only, which is the state the seed writer's two-file upgrade can leave behind if it dies between its writes. A secret for a seed's key is still listed twice, as before -- that is a duplicate the writers refuse, not a state the listing hides. IdentityWriter: writeNostrPublicKey beside writeNostrKey, both refusing a duplicate by public key against the seeds and the credentials, with the one exception that is the point of the file -- the nsec of a key held read-only is not a duplicate, since the device does not have that secret. writeNostrKey replaces the public entry with a secret one in a single write, under the id it already had, so the read-only identity's preferences are the ones the signing identity keeps. writeMnemonic has to span two files for the same upgrade and removes the credential first, then writes the seed: a crash between the two loses the read-only identity, which the npub pasted again restores, rather than listing one npub twice under two ids. forgetNostrKey becomes forgetNostrCredential and removes an entry of either kind; NotABareKey becomes NotACredential and still means a mnemonic. The startup screen's third branch is here rather than in Phase 3 because StoredIdentity is sealed and the compiler asked for it: a read-only identity is active the moment it is read, as the nsec one is. NostrSecretViewModel reads the secret entry and answers a public one with "no key for this identity", which the screen it belongs to will never show once Phase 5 hides the row. Tests: the writer's upgrade in both directions -- the nsec of a read-only key accepted under the same id, the npub of a secret refused -- and forget of either kind; merge listing all three kinds, the shared id of a public key and its secret, and the seed-over-public precedence. Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library's master as 84cc44c with an identical tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@5efeae769f |
||
|
|
cf43973b36 |
feat(identity): a key the identity may not have
Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see. Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a constructor field, because the kind this plan adds -- NostrPublic, a bare public key -- has nothing to derive a pubkey from. A data class would let the two disagree, so two checks in init refuse an identity whose kind and key disagree about whether there is one, and one whose key does not derive the public key it was given. Identity.signing derives the pubkey and refuses the read-only kind; Identity.readOnly builds the other; nothing else constructs one now. The id of a read-only identity is toWalletId() of the x-only key -- the same id its nsec would have -- which is what will let a read-only identity become a signing one and keep its preferences. canSign is a property of the identity rather than nostrPrivateKey != null at each site, so that a signer with no local key has one place to answer. Every reader of the key already reached it through ?. on a nullable identity, so the compiler forces nothing here; the sites that need a decision are the plan's Phase 5, found by reading. The one site that did change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an else that meant "mnemonic": both are exhaustive now, so the new kind is not handed the phrase option by default. Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the same id and pubkey as the secret one, the routing case for it (by its account, like any other), and the three refusals plus the factory's own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@15766596e4 |