2f542408c96cac05621918aedb46e0257e80179f
136 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2f542408c9 |
docs(subgroups): the dialect catalogue is in the tree now, so cite it again
Written while the catalogue was on an unmerged branch, so three citations were replaced with the reasoning stated directly. It landed on mantra during the build and the merge brought it in, so `DialectScaffold`, `AfricanLanguages` and docs/african-dialect-catalogue.md are all here. The restated reasoning stays rather than turning back into links -- a plan that explains its own rule survives the file it borrowed it from being moved -- but two cross-references are worth having. `DialectScaffold` is the closest thing in the tree to `SubgroupOrigin`, and `DialectScaffold.pendingIn` is Phase 7's `ceremoniesFor` solving the same race one layer up: read what is already in flight, so two admins looking at the same screen do not propose the same thing twice. Eighty dialects there, two rooms for one project here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b8d08ebf64 | Merge branch 'mantra' into claude/subgroup-convenience-function-31b1b2 | ||
|
|
e5d966bc6a |
docs(subgroups): what the implementation chose differently, and one floor that cannot move
The deviations table every doc in here carries, and it is the most-read part of them. Seven entries, of which three are worth the reader's time. `proposeBirthCertificate` was planned to take an `origin` parameter; it already takes the whole `DkgSession`, so it reads `key.origin()` and nothing in the repository or the ritual view model changed. A parameter would have been a second source for one fact, and the proposer's copy of it at that. Phase 3 put a button on four screens and Phase 7 replaced all four with one `LazyListScope` extension, so they were written twice. The giveaway was already in Phase 0.3, which argued the dialect's entry point had to be a screen because a card cannot *grow* into a list -- the same argument applied to all four, and the section should have been planned where the buttons were. And Phase 8's own claim was wrong: the M3 adaptive floor cannot be raised by this work. The floors count `MaterialTheme.breakpoint`, `BoxWithConstraints`, the pane scaffolds and the navigation components; `readableContent()` and `NavigateBackButton` are neither, so a screen that follows every rule in CLAUDE.md adds nothing to either count. Raising a floor to the number the tree happens to sit at would make it fail the next time something unrelated was removed. The floors guard two-pane layout and the navigation shell, which a detail screen is not, and they stay where phase 6 of the conformance work left them. Recorded rather than quietly dropped, because the next plan will want to make the same assumption. Pointers from docs/README.md, from docs/subgroups.md beside the multiple-parents line, and from docs/project-feature.md -- where a project's own room was the first thing that file said it lacked, and which now says the rows stay in the parent's tables all the same. Phase 8 of docs/subgroups-for-entities.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c17a5be894 |
docs(subgroups): a plan for making a subgroup from a dialect, an artifact, a translation or a project
Four entities, one shape: `id`, `name`, `chatRoomId` and a per-type kind. So the convenience function the title asks for is four field reads, and recording that is most of what this doc does -- the rest is the part that is actually missing. Nothing in the app can say which subgroup a project's room *is*. A name pre-filled into the picker gets a room related to its entity by a string somebody typed once, which is the same bargain a dialect already loses: nothing in the schema makes a dialect unique, so its identity is `(language, country)` and never its name. So the link goes on the birth certificate -- the one artefact in the relationship that is signed, travels, and is already read on both sides -- as an optional `subgroupFor` tag the parent's quorum signs along with the name it already signs. Eight phases, one commit each, one migration. Phases 1-3 are a shippable release on their own and are exactly the cheap version of this feature; 4-7 add the tag, the `DkgSession` columns that carry it past the ceremony, and the reverse view that makes it worth having. Two things Phase 0 found by doing what it says: A dialect has no detail screen, so it is Phase 2 and not a button on a card. The first draft said the opposite; the reversal is recorded in the doc rather than edited out, because the argument against the card is the argument for Phase 7 -- a `ListItem` in a `LazyColumn` has nowhere to put a list of the subgroups made for an entity, and a dialect is the entity a room is most likely to want several rooms for. And the schema number collided. 23, 24 and 25 are taken on an unmerged branch; this takes 23 anyway, because a number is free relative to the merge base and pre-allocating 26 moves the renumbering rather than avoiding it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19543df263 |
Merge branch 'mantra' into claude/curated-kmp-ios-build-plan-ec280d
The african-dialect line, nine commits, against the four landed ios phases. No conflict: the only file both sides touched is docs/README.md, where each added its own table row, and mantra left the library pin alone. The merge is worth a note anyway, because it could have undone Phase 3. That phase took the commas out of 84 commonTest names to get linkDebugTestIosArm64 past Kotlin/Native's "Name contains illegal characters: ','", and mantra arrives with five new commonTest files. They happen to carry none, so the invariant holds -- but it holds by luck rather than by anything enforcing it, and the next branch to merge may not be so tidy. jvmTest 1221, testDebugUnitTest 599, the library's 159, and the m3 audit's ten counts all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
46ccec3439 |
fix(scripts): stop m3-touch-targets.py reading comments as code
A KDoc line on the dialect picker's row composable, explaining that it used `Card(onClick = …)` rather than a `Modifier.clickable`, was counted as a bare clickable chain. The budget is zero, so a sentence about the rule broke `./gradlew check`. The comment was reworded to get around it, which was the wrong fix -- the sentence was right -- and it is restored here. Not a new rule. m3-spacing-positions.py skips `\s*(//|\*|/\*)` and m3-title-case.py skips the same three prefixes as a startswith tuple; only this checker was missing the two block-comment ones, having been written with `//` alone. m3-audit.sh explains the whole problem at NOT_A_COMMENT, where a note about a hardcoded colour once counted as a colour. The same function had the mirror bug, which is the dangerous direction. It looked for minimumInteractiveComponentSize in a two-line window without stripping comments, so a comment above a real bare `.clickable` that merely mentioned the modifier would have excused it. A false positive is noise; a silently excused offender is a miss. Both lines of the window are now read as code only. Checked against six cases: a bare clickable is still caught, a guard on the same line and a guard on the line above still excuse one, a starred KDoc no longer counts, and a real offender under a comment naming the modifier is now caught. Counts for the tree are unchanged -- 0 before and after -- which was worth establishing, since a broken detector would also report zero. A prefix test rather than block tracking, and the limitation is written on the function: a `/* ... */ `body line not starting with `*` still reads as code. There are 539 block comments under ui/ and not one such line, because every KDoc here stars its body, and three identical rules spelled three ways would be worse than one limitation on the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
55c8f92cd3 |
docs: record what the first four ios phases actually did
Phases 0 to 4 are on this branch and verified on linux; 5 needs two decisions and an asset, 6 and 7 need a mac. Each phase's text is left as it was planned, with a Landed note where what happened is worth knowing. Three findings the plan did not predict. The three library bumps touch no Kotlin source anywhere in the chain -- eleven build scripts in secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE afterwards, which reads like a stale cache and is not one. That bump also carries four fixes beyond the stub archives, the only consequential one being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's own 37. And two of the 84 test names needed rewording by hand, where the comma had been carrying real work rather than pausing. Also records that `-Xdisable-phases` is in but unproven -- no KotlinNativeLink task exists here for it to match -- and the team id Phase 5 is expected to use, which still wants confirming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a998cf20a9 |
docs: the plan for an ios build, phased against the route Curated walked
Curated reached a device in eight commits. Five defects stand between this tree and the same place -- the native chain not configuring on a mac, Room with no way to construct the database on native, a cancellation Swift cannot see, two separate ios link failures, and CIO having no TLS on kotlin/native. Each is diagnosed upstream, so the work is porting a known answer. Three places this tree cannot simply cherry-pick: the test-name sweep has to run over Mantra's own 84 names in 35 files (ten of Curated's 38 files are from lines Mantra never pulled, and Mantra has eight Curated does not); the fork's "linux cinterop" fix is `if (!currentOs.isLinux)` and is really the fix that makes a mac work; and `@ConstructedBy` changes what Room's KSP generates for every target, not only the one it unblocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dd405e90e5 |
test(dialects): guard the catalogue against a proposal's two limits
DialectCatalogueSizeTest, modelled on ChapterProposalSizeTest and measured the way the manager measures it -- the real encoding rather than an estimate. Neither limit is checked anywhere a reviewer would see it: proposeSigningBatch enforces both with a require, so the first sign of the catalogue outgrowing a batch would be a wave that split in two, or one that threw. Somebody adding a language has no reason to be thinking about FROST. The third assertion changed shape while being written. "A wave is small rather than just legal", as a fraction of MAX_PROPOSAL_BYTES, failed: the catalogue encodes to 16,832 bytes and a quarter of the budget is 16,384. The quarter was a guess. What actually wants guarding is the cost of one dialect -- 301 bytes, bounded here at twice that -- because that is the number that would drift if a dialect event grew something large, and unlike a fraction it does not move every time a language is added. The size test's failure message says not to trim the catalogue. The view model already chunks by MAX_BATCH_SIZE, so a larger catalogue is two sessions rather than a bug; what would be lost is a group adopting the whole list answering once, and that is a decision rather than a repair. Also records the plan as built, with a table of the seven places the build chose differently, and indexes it in docs/README.md. One of those entries is a finding rather than a choice: m3-touch-targets.py reads comments as code, so a KDoc explaining that a row used a clickable Card "rather than a Modifier.clickable" broke a zero budget and with it ./gradlew check. The comment was reworded, which is the wrong fix. m3-audit.sh already solves this for the colour check and says why, at NOT_A_COMMENT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9d34a2cc99 |
feat(dialects): a catalogue of the languages most spoken in Africa
Fifty-six entries -- fifty-three African languages and three European ones -- for populating a group's dialects without typing each into a three-field form. Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md. The unit is a language *in a country* rather than a language, because that is what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili is three entries and that is the point rather than a duplication. (code, country) is the identity and the name is deliberately not part of it. Nothing anywhere makes a dialect unique -- the id is a content hash including createdAt, there is no index, and the rows are quorum-signed so they cannot be rewritten -- so duplication has to be prevented at proposal time, and of the three fields the name is the only one that is a matter of spelling. Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than 639-3 throughout: a uniform `sot` would disagree with the `st` that every room which used the form has already signed. Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai); 639-1's `lu` is Luba-Katanga, a different language of the same country, and taking the two-letter code because it was there would have named the wrong one. And the three European languages are a list of their own rather than lines buried in the African one, so dropping them is one edit. It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string literals and title case, and "Northern Sotho" is title case for the same reason m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of reach is cheaper and more honest than an m3-string-exempt marker on a file that is not UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5105d8bd3c |
Merge mantra into the chapter uploads branch
Thirty-eight commits had landed on mantra since this branch left it. Six conflicts, all of them two sessions adding to the same place: the string catalogue, the docs index, and the four files the projects line and this one both touched. Only one needed a decision. Both sides added parameters to ProposedEvent.summarize -- chatRoomId and resolve for naming a project's owner, chunkCount for saying how many chunks travel with a chapter -- so the signature is the union, with chunkCount last so nothing positional on either side moved. FrostSigningScreen and ProposalListViewModel pass all three, and the signing screen's summary now names the thing, resolves its ids, and counts the batch. The submodule pin is identical on both sides, and mantra's schema v20 to v22 does not collide: this branch adds no entity and no migration. 1170 tests, no failures; m3Audit's budgets all met. 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
|
||
|
|
306b8bb150 |
test(chapters): the five gaps a coverage pass found, and two vacuous tests
A pass counting tests per file rather than per feature found five pieces of this work with none. One of them mattered. MAX_PROPOSAL_BYTES was enforced and never tested, on either side. The inbound check is the half that counts -- the proposal is the one place a remote party decides how much work everybody else does -- and it had nothing at all. Four tests now, propose and accept, bytes and count, in SignedGroupKeyStateTest where the adversarial inbound tests belong. The count cap's accept side was untested too, although the propose-side test's own comment already claimed otherwise. Two of those tests passed vacuously as first written, which is the part worth keeping. acceptProposal rebuilds every event from its own fields under the room's path and drops the whole proposal if an id does not match, so a hand-built event with an invented id is refused whatever its size -- and an assertNull then stays green with the guard deleted. authoredProposal fixes it by taking its events from real proposals, so their ids are the ones the manager computes, and a control asserts that a well-formed proposal of the same shape does open a session. That control failed on its first run, which is how the vacuity was found; commenting the guard out now fails exactly the test named after it. UploadChaptersScreen had no UI test, against a project with fifteen runDesktopComposeUiTest files that tests other screens this way. Ten now: the cost of an upload stated before the picker opens, the index and name per row, every per-row reason rendering, and the FAB counting only what it would actually propose rather than how many files were picked. canPropose, kibibytes and the chapter summary had none either, each being read only by a composable. Eleven, five and five. Still open, and not closable here: the three FilePicker actuals need instrumentation this project does not have, and on iOS a host that can compile them. 1030 tests, no failures; m3Audit's budgets all met. 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> |
||
|
|
a0bad7c8da |
docs: the chapter uploads plan has been built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1673ce0e5e |
test(chapters): an upload end to end, and the callback it found missing
Phase 9 of docs/chapter-uploads.md. ChapterUploadJvmTest drives the view model over a real database: five files become five sessions, one chapter leading its own chunks in each -- never two chapters sharing a batch -- numbered contiguously from zero, which the old `.size` numbering could not do because nothing is signed while the pass runs. Each chapter's chunks are the blocks of its own file, markers off. Four chapters in a three-dialect group ask for one scaffolding session of twelve rows, not four. It found one thing: propose() with nothing proposable returned without ever calling its callback. The FAB is disabled in that case so the screen never reached it, but a callback that sometimes never fires is a trap for the next caller, so it now fires onDone(0, 0). That is the whole reason for writing the end-to-end test rather than assuming the parts composed. One thing it deliberately does not test the way the plan asked. "A failure on file 4 leaves files 1-3 proposed" is unreachable by input: both caps are checked in the form against the same functions the manager enforces, and ChapterProposalSizeTest asserts the form's estimate is never under the manager's measurement, so no file passes the review and then fails the proposal. It is reachable by state, which is what the test does -- a room with no shared key fails all three, every row says so and stays retryable, and a second pass after the key arrives proposes all three from zero, the failed pass having spent no indices because it created nothing. 995 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
aceef313ed |
docs: record what building projects changed about the plan
Five places the build chose differently, and one thing the plan did not anticipate: a project reaches a chronicle only off the GroupSignedEvent record, because the deprecated rebuild has no `toProjectEvent` and deliberately never will. `recordSignedEvents` catches its own failure so a ceremony that succeeded is not reported as failed, which leaves a narrow window where the row exists and the signed event does not -- a project this device can show and can never hand on. There is a test holding that case open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c76359b34 |
feat(chapters): a chunk is a markdown block, not a blank-line paragraph
Phases 7 and 8 of docs/chapter-uploads.md. Measured against markdown-it -- the parser mantra.press translates with -- this app's blank-line split disagreed on ten cases in eleven, and on the text as well as the boundaries. markdown-it's content is a leaf block's inline source with the BLOCK marker removed, so `# Chapter one` is "Chapter one" while inline markup is kept. That is the half that matters to a translator: handed "# Chapter one" they can break the document by mistyping the hash, and handed "Chapter one" they cannot. So splitBlocks adopts it, boundaries and stripped text both, with three deliberate departures. Code blocks yield no chunk -- markdown-it's fence and code_block carry content, so mantra.press asks a translator to translate `code();` and the group to sign it as prose. A table is one chunk of its own source rather than one per cell, because four envelopes to carry one word each is a poor trade and a table's alignment is structure per-cell translation cannot preserve. And a link reference definition yields nothing, which is free: it is markdown-it's own behaviour with the `reference` rule left enabled, where mantra.press disables it -- reaching for a block rule to stop links rendering -- and makes a translatable chunk of a URL. Hand-rolled, not org.jetbrains:markdown, for one reason specific to this function: its output is hashed into ids a quorum signs, so a version bump that moved one boundary would silently re-split every chapter proposed afterwards and nothing would notice. What pins it instead is thirty golden fixtures generated FROM markdown-it by docs/scripts/markdown-block-fixtures.js, which also encodes the three departures so the Kotlin test is plain equality. MarkdownProducer is new and is what makes stripping markers safe: nothing in the app could put a `#` back before. It substitutes by index, which is strictly better than the thing it mirrors -- mantra.press matches on text, so a chapter with two identical paragraphs puts the first one's translation into both. The scanner segments every line rather than only the chunks, which is what makes the byte-for-byte round trip exact by construction rather than by getting every markup rule right. ProposedEvent.summarize stops re-splitting a chapter to count its chunks and takes the count from the batch. That number is a count of the batch dressed as a fact about the text: across a splitter change the figure in the approval gate would disagree with the events being approved, and it re-scanned every open proposal's prose on every emission of the room's list. Phase 2's constant was flagged as provisional pending this, and measured it did not move: prose separated by blank lines splits the same under both rules, so the fixture pinning 24 KiB over forty paragraphs is the shape this does not touch. The narrower claim holds -- a heading- or list-heavy chapter now reaches both caps sooner, visibly, in the form's own counter. 987 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8a0ee1cdb |
test(chapters): twelve proposals each keep their own summons
Phase 6 of docs/chapter-uploads.md, which asked for three things to be checked rather than assumed. All three already hold, so this is their tests and the record of it. The proposal list already groups: a "Waiting for you" header over the sessions isAwaitingApproval picks out, "Everything else" under them, over a query already ordered newest first. The chapter summary is already `name - N words - N chunks` rather than "Chapter", so twelve rows are twelve distinguishable rows. And isAbout already compares frostSigningSessionId when both rows carry one, falling back to the clock only when either does not. The last of those is the one with a history -- two concurrent sessions broke it once, which is why the column exists -- so it is now tested at twelve, with the answers given in the opposite order to the asking, so that neither the clock nor the list order could produce the right answer by accident. Plus one proposal failing and settling only its own row, which is the transcript's version of why an upload is N sessions and not one. Noted for Phase 8 rather than fixed here: twenty open chapter proposals is roughly 900 KB of unsignedEventJson parsed and summarised on every emission of that query, and summarize then re-splits each chapter's text to count its chunks. Phase 8 replaces that derived count with the session's own item count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
428026cdfd |
refactor(chapters): one scaffolding batch for every chapter, not one each
Phase 5 of docs/chapter-uploads.md, and the third fix to something wrong today. AddChapterViewModel was right that translation scaffolding needs its own session -- a chapter's paragraph budget should not be spent on the group's dialects, and how long a chapter may be has nothing to do with how many languages it is read in. It was wrong that it needs one session per chapter, which only becomes visible above one chapter: twelve chapters in a group with three dialects is thirty-six join rows, which fit in a single batch, where the per-chapter shape opens twelve sessions to carry three rows each. Twenty-four proposals where thirteen will do. ChapterScaffolding.propose is now the one answer to what a chapter's scaffolding is, and both view models call it. Two copies of a cross product would be two chances to disagree about what a translation covers. It also fixes a latent bug one chapter could never show. The old code chunked the *translations* by MAX_BATCH_SIZE -- right by coincidence at one chapter, where the cross product and the translation list are the same length, and wrong at any more. 25 chapters in a three-dialect group is 75 rows, which the translation axis would have put in one batch of 75 and proposeSigningBatch would have thrown. Chunking the product is the generalisation, and the test for it is the only one here that fails against the per-chapter shape. Two smaller decisions. The batch takes one timestamp, the latest of the chapters' own, because that is the moment the act finished and the clock would be a third time unrelated to either end of it. And a session whose lead event cannot be read is left out rather than guessed at: a join row naming the wrong chapter is worse than a missing one, because the catch-up can find a missing one. Failure policy unchanged and for the reason it already gave: logged and swallowed, since the chapters are what was asked for. At N the argument is stronger, not weaker -- one cross-product batch is only as available as its worst item. 970 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
de4f15794a |
docs: a phased plan for projects, and the four keys it has to re-answer
The platform's server schema has had `project` and its two join models since before this app existed. Bringing them here is not a port: the server answers "whose project is this" out of a table of accounts, and this app is a group with no accounts, no server and no authority but a quorum of its own members. So every foreign key on `project` is re-answered. The creator becomes a `creator` tag and the owners a set of `p` tags with an owner role -- written into the template before it is proposed, so they are inside the id the quorum signs rather than one device's note about who inserted a row. The owner of record is still structurally the room, and `chatRoomId` carries that; the tags are what let a project be the group's work and somebody else's property. Campaign is out of scope entirely. Two things the plan is mostly about, because neither is recoverable from the code. The nip30303 kind 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 -- so projects take 30330-30332 and the collision gets written down. And `ContributorTag.parse` drops a role that is not in `KNOWN_ROLES`: the tag parses, the role comes back null, the string stays inside the signed event id, the event still verifies, and the project lands with no owners. Nothing is logged. `ROLE_OWNER` reaching that set is Phase 1's first line, and three tests point at it. Ten phases, 0-7 to build and 8-9 deferred. The one decision to make before Phase 1 is the scope fork: a project's contents are same-room, with real foreign keys, because the FK is what makes a bad proposal fail loudly. Cross-room is Phase 9 and follows `parentChatRoomId`'s precedent -- a plain column, never a foreign key, because a dangling value is the normal state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
83bd91f46c |
fix(chapters): number a chapter past the ones already proposed
Phase 3 of docs/chapter-uploads.md, and the second fix to something wrong today. A chapter's index is signed into the event and cannot be recounted on arrival: a device applying one cannot recount from a version other members may have added to in a different order. So the number has to be right when the chapter is proposed, and `getChaptersForArtifactVersion(...).size` is not it -- a proposed chapter is not a row until a quorum signs it, so two proposals a second apart read the same table and are signed at the same index. At one chapter at a time that is a race nobody hits. An upload of twelve files hits it eleven times out of eleven. The information was already published. A session's items carry the unsigned events it is signing, and an unsigned event's fields are settled at proposal time -- which is the whole reason FrostSigningRepository.unsignedEvents exists rather than only signedEvents. So ChapterIndexing.nextIndex reads the table and the room's proposals, and returns one past the highest rather than a count: a count is wrong the moment the sequence has a gap, and a chapter that failed to reach a quorum leaves one. Three things that are easy to get wrong and are tested. Every session counts whatever stage it is at, because filtering would trade a harmless gap for a collision with a stalled session that still completes. Items are filtered by kind, because a chunk carries an index too -- its position in the chapter -- and reading those as chapter indices puts the second chapter of a two-paragraph first chapter at 2. And proposals are scoped by the artifactVersionId tag, because the row query is scoped by version while observeSessionsForChatRoom returns the whole room. This fixes one device, not the group: two members proposing at the same moment still collide, and no amount of local reading will change that. That is tolerable where this was not -- an index orders reading, two chapters at index 4 sort arbitrarily between themselves, and nothing else breaks. 951 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dbc7940618 |
feat(frost): a signing proposal has a size, and the form says what it is
Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today. MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how large they are, and those are not the same limit: sixty-four dialects are a few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is the largest message in this protocol by an order of magnitude -- a signer's nonce payload is k*133 bytes, their partial k*65, the proposal k whole events -- so a legal batch could exceed what a group event carries, and when it did the failure was silent: the proposal went out and the session stalled with nothing to blame. MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way: when proposing, against the real encoding, and independently in acceptProposal, before parsing. That second check is the one that matters, for the reason the count check already gives -- the proposal is where a remote party decides how much work everybody else does. The chapter form needed the same limit early enough to be useful, which the exact answer cannot be: it needs the chapter built and the chunks cut, one line from opening a session. So ChapterProposal estimates it. Measured over chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an encoded proposal runs 357-388 bytes; the estimate pays 448, because over-estimating costs an author a shorter chapter and under-estimating costs the group a stalled session. A test asserts it never falls under the real encoding and stays within a tenth of it. The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter about 30, because the envelopes are what varies. The chunk count is known as the text is typed, so the limit is too. While the screen was open, its last two hardcoded English literals moved to the catalogue, and the too-long message compares like with like -- what proposing would send against what a group message carries -- rather than a UTF-16 character count against a byte budget, which is what the first draft of it did. 944 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ab4f0e557 |
docs: a phased plan for chapter uploads, group-signed
Mirrors mantra.press's multi-file chapter upload under this app's rule that the group, not the uploader, authors the work. The web app's shape -- N files in, N chapters out, one transaction, one redirect -- is the one shape this app cannot have. A signing session is one quorum, all-or-nothing, 64 items and about 64 KiB on the wire, so twelve chapters in one batch does not fit, should not fit, and would ask every member to read twelve chapters in a single approval gate. One upload is N sessions, one per chapter, and that cost belongs in front of whoever is picking files. Reading both trees turned up four things the single-chapter path gets away with at N = 1 and an upload does not: the index is read from rows and no proposed chapter is a row yet, so twelve proposals all sign the same index; nothing checks the proposal's size, which puts a silent ceiling near 28 KiB of markdown; there is no file picker in the app at all; and translation scaffolding opens a session per chapter where one batch would do. The splitter is measured rather than assumed. Run side by side, markdown-it's block tokens and this app's blank-line rule disagree on ten cases in eleven, and on the text as well as the boundaries -- markdown-it strips the block marker, so `# Chapter one` is `"Chapter one"`. Phases 7 and 8 adopt it, with three deliberate departures (no chunk for code blocks, one chunk per table, none for a link reference definition) and a producer, which the app has never had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7a28b227d9 |
docs: record what the adoption plan built, and the eight places it chose differently
The note shipped as research and is now five commits of code, so it says so, in the shape docs/subgroups.md uses: the phases kept as written because they are the reasoning, and a table of where the build disagreed with them. Eight entries, and two are worth more than the rest. **A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent** -- author, subject and named parent one room, the id deriving from the key, the signature verifying. The note never considered it. The self-parent guard began inside the offer branch, where it never ran, because the child branch catches such a certificate first and reads it as an ordinary child. A test caught it, and the gap predates this work: `subgroupsOf` would list the group as its own subgroup today. **The certificate's roster.** The note was careful that a founding certificate's name and `p` tags are the founding roster, frozen, and said nothing about adoption -- where there is no founding. The adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now, and the current roster is the honest value rather than a compromise. Still frozen the moment it is signed, still never read as live. The rest are smaller and all one shape, which is a note counting a thing and missing its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that travelled through four files to reach its only writer; `stateFrom` returning "a state and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that is also a plain data class, so the pure verifier builds one with no database near it. One thing the note asked for is **not** done and cannot be: the key-state summary saying which parents are being *dropped*. A summary is pure and a disavowal is a difference against the row this device holds, so it belongs to the screen. The rough-size table becomes estimated against actual. It guessed about two dozen files and it was 39, with the UI three times its estimate -- a picker needs a route, a screen, a view model and a state, which the note had as one line -- and the repositories half of it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files, 53 new cases. `docs/README.md` moves the note from research to a phased plan that has been built, and its one-line summary now ends on the self-certificate rather than on the consent gap, which is closed. Docs only -- no code changed, so there are no test counts of this commit's own. The five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d97ba7228b |
docs: reconcile the adoption note with several profiles and a broadcast button
The seventy-one commits left the subgroup code byte-identical, so the first pass over
this note changed only a schema number. That was too quick a reading. None of what
arrived is *about* subgroups and four pieces of it bear on this design anyway.
**A built, unreachable path would put a birth certificate on public relays.**
`BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member
picks, and its own comment names the property that makes it dangerous here: the
signature is the group's, so any member holding the event can send it and no share of
the key is needed. It is not restricted by kind -- "the seed is the same for every
event of every kind" -- and nothing opens it today only because the rows that carried
the button went with the curated lists. The route is still registered and
`BroadcastButton` still built.
A certificate is the worst event in the app to send: the parent-child lineage, the
child's threshold key, and the founding admin roster in its `p` tags, which is a
membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with
everything that never leaves an encryption, and
`SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable
semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on
the child's room id, so relays would start treating certificates as replacements of
one another. So the note now carries a negative rule: the subgroups list and the parent
cards must never grow a broadcast button. It is also why relays are not the answer to
the overlap requirement, which they otherwise look like -- the price is publishing the
lineage and the roster to anyone, to save one person being in two groups.
**"One person in both groups" has a second reading now, and the mechanism wants
care.** The same human can be a P2 admin under one profile and hold C's share under
another, which satisfies both steps with no single key in both groups. `ChatRoom` is
keyed by the group id alone --
|
||
|
|
acbf089378 |
docs: stop the adoption note naming a schema version, since two landed under it
Seventy-one commits of `mantra` merged in -- npub and nsec sign-in, several profiles on one device, three pulls from the Curated fork, a profile preview and a FAB sweep -- and none of it touches subgroups. `docs/subgroups.md`, `SubgroupManager`, `GroupKeyStateManager`, `GroupKeyStateEvent`, `nostr/subgroup/`, `GroupKeyState` and `ProposedEvent` are byte-identical, so the design stands as written. The only `FrostSigningManager` change is a comment, recording that putting a signed event on relays is now a member's separate act from `BroadcastGroupSignedEventScreen`. **The note said schema 19 -> 20, and 20 was taken while it sat there.** 19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`; 20 is the several-profiles pull's, putting an owning identity on the queues. This is the second time the number has moved under a note that is not being built yet -- it was 18 -> 19 when first written three weeks ago -- which is twice too often for a figure nothing depends on. So it now says to read the version off `MantraDatabase` at build time, and the test line that named one does not. Everything else re-checked against the merged tree and unchanged: the four parent-indexed DAO reads are still test-only, `ChatRoom.parentChatRoomId` still has no production reader, `describe(purpose, path)` still takes no parent, and `GroupSignedEvent` still declares its foreign key onto `ChatRoom` -- the last of which is load-bearing for this design, since it is what forbids a child device storing a certificate signed by a group it is not in, and so forces the certificate to ride inside the key state. `docs/README.md` merged without a conflict: the row for this note sits where it was put, under the subgroups row, and the seven rows the sign-in and pull notes added append below it. Merged through the app's host sync rather than `git merge`, because the merge brings in a `.gitmodules` line -- `branch = master` on the lightning-kmp-app submodule -- which the sandbox is not meant to write. Docs only -- no code changed by this commit, so there are no test counts to report. The merged changes have not been built here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3372023829 | Merge remote-tracking branch 'origin/mantra' into claude/subgroups-multiple-parents-053be4 | ||
|
|
76055bc0a8 |
docs: record the third pull, the profile preview and the extended FAB
Eleven commits reached the fork after the second pull had landed, and this note is their inventory, verdicts and record: the profile-preview line pulled whole, the extended-FAB sweep pulled with its seven removed screens resolved as still deleted, and d2a4298d -- one commit half in the curated-entries line Mantra took out and half in the broadcast line it kept -- pulled in part, by a PARTIAL rule that applies the kept paths and writes the rest into the note. Three things differed from the pulls before it and are set out: a brand token the normaliser could not see, NotYetOnCurare, fixed by a rule and a second rewrite that left the earlier forty-nine commits hash for hash the same; a merge whose join files carried only the dropped half, so that for the first time a join was not taken from the merge; and two conflicts the compiler found where git saw none, an import Mantra's projects section still needs and a jvmTest fixture recreated reduced to the room so that an upstream test lands byte for byte. The record is two phases: jvmTest 871 -> 909 -> 914, testDebugUnitTest 420 -> 424, every audit budget met at both cuts, the schema unchanged at version 20, and an exactness check whose every movement is named. The README gains the row and a sentence; the second plan points forward. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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
|
||
|
|
d7d6f5a922 |
chore(scripts): a partial pick, and the seven deletes the FAB sweep needs
Two rules for the third pull. ca6c16be moves twenty screens onto one ExtendedFab; seven of them -- the curated-list and group-identity screens -- were taken out with lines B, D and H, so each is a modify/delete conflict that resolves as "still deleted", the DELETE rule it already had for one test file, now for a set. d2a4298d is half in line D and half in a line Mantra kept: its Broadcast-all button on the entries screen is gone here, but the sendToRelays it lifted into the broadcast view model's companion, the RelayOutcomeLabel widget and the screen reading it are the broadcast line Mantra holds. PARTIAL applies the diff restricted to the kept paths, commits it under the original message and trailer, and writes what was left out into the note, so the four shared files stay identical to the fork's and the FAB commit lands on them cleanly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a8ba589ecc |
docs: record what the profile preview plan built, and the places it chose differently
Two strings that already existed, a room rule that follows the old code's intent rather than its branch, a retry that never takes an open chat back to checking, the flag the ordering cases asked for, and the test harness the other view models use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@b3bcc06047 |
||
|
|
69aa43a108 |
docs: plan a profile preview before a chat, starting from what the button is allowed to do
The "Direct message via npub" option goes straight from a pasted string to an MLS room. The plan puts a screen between them, keyed by public key because that is all the dialog has, and keeps the room's creation where it is: four decisions -- where the preview lives, what "found" means when a row can be a placeholder, that the button hands over rather than creates, and that it waits for the key package so the twenty-second dead end is answered before the press -- and four commit-sized phases with the tests for each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@db268a53d3 |
||
|
|
0728f3f969 |
chore(scripts): teach the normaliser the brand inside a CamelCase identifier
The fork's profile-preview line names a Readiness state NotYetOnCurare, and the normaliser's \bCurare\b rule cannot see a brand that has no word boundary in front of it: the first rewrite of the eleven new commits left seventeen occurrences of it in code, tests and the note. One rule, for a brand preceded by a lower-case letter or digit and followed by an upper case letter or the end of the word, maps it to NotYetOnMantra. Only the Curare generation gets the rule: "Curated" is product vocabulary at the tip (CuratedSchemaEvent) and a CamelCase-embedded Curated is therefore not a brand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
213535278a |
docs: record what the profiles pull built, and the reverse direction measured
The plan's top now says it was built, the same day it was written: the ten commits in four phases at the dry run's numbers exactly, Phase 6's two native items done, and its other three -- pushing the removal, the two reverse-pulls, the fork decision -- named as the user's. Phase 6 gains its own Built paragraph: what the refusal changed and did not change in the DAO's welcome branch, the four re-pointed anchors, and a measurement the plan had only asserted -- that a Mantra commit offers itself to the fork cheaply. git format-patch on the refusal commit, four inverse rules over the patch text, and git apply --check on a scratch worktree of curated/curated at 29027f2b: it applies cleanly, five files. That is the shape a normaliser running the other way would take, and it is recorded so the fork can take the commit in seconds rather than by hand. The README's reading-order sentence and the first plan's pointer say "built" where they said "planned". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2620e43d57 |
docs: re-point the profiles note's four line anchors at Mantra's tree
The note arrived with the pull and anchors four of its links to line numbers -- three into MantraNavHost.kt and one into DatabaseNostrRepository.kt -- that were the fork's. Mantra's nav host is 264 lines shorter than the fork's, having lost the group-identity block in the removal, and its repository grew a constructor parameter in Phase 6 of the same plan, so all four pointed a few lines wide of what the sentence around them describes: the key read off the current route (now :404), the sign-in and create tails (:741 and :537), and the pump reads that used to drain whatever was pending (:118). They describe the tree before the line was built, which is what that section of the note is about, and each still lands on the thing described; only the numbers moved. Phase 6, item 2, of docs/curated-to-mantra-profiles.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
310d3bbbd3 |
docs: record Phase 5 of the profiles pull, which completes the ten
9bb34004, 88577bb5 and 29027f2b landed as |
||
|
|
ba5ef72385 |
docs: record what the multiple-profiles plan built, and the places it chose differently
Phases 1–8 are implemented, in order, one commit each; Phase 9 is the rollout and stays as written. The plan's phases are kept as the reasoning, and the table at the top says where the build chose differently: the repair reads before it writes and hands the listing its result; one WalletAttached outcome with two ways in; the node stop and the relay scope injected for their tests; the default save that cannot crash; a ProfilesViewModel over flows; a NewProfileWriter and a route flag where the plan expected the create screen's existing writer to serve; the colliding id on the outcome rather than on the enum; the DAO's own requests left unowned because they fetch public kinds; the inbox reopened by re-indexing each wrap in its own transaction rather than by lifting the unseal branch out; and the round trip's A made through the create view model with a never-started PhoenixBusiness for the switch to stop. Three things found on the way and in no phase are recorded beside the table: the library's unsynchronised global-preferences cache, reached by two threads at once for the first time, now behind JvmGlobalPrefs on the jvm target; the same cache's consequence for tests that construct the sovereign view model; and the gift wrap seal's link to its wrap, a foreign key that existed and was never written until the inbox sweep asked the question it answers. The README's row and reading order say the note is built, and name switchToIdentity where they named switchToWallet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@29027f2b92 |
||
|
|
b92659fe92 |
docs: record Phase 4 of the profiles pull, the migration to version 20
759199f2 landed as
|
||
|
|
ae8cde889e |
docs: record Phase 3 of the profiles pull, and the one resolution
Four commits landed as |
||
|
|
d6d87ed208 |
docs: record Phase 0 and Phase 2 of the profiles pull
Phase 0 needed one thing this plan did not predict in detail: the worktree's library checkout sat at 01489b8, an older master whose object store did not even hold the pinned 84cc44c, while its nested chain was already at the pinned four. One fetch from the main checkout's submodule and a detached checkout made the tree clean. The rewrite was rebuilt from the fork point and produced the same forty-nine rewritten commits as the dry run, hash for hash: filter-branch with the normaliser is deterministic for the same input, so the dry run's numbers are the run's numbers rather than an estimate of them. Pushing the removal to origin/mantra is the user's step and is recorded as still open. Phase 2 landed a5ff2641 and 3008137e as |
||
|
|
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 |
||
|
|
44324c902b |
docs: plan several profiles on a device, starting from what a profile is
The switch already exists as a state transition -- switchToWallet, resetToSelector, a null identity sent to startup with popUpTo(0), every collector a child of collectLatest -- and is reachable from nowhere: Landing shows only when the device holds no identity, so a second can never be added, and the profile tab's "change account" is a pending route. This plan builds the two entrances and fixes what the transition gets wrong. Before either, it changes one rule the two sign-in plans share. A seed's nostr key is not written to the credentials file; it is derived from the words at listing and from the running node at activation, and the writers keep one key out of two files. That puts the wallet where the profile should be: the list is a merge of two files with opposite ideas of what a row is, and the node has to run for a profile to know its own key. Phase 1 makes a profile a credential and a seed a wallet attached to one -- the credential written when the seed is, repaired into the file for every seed already on the device, merge inverted to list credentials and attach seeds by pubkey, the identity's key read from the credential with the node's as a cross-check, and a second refusal on forget for a key a seed derives. The node still starts for a profile with a wallet attached, for the channel watcher rather than for the key; making it lazy is now one branch and is named as its own decision. The other decision is that a switch is a restart of the signed-in graph, not a swap under it: every route carries the key it was pushed for. That settles the switcher as a pushed screen behind one tap, the previous node stopped, the last-used profile as the one that opens on launch, and a profile added from inside switched to. Nine phases: the credential; the switch, with the relay observer that never cancelled its predecessor and the node that kept running; the startup precedence, which put "show me the list" above "open this one" and never saved a default; the switcher, showing the nostr profile rather than "Default name" and labelling a row with a wallet attached; the add rows, where a profile created from inside is a bare key and not a second wallet, and "end this" -- which wipes every profile's database -- goes; an owner on the two fetch queues and an inbox sweep on activation, because a gift wrap fetched under the other profile's key is stored and never opened again; the exits; a round trip; rollout, with the one downgrade that lists a seed's profile twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a5ff264164 |
||
|
|
5f2414dcb5 |
docs: plan the second pull from the fork, with its dry run already done
The upstream moved the day the first pull landed: ten commits on curated/curated, 86cb876b..29027f2b, that let a device hold several profiles and move between them, and with them the fork's first schema migration, version 20. The first plan named them the next pull and said the schema change earned a decision of its own. This is that plan, and unlike the first it was written after the dry run rather than before it, so its numbers are measured: rewrite from the fork point in 103 s, ten commits replayed onto |
||
|
|
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
|
||
|
|
0fa807a6fb |
docs: record what the pull built, and the seven places it chose differently
Phases 0 to 5 of docs/curated-to-mantra.md are landed on this branch: thirty
commits pulled from curated/curated, each carrying a `Pulled-From` trailer
naming the commit it came from, on top of Phase 0's two native ones. The plan
is kept as written and its status line now says what happened, with the table
the house keeps for a plan that has been built -- what it said against what
it turned out to be -- and the README's sentence about it follows.
**Seven differences, and none of them are corrections to the plan's
decisions.** The three decisions -- drop the brand line but keep its
reasoning, keep Mantra's sections by dropping 808a3459, take the curated
lists -- all held, and the measured predictions about them (the screen's
order, the tests, the audit) came true to the number. What changed was
mechanics: the phased replay is a cherry-pick per commit rather than a rebase
of each cut, because a recreated merge cannot reach a parent that was replayed
in an earlier phase; the library pin follows the commit rather than staying
put, because the compiler said the nsec line does not build against 84cc44c;
the join files are taken from upstream's own merges rather than unioned, for
three reasons that each took a failed attempt to learn; and the exactness
residual is seventeen files rather than sixteen plus a logo, because Mantra's
own side had moved too. Each is written in the table with the reasoning
beside it, so the next pull starts from what happened.
**The numbers are per phase, so a regression later can be placed.** jvmTest
736, 864, 905, 1,007, 1,039 and testDebugUnitTest 403, 486, 495, 530, 530
across Phases 0 to 5; both compilers clean and every m3 audit budget met at
each; the final jvmTest executed rather than restored from the build cache,
1,039 tests in 46 seconds of test time, 0 failures. The plan's own dry run
predicted 1,036; the three extra are 39fb64b6's pin of the library's
nostrPublicKey() against NIP-06.
**What is not done is named rather than implied.** Phase 6's two reverse-pulls
--
|
||
|
|
89e364c1bd |
chore(library): base the credentials branch on the library's master
The Phase 2 library commit was made on a branch cut from 59c11ed -- the nsec key-store commit, which is what the app's curated branch pins -- but that commit is not the library's default branch's tip: master is at e51e3ae, the merge of PR #1 that brought 59c11ed in. The two trees are identical, so the rebase is content-free; what changes is that claude/nostr-credentials is now one commit ahead of master rather than a sibling of its tip, and the app's pointer follows it to 84cc44c. The plan said the nsec library commit "sits on a remote branch called detached", which was true of where it was found and false of where it is: it reached master through the PR. What is still true, and still the rollout's one hard step, is that it is untagged -- the library has no tags at all -- and JitPack consumers resolve by tag. The two sentences that said otherwise are corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@00c36ec509 |
||
|
|
042c5db5f4 |
docs: record what the npub sign-in plan built, and the twelve places it chose differently
Phase 8 of docs/npub-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each a decision worth reading before touching the code it describes -- the startup branch one phase early because the sealed type asked for it, the migration's three outcomes, the DAO guard that was belt-and-braces on paper and load-bearing for two phases, a null identity answering "can sign" with true because nobody is not read-only, and a round trip run against a real database with a contrast case so that "nothing was signed" is known to be a claim the harness can refute. One finding that belongs to no phase is recorded with it: a compose test looking for a floating action button's label has to search the unmerged tree, or its "does not exist" is vacuously true. And the one rollout fact that is not process: the library commit is on claude/nostr-credentials at 01962f3, bumped in by Phase 2, and like the nsec plan's before it has to be pushed and tagged before any of this leaves the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@f866b9d17b |
||
|
|
10df01f4b7 |
docs(npub): one credentials file, not two, and the write that makes the upgrade atomic
The plan's first design kept public keys in a plaintext file beside
nostr-keys.dat, app-side, so that nothing touched the library. Review asked
why the key file was not simply made a credentials file with a read-only
kind, and the answer is that it should be. The upgrade -- pasting the nsec
of a key held read-only -- was the one operation spanning both files, so it
was two writes with a crash window between them and a "secret wins" rule in
merge to repair it; with one file keyed by pubkey it is a single write that
replaces {type: public} with {type: secret}. Forget collapses to one path
with it. And the advantage that paid for the two-file design was worth less
than it looked: the library commit that introduced nostr-keys.dat is
untagged, so a credentials format rides the tag that work already owes.
Phase 2 is rewritten for nostr-credentials.dat: a typed entry per pubkey in
the same envelope, renamed rather than versioned in place because an older
build's listIdentities returns on SerializationError before it publishes
anything and would list no wallets at all; a migration that reads the v1
file once and deletes it, since a frozen copy would resurrect a forgotten
key on a downgrade; and the one upgrade that still spans two files -- a
recovery phrase over a public credential -- with its order stated. The
header, Phases 3, 6, 7 and 8, the estimate and the appendix follow, where
the two-file design now sits as the rejected alternative with the reason.
One overclaim of the revision's own is corrected in it: the library does not
use a class discriminator anywhere -- its cloud payloads pick a variant by a
version field -- so the plan says the discriminator is chosen here, not
inherited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@dc8a1091d3
|