21d57eba542c4fb02828df583d458440e339caa3
625 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
21d57eba54 |
feat: honour the platform's contrast setting, reaching four schemes that were dead code
Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium-
and high-contrast variants of both themes since it was generated -- 156 colour values,
wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected.
`TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who
turned contrast up in Accessibility settings got no change at all.
M3's accessibility foundation leads with *honour individuals*: "supporting varying
preferences and choices that allow individuals to address how their changing conditions,
individual knowledge, and varying needs are met." The work to do that was already done and
disconnected.
**The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme`
took four arguments and did two unrelated jobs -- decide the contrast-free light/dark
scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have
meant passing six schemes across the boundary and repeating the selection table in three
actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each
answering one narrow platform question, with the six-way table as a plain function
`appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null
rather than falling back internally so the fallback stays in one place.
**Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is
API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped
to the nearest of the platform's three documented positions rather than matched exactly, so
a future finer-grained slider degrades to the closest scheme this app has instead of
falling back to Standard.
The `ContrastChangeListener` is the part that is easy to leave out and matters most. A
contrast change does not restart the activity and does not arrive as a `Configuration`
update, so without it the new setting would take effect on the next cold start -- which is
precisely the case the setting exists for. `context.mainExecutor` rather than
`ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and
composeApp does not declare androidx.core -- it only arrives transitively through
activity-compose, which is not a dependency to lean on.
**iOS observes the notification for the same reason** --
`UIAccessibilityDarkerSystemColorsEnabled` plus
`UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a
slider, so iOS reports High or Standard and never Medium.
**Desktop is honest rather than complete.** Windows publishes high contrast as the
`win.highContrast.on` AWT desktop property and fires a property change when it is toggled,
so that path is real and live. macos "Increase contrast" and the linux desktop equivalents
do not reach AWT, and reading them means a native call per platform, so on those two the
answer is Standard and the file says so. This is the right place for a user-overridable
preference later; a desktop app cannot always see what the desktop was told.
**Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off
(see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip
for now" label as `settings put secure contrast_level` moved:
standard (0.0) #848484 onPrimaryContainerLight
medium (0.5) #A7A7A7 onPrimaryContainerLightMediumContrast
high (1.0) #D0D0D0 onPrimaryContainerLightHighContrast
The three declared values exactly, and **the app was not restarted between them** -- only
the setting changed, four seconds apart. That is the listener working end to end. The probe
that switched dynamic colour off is reverted in this commit; the emulator's contrast_level
is back at 0.0.
**A finding that came out of the verification, and is not fixed here.** `TorchTheme`
defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally --
so on essentially every current Android device **none of the six schemes is used at all**
and the app renders in whatever the user's wallpaper produced. The first screenshot of this
session shows the onboarding screen in Material lavender; switching dynamic colour off
reveals the black-and-gold brand for the first time. Nobody on a modern Android has been
seeing this app's palette.
That is a product decision, not a conformance one, so it is recorded in the plan's "What
this plan does not cover" rather than changed. It does bound what this commit buys: on
Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the
`system_*` resources shift with it, confirmed on the same emulator -- buttons went
slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13,
iOS, and desktop.
**Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its
failure mode is silent and specific: a scheme wired to the wrong cell still renders a
complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back
cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of
the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells
on the same scheme would pass the first test only if it also mislabelled one), and that
`onSurface` on `surface` never *falls* as contrast rises -- the one direction that must
hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest
explains is false.
**Not compiled: the iOS actual.** The ios targets are declared only on macos (see
docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings
rather than checked by a compiler. Its file comment says so. The android and jvm actuals of
the same two functions are compiled, and the android one is verified on a device.
**Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build,
`m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched --
the new parameter is defaulted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
86c9628eee |
fix: assign every ColorScheme role, so no component can fall back to Material lavender
Phase 1, step 1 of docs/material-design-conformance.md. `Theme.kt` assigned 36 of the
49 roles `androidx.compose.material3.ColorScheme` declares. The other thirteen took
`lightColorScheme()`/`darkColorScheme()` defaults, and for twelve of them that default
is the Material baseline palette: `primaryFixed` -> `ColorLightTokens.PrimaryFixed` ->
`PaletteTokens.Primary90` -> **#EADDFF**. Lavender, in an app whose primary is
`#000000`, in both themes, in all six schemes.
Nothing in the tree reads a fixed role today, which is why nobody has seen it. That
also means it could not have been found by looking at the app -- it springs the first
time an expressive component reaches for one, and it will look like a rendering bug
rather than a missing assignment.
**The tones were computed, not chosen.** M3 defines the family by tone: `xFixed` =
tone 90, `xFixedDim` = 80, `onXFixed` = 10, `onXFixedVariant` = 30, and ColorLightTokens
and ColorDarkTokens carry identical values for all twelve -- theme-independence is what
"fixed" means. Tone is CIE L*, so for a chroma-0 palette a tone is exactly the sRGB grey
at that L*, and inverting L* -> Y -> sRGB reproduces this palette's own greys **to the
byte**:
tone 0 #000000 primaryLight
tone 10 #1B1B1B primaryContainerLight, onSurfaceLight
tone 20 #303030 onPrimaryDark, inverseSurfaceLight
tone 40 #5E5E5E inversePrimaryDark
tone 80 #C6C6C6 primaryDark, inversePrimaryLight
tone 90 #E2E2E2 onSurfaceDark, surfaceContainerHighestLight
tone 95 #F1F1F1 inverseOnSurfaceLight
tone 100 #FFFFFF onPrimaryLight
Eight independent hits. The primary and tertiary palettes are the standard M3 neutral
tonal palette at chroma 0, so their fixed families are derived rather than invented.
**The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is maximum
in-gamut chroma at that hue.** Generating tones off that ramp regenerates
`onSecondaryDark` (#3D2F00, tone 20) and `secondaryLight` (#745B00, tone 40) byte for
byte, which is what licenses using it for tones 10 (#241A00) and 30 (#584400).
Its tones 90 and 80 are **reused rather than regenerated**. The palette already ships
#FFDE82 at tone 90 (as `secondaryDark`) and the brand gold #EFBF04 at tone 80 (as
`secondaryContainer`, identical in light and dark -- someone hand-set it, no generator
emits that). Regenerating would have produced #FFDF99 and #F1C100: a second gold two
units from the one already on screen, indistinguishable in isolation and wrong beside
it. A near-duplicate brand colour is worse than none.
**Sanity check on the whole derivation.** The four ratios these families produce land
within 0.1 of M3's own baseline fixed family --
onFixed on Fixed 13.30 (baseline 13.32)
onFixedVariant on Fixed 7.17 (baseline 7.23)
onFixed on FixedDim 10.08 (baseline 10.08)
onFixedVariant on Dim 5.44 (baseline 5.47)
-- because tone, not hue, sets the ratio. Two palettes with nothing in common landing
on the same four numbers is the check that the tone mapping is right.
**Containers hold across the contrast setting; content darkens.** That is the move
`Color.kt` already makes everywhere else -- `onSurfaceLight` goes #1B1B1B -> #111111 ->
#000000 while `surfaceLight` stays #F9F9F9 through all three -- so the fixed family
follows it: content tones 10/30, then 5/20, then 0/10. The weakest pair ladders
5.44 -> 7.73 -> 10.08. Shifting the containers instead would have moved the brand-visible
half for a setting that is about legibility.
**`surfaceTint` is the thirteenth, and it was never a defect.** Its default is `primary`,
which is correct: `surfaceColorAtElevation` composites it over `surface` at 2-8% alpha,
so an elevated light surface darkens toward primary and an elevated dark one lightens --
M3's own behaviour, and this app sets no elevations anywhere, so nothing reads it. It is
assigned explicitly anyway, with that reasoning in a comment, so that "every role is
assigned" is a property a reader can check by looking rather than by knowing which
omissions were deliberate. m3-audit.sh reports the two kinds apart for the same reason.
**Three new assertions, and the two that matter cannot be satisfied by accident.**
`ColorSchemeContrastTest` grows from 4 to 7:
- both content roles on both fixed containers at 4.5:1, across all six schemes;
- the fixed roles are the same colour in light and dark, which is the definition and
would otherwise only fail on a screen that puts one beside a themed surface;
- no role is left at the Material baseline palette -- the twelve baseline hex values
read out of `PaletteTokens.kt` and asserted absent.
Verified by deleting `primaryFixed = primaryFixed,` from `lightScheme` alone: two tests
fail, naming the role and printing back `Color(0.917, 0.866, 1.0)`. Reverted.
**Audit budget ratcheted 12 -> 0**, dated in the file. Per the header's contract that is
the only direction a budget moves, and the commit that lowers it is the one that earns it.
**Tests.** 920 pass, 583 jvm over 70 classes and 337 android over 42, up from 914/580/337
-- three new assertions counted once per target. `:composeApp:compileDebugKotlinAndroid`
builds, `m3-audit.sh --check` exits 0. No visual change: every role that had a value keeps
it, and the thirteen that gain one were rendering baseline defaults nothing reads yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2b0ce8d73b |
test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test
Phase 0 of docs/material-design-conformance.md. Every count in that document was produced by hand, which makes the eight phases after it opinions rather than work with acceptance criteria. This is the harness that turns them back into numbers. **`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.** Plain invocation reports; `--check` exits 1 when a budget at the top of the file is exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare `.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the contract written into the header is that they ratchet **down**, in the same commit that earns the reduction, and are never raised. Counts a phase has not reached yet are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which point a raised budget is the diff a reviewer is looking for. Verified both directions: `--check` exits 0 on the clean tree, and appending a single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget. **Two counts are reported apart from each other on purpose.** Thirteen ColorScheme roles are never assigned in Theme.kt, and reporting that as one number would overstate it. Twelve are the `*Fixed*` family, which default to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a monochrome app renders Material baseline lavender the moment anything reads one. The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a defect. The script labels the first group "lavender" and the second "not a defect". The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines. `DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in the commit that lengthens it. **`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.** Four assertions over all six declared schemes: every content role on its container at 4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1. WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4 exponent, not a gamma-2.2 approximation, because the approximation moves borderline pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1. `Theme.kt`'s six schemes went from `private val` to `internal val` so the test can see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s public values -- keeps production visibility untouched and was rejected: it would assert the palette and miss the wiring, and the wiring is the half that fails silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character slip, compiles, and reads fine in review. A comment above the first scheme says this, so the keyword is not quietly widened back. **Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to `#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is 2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged. **Monotonicity across the contrast ladder is deliberately not asserted.** The obvious invariant -- high-contrast beats medium beats default for every pair -- looks right and is false. Ten pairs move the other way, and correctly: in the light high-contrast scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19 from default to medium for the same kind of reason. Asserting the ladder would have meant either a red test or nine exemptions; the floor is the real invariant and every one of those values is comfortably above it. The test's doc comment records this so the next reader does not add the assertion. **Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads 1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline sits in the same range and the role is a decorative divider, so `outline` is what gets the 3:1 assertion. The seven call-site pairings that are genuinely below threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding them now would mean checking in a red test. **Doc reconciled to the script rather than the other way round.** Three hand counts were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp` literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*` typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`, which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten from a plan into what was built, including what was decided against. **Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from 906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid` builds. No app behaviour changes: the only production edit in this commit is `private` -> `internal` on six vals. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7c41992ea7 |
fix: hold the wallet state where a background thread can be heard, so a device with no seed boots
Since
|
||
|
|
f301a924fd |
fix: build a release apk, by dropping the app's copies of what the library already ships
`:composeApp:assembleRelease` dies in `mergeDexRelease`:
Type com.machankura.compose.ui.composable.widgets.nfc.ComposableSingletons$HceMonitorKt
is defined multiple times:
composeApp/build/intermediates/project_dex_archive/release/dexBuilderRelease/out/...
lightning-kmp-app/library/build/.transforms/.../bundleLibRuntimeToDirAndroidMain_dex/...
Both paths are ours. composeApp compiles a class, lightning-kmp-app's `:library`
compiles the same fully-qualified name, and D8 will not merge the two into one
apk. Debug never objected, because it packages the per-project dex archives as
they stand and only the release merge walks the whole set looking for
collisions -- so this has been true for a while and surfaced the first time
anyone asked for a release. There were two such copies, and fixing the first
only uncovered the second.
**The NFC widgets were renamed by directory, not by package.**
|
||
|
|
224d6009dc | Merge branch 'mantra' into claude/marmot-profile-metadata-loading-e2a067 | ||
|
|
e6524e201d | Merge branch 'mantra' into claude/proposals-signature-card-090e46 | ||
|
|
f146afd49e |
fix: keep asking who a Marmot group's members are, instead of once and never again
A member of a Marmot group shows as "LOADING..." and stays that way. The same
member in a NIP-17 room starts as "LOADING..." and then turns into their name.
The difference is not that Marmot forgets to ask. It asks exactly once, and
NIP-17 is the one that gets asked again.
**"LOADING..." is a row, not a spinner.** Every pubkey this device sees gets a
Profile row immediately, because Participant.participantPublicKey and
ChatRoom.userPublicKey are both foreign keys onto Profile and nothing can be
filed until one exists. What gets written is a placeholder:
Profile(
displayName = "LOADING...",
publicKey = ...,
createdAt = GENESIS_AT,
nostrEventId = nostrEvent.id, // Will get overwriting by sync,
)
GENESIS_AT (1231006505000L, the Bitcoin genesis block) is the marker: a row
carrying it has never been read off a kind:0. `NostrDao.indexNostrEvent` writes
one, `MarmotInboundManager.processGroupMembershipChanges` writes one, the Welcome
branch of `NostrDao.indexNostrEvent` writes one,
`NostrDao.getOrCreateNip17ChatRoom` writes one. Each of them then queues the
kind:0 request that is supposed to replace it. Each queues it once.
Once is a whole lot of load-bearing. `Relays.DefaultDMRelayList` is
`listOf(ephemeral)` -- one relay -- so "ask the relays" is one negentropy
reconciliation against one host, at whatever moment the pubkey first appeared. If
that host has not got the member's kind:0 yet, that is the end of the enquiry.
**NIP-17 gets a second chance twice over.** Opening a NIP-17 room runs
`ChatMessageListViewModel.scheduleSynchronization`, which fetches each
participant's kind:10050. That request is queued at level 0, so the kind:10050 it
brings back is *indexed* at level 0 -- and the top of `indexNostrEvent` says:
} else if (profile.createdAt == GENESIS_AT && level == 0) {
logger.i("This is a placeholder profile... that might need to get synced...: $profile")
...
profilePublicKeysToSync[relayURL]?.add(nostrEvent.pubKey)
}
which queues the full `profileEventKinds` set, kind:0 included. So the name
arrives on the bounce: we asked for a relay list, we got an event that member
signed, indexing it noticed the placeholder was still there, and it asked again
for the profile. Any other event of theirs we happen to index does the same thing.
**A Marmot group has neither half.** The first half is gated off explicitly:
if (localChatRoom.chatRoom.mlsGroupState == null) {
which is the whole body of `scheduleSynchronization`. Opening a Marmot room asks
for nothing, by construction -- and reasonably so on its own terms, since an MLS
room does not need a member's kind:10050 to address a message to them.
The second half cannot fire, because a Marmot member never authors anything this
device indexes under their own key. A kind:445 is signed by a throwaway keypair
minted for that one event (`MarmotOutboundDao`, two sites: `NostrSignerInternal(KeyPair())`),
and the real sender is inside the MLS frame, recovered in `indexMarmotGroupEvent`
as `mlsGroup.memberIdentityHex(it.senderLeafIndex)` -- long after the pubkey check
at the top of `indexNostrEvent` has already run against `nostrEvent.pubKey`. That
check does fire on every kind:445; it just fires on the throwaway key, mints a
placeholder for a key that will never exist again, and queues a profile sync for
it. The member it is standing next to is not looked at.
So: one ask at the Welcome (or at the commit that added them), and then nothing,
ever, for the life of the room. Lose that one ask and the room is full of
"LOADING...".
**Two smaller holes, same shape.** Both Marmot mint sites test `profile == null`:
val profile = database.profileDao().getProfileByPublicKey(newParticipant.participantPublicKey)
if (profile == null) {
// create placeholder AND queue the sync
}
A placeholder is not null. A member we already hold one for -- seen in another
room, or removed from this one and added back -- takes the `false` branch and is
never queued at all. Not even the single ask.
**The change.**
- New `nostr/MemberProfileSync.kt`. Picks out, from a set of rooms, the members
nobody has read a kind:0 for -- missing row and placeholder row treated the
same, ourselves excluded because our own profile is not something a relay
teaches us -- and builds the kind:0 requests for them. Authors are chunked 100
per filter: a relay may refuse a filter it thinks is too big, and one refusal
should not take every member down with it. Requests go out at level 0, which
is deliberate: it is what marks a request as one somebody is waiting on, and
it is what lets the arriving kind:0 pull the rest of the member (DM relay
list, key packages) in behind it via the placeholder branch quoted above.
- `LiveSubscriptionManager.queueCatchUpSynchronization` now also asks about every
member it cannot name, across every room on the account. This is the main
repair. It is the right home for it: the foreground catch-up already holds the
room list (it was fetching it for `groupIdsFrom` and throwing the rooms away
-- `liveGroupIds` is gone, the rooms are kept), it already exists to answer
"what did I miss", and running there covers the chat list, the member lists
and the message feed at once rather than one screen at a time. It re-runs on
every foreground, so an ask that comes back empty is retried rather than lost.
- `ChatMessageListViewModel.scheduleSynchronization` asks too, for both kinds of
room, before the NIP-17-only relay-list block it already had. This closes the
gap between foregrounds: join a group while the app is open, and the names
resolve without backgrounding it first.
- `MarmotInboundManager.processGroupMembershipChanges` and the Welcome branch of
`NostrDao.indexNostrEvent` now treat a placeholder as unresolved. The
placeholder insert still only happens when there is no row (it is an @Insert
and would throw on conflict); it is the *ask* that now happens either way.
**Left alone, deliberately.** The `mlsGroupState == null` gate below the new code
stays: kind:10050 genuinely is NIP-17-only, and an MLS room's messages go to the
group's own relays. The placeholder minted for a kind:445's throwaway signer is
untouched -- it is waste, not a bug, and removing it means deciding what
`indexNostrEvent` should do with an event whose author is by design nobody, which
is a bigger question than this. `DefaultDMRelayList` being a single host is left
as it is; widening profile lookups to the directory relays (purplepag.es,
user.kindpag.es, directory.yabu.me are all already in `Relays`) would find more
kind:0s than asking one relay repeatedly, and is worth doing on its own.
**Verified.** `:composeApp:compileDebugKotlinAndroid` builds. `:composeApp:jvmTest`
is green: 576 tests over 69 classes, including 7 new ones in
`MemberProfileSyncTest` covering placeholder-vs-null, self-exclusion, a member in
several rooms counted once, the filter shape (kind:0, level 0, one request per
relay) and the 100-author chunking.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
73cba2ae0e |
feat: say at the foot of the transcript what the group is still waiting for you to sign
The transcript carries a proposal past as it happens, and |
||
|
|
ff1ecc1194 | Merge branch 'mantra' into claude/torch-intro-boot-hang-76b0a8 | ||
|
|
11ab8892f0 |
fix: let startup finish, rather than leave it for an intro that was never built
Sometimes the app boots to "Introducing... Torch" and stays there. That string is not a screen. It is the `text` of a LoadingRoute that SovereignWalletStartupScreen navigated to whenever a preference called showIntro was true, and the one thing that was supposed to move the user off it had been commented out since |
||
|
|
e8a07aadf7 |
Merge branch 'mantra' into claude/proposals-view-navigation-9f3a58
Brings in ten commits: `ChatRoom.joinedGroupAt` and the pre-join indexing gate
with schema v15, the home list's last-message preview and its `chatRoomId`
index at v16, the membership transcript lines, and the widening of the notary's
third queue.
No conflict. mantra touches two of this branch's four files and meets neither
change. In `ChatMessageListViewModel` its edits are a `MEMBERSHIP_TYPES` branch
in the items loop and three icons and a tint in `RitualNotice`; this branch's
are the constructor, `hidesOtherDecisions` and `observeProposalsAwaitingYou`.
In `ChatRoomDetailScreen` its edit is the reindex report's wording at the foot
of the screen, a couple of hundred lines below the button that moved.
**The membership branch sits in front of the signing one**, which is worth
checking rather than assuming: both are early returns in the same loop, and
order decides which of them claims a row. Neither can claim the other's.
`MEMBERSHIP_TYPES` is the three invite types and `FROST_TYPES` the eight signing
ones, disjoint sets, and a membership line's `onClick` is `{}` -- it leads
nowhere at all, so it never reaches the routing this branch changed.
**Nothing mantra deletes can take a signing line.** `MIGRATION_14_15` deletes
transcript rows, which is exactly the sort of thing that could quietly empty
the transcript this branch routes from. It cannot: the delete is scoped to
`ChatMessage.UNRESOLVED_MARMOT_TYPES`, which is `TYPE_UNDECRYPTABLE_OUTER_LAYER`
and `TYPE_PENDING_COMMIT` -- placeholders standing in for events that were never
read -- and no FROST type is in that set. Every other line, the signing ones
included, is the final word on its group event and is left alone.
**The pre-join gate and the proposal count agree by construction.** A signing
message is a kind:445 like every other event in a Marmot group, so
`NostrDao.indexMarmotGroupEvent` holds back the ones a group published before
this device joined, and a session proposed before then leaves no rows here at
all. `observeProposalsAwaitingYou` counts sessions rather than lines, so there
is nothing for it to over-count: a member added mid-signing is not told they owe
a decision on something they cannot see, and the transcript is not asked for a
line about a session that was withheld from it. The two changes needed no
reconciling because they are answering about the same withheld events from
opposite ends.
Verified after the merge rather than assumed from before it:
:composeApp:compileDebugKotlinAndroid succeeds; 884 tests pass -- 565 jvm and
319 android, up from 808 because 76 of them are mantra's. This branch adds
none, for the reasons its own commit gives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b97732643b |
fix: put proposals in the room that signs, and open the queue when one row is not all of it
The Proposals button sat behind `mlsGroupState == null`, so it was offered in
NIP-17 rooms and withheld from Marmot ones. That is the wrong way round rather
than a gap: FrostSigningManager signs "in its #admins room", and an #admins
room is a Marmot group.
**Where a group actually proposes.** `FrostSigningManager.broadcast` writes a
signing message as an unprocessed `MarmotInnerEvent`, which `NotaryViewModel`
MLS-encrypts and broadcasts as a kind:445 -- "the same path every other event
in a Marmot group takes, which is why this needs no transport of its own". A
NIP-17 room holds no MLS state for that path to use, and since
|
||
|
|
acaf13fcd6 | Merge branch 'mantra' into claude/marmot-group-message-queue-1ecaed | ||
|
|
30c732af67 |
Merge branch 'mantra' into claude/home-chat-previews-e1913f
Two conflicts, and both are the same collision: mantra took schema v15 while this branch was also calling its migration v15. **The version number.** mantra's v15 adds `ChatRoom.joinedGroupAt` with a manual MIGRATION_14_15, because half of what it does -- deleting the placeholder chat lines already written for messages sent before this device joined -- is not a shape Room generates. That is the older claim on the number and it keeps it. The index migration here becomes `AutoMigration(from = 15, to = 16)` and the database goes to v16, so a device that has already run v15 gets the index on top of it rather than the two fighting over one version. `15.json` is resolved to mantra's wholesale -- an add/add conflict between two unrelated schemas is not something to merge line by line -- and 16.json is regenerated from the build. Checked rather than assumed: 16.json differs from 15.json in exactly one place, `index_ChatMessage_chatRoomId`, and no table's fields, createSql or other indices move. **mantra's new membership lines needed handling here**, and nothing would have told me: `98f766f` added `TYPE_MEMBER_INVITED`, `TYPE_MEMBER_INVITE_SENT` and `TYPE_MEMBER_INVITE_FAILED`, which the transcript renders as system notices. The chat list preview dispatches on the same question the transcript does -- is this somebody's words -- and a type missing from that check falls through to the chat bubble branch. A room whose newest line was an invite would have previewed as "Alice: Invited Bob to the group", which reads as Alice having said it. Exactly the failure `ChatMessage.MEMBERSHIP_TYPES`' own comment warns about, one screen over from where it was written. So `MEMBERSHIP_TYPES` joins the ritual and chronicle sets in `lastChatMessagePreviewText`. They are not in the AUTHORED sets -- their content is a whole sentence with the invitee's name already in it -- so they stand alone, which is what the transcript does with them too. One new test, over all three types rather than a representative one, since the set is the thing being relied on. Nothing else needed reconciling. mantra's pre-join fix filters at indexing time and deletes the rows outright, so the last-message subquery sees fewer rows and needs no `memberSince` clause of its own to stay in step with the transcript. 550 jvm tests and 319 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5a22592c48 |
Merge branch 'mantra' into claude/member-invite-transcript-7a2d63
Brings in `ChatRoom.joinedGroupAt` and the pre-join indexing gate, plus schema v15. No conflict: mantra's only edit to `MarmotOutboundDao` is in `createMlsDirectMessageChatRoom`, stamping the new column as it builds the ChatRoom, and every line of this branch's is further down -- `inviteMember`, `addMembersToChatRoom`, `deliveryWelcome` and the three new announce helpers. The two changes do meet in one place, and it is worth saying why nothing had to be done about it. `MIGRATION_14_15` deletes transcript lines, which is exactly the sort of thing that could quietly eat the lines this branch adds. It cannot: the delete is scoped to `ChatMessage.UNRESOLVED_MARMOT_TYPES` and to rows whose `marmotGroupEventId` names an event older than the room, and a membership line is neither -- it is not a placeholder for an event still to come, and it has no group event behind it at all. Nor could it ever be in reach, because these lines are written by the *inviter*, whose own room has no epoch predating them. 828 tests pass -- 526 jvm, 302 android. The six new ones are this branch's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
950deb2288 |
fix: widen the last of the notary's queues, the one already known to stall
|
||
|
|
9107b81c99 |
fix: hand the notary the whole queue, not the one row standing at its head
A message sent into a marmot group sometimes sticks on the unsealed icon and
never leaves. It is not that message that is broken. Something ahead of it in
the queue cannot be sent, and because the notary was handed one row at a time,
that row was the queue.
**The queue.** MarmotInnerEventDao.observeUnprocessedMarmotInnerEvents selected
every unsent row for a key and returned `Flow<MarmotInnerEvent?>`, so Room
handed back the first one and dropped the rest:
SELECT * FROM MarmotInnerEvent
WHERE publicKey = :publicKey AND marmotGroupEventId IS NULL
ORDER BY createdAt ASC
The only exit from that queue is a successful send.
MarmotOutboundDao.encryptAndSendMarmotInnerEvent stamps the row with the group
event it became, inside the same @Transaction that writes the event, the
NostrEvent and the BroadcastNostrEventRequests. Nothing else clears
`marmotGroupEventId`, there is no attempt count, no failure status and no
sweep. A row that cannot be sent therefore does not move, and it is selected
again, and again, at the head of every later emission.
Note what the filter is: the sender's key, not the room. One room whose MLS
state is gone silences every group on the device.
**The stopper.** DatabaseMarmotRepository.encryptAndSendMarmotInnerEvent was two
nested `?.let`:
database.chatRoomDao().findChatRoomById(...)?.let { localChatRoom ->
localChatRoom.chatRoom.toMlsGroup()?.let { mlsGroup ->
...
}
}
A row for a room this device holds no MLS state for -- the shape a room
restored from an inbound gift wrap has -- fell out of both and returned. No
send, no throw, no log, and no mark on the row. So the queue manufactured its
own permanent head: a message that could never succeed, reported as though
nothing had happened, sitting in front of everything else forever.
MarmotOutboundDao.inviteMemberToChatRoom already throws
MarmotMissingChatGroupException on exactly this condition, with a comment
saying the point is to "say so instead of silently doing nothing and letting
the caller report success". The send path disagreed with the invite path about
the same missing group.
**distinctUntilChanged.** Both notary collectors sat behind it, which is the
wrong question to ask a work queue. A queue re-emits because its table changed;
that is the signal to look again, not a duplicate to discard. Comparing an
emission against the previous one asks "is this new work?" when the question is
"is there work left?".
The two queues then failed by different mechanics, which is worth writing down
because it explains why the symptom looks like a retry loop in one place and a
dead collector in the other:
- MarmotInnerEvent.equals compares `logger`, an @Ignore'd `Logger.withTag(TAG)`
initialised per instance. Kermit's `withTag` returns `Logger(this.config, tag)`
-- a fresh object -- and neither Logger nor BaseLogger overrides equals, so
two reads of one row are never equal. distinctUntilChanged suppressed nothing
here, and the notary spent the session retrying the stopper and never looking
past it. Correct behaviour by accident, resting on a field that is not part of
the row.
- GiftWrapPayload.equals is an honest value comparison with no logger in it. The
refused payload's re-emission compared equal and was dropped, so after one
refusal nothing on that queue was collected again for the life of the session,
whatever was queued afterwards.
**Why it reads as unsealed.** ChatMessageListViewModel picks its status icon off
three relations, in order: a broadcast receipt, a broadcast request, a
NostrEvent. A queued marmot message has none of them until the notary turns it
into a kind:445, so it falls to the last branch -- KeyOff, "Unsealed message
status". The icon is accurate. The message is exactly as unsealed as it looks,
and will stay that way.
**The gift wrap queue has it too, and a Welcome rides it.** MIP-02 addresses
kind:444 to a joiner who holds no group state and cannot read a kind:445, so
MarmotOutboundDao.deliveryWelcome queues one as a GiftWrapPayload deliberately
-- marmot traffic on the NIP-17 path. That queue had the same single-row shape
and no ORDER BY at all, so which row was "the head" was whatever SQLite
returned first. Two known refusals leave a payload there with `giftWrapSealId`
still null: sealGiftWrapPayload refuses outright to seal a non-Welcome payload
belonging to an MLS room (
|
||
|
|
473228bfab |
feat: show the last message and its time on the home chat list
The row was a centred column with the room's name in it and nothing else. It is now a row: the name and a one-line preview of what was last said in a weighted column on the left, the clock on the right. Both text lines are clipped to one with an ellipsis, the name included, and that is what keeps the clock on the row. A long subject, or a five-member group whose title is five names joined with commas, would otherwise push the timestamp off the edge; a multi-line message would push the next room down the screen. The clipping is on the title's own composable rather than the row, because that is where the three ways of building a title live -- a subject, "Note to Self", or the participant list -- and only one of them being clipped is the shape this would rot into. The clock is absent rather than blank for a room with nothing in it. There is no message time to show, and the room's own creation -- which is what it sorts on in that case -- is not something the user has any reason to read here. The preview line carries "No messages yet" in its place, which is the honest state for a room that exists because it was just made, or because a member joined a working group and is still waiting on the chronicle to fill it in. Everything the row renders was decided in the model, so this commit is the card body and the title clipping and nothing else. Verified: `:composeApp:compileDebugKotlinAndroid` builds, and the full `:composeApp:jvmTest` suite passes at 526 tests, 0 failures -- the 503 that were there before this branch plus its 23. The layout itself has not been run on a device; it is checked by compilation and by the model tests behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
81965ba2d3 |
perf: index chat messages by their room
The query in the previous commit reads the newest line of every room the user is in, once per room, and Room re-runs the whole thing every time a message lands anywhere. Without an index on `ChatMessage.chatRoomId` each of those lookups is a scan of every message on the device: work that grows with the entire history rather than with the room, on the hot path of every arriving message. A device with ten rooms and a few thousand messages does tens of thousands of row reads to redraw a list whose visible change is one line of text. Room has wanted this index since the foreign key was declared and has said so on every build -- `chatRoomId column references a foreign key but it is not part of an index. This may trigger full table scans whenever parent table is modified` -- which is the same warning it still emits for a dozen other `chatRoomId` columns. Those stay as they are; this one now has a reader that makes it matter. **Schema v15, and Room writes the migration itself.** Adding an index changes no columns and moves no rows, which is one of the shapes `AutoMigration` handles without a spec, so this is an entry in the list rather than another manual migration alongside MIGRATION_13_14. The generated 15.json differs from 14.json in exactly one place, checked rather than assumed: `index_ChatMessage_chatRoomId` appears on ChatMessage, and no table's fields, createSql or other indices move at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
50feb6fa0c |
feat: carry each room's newest line with the room, and say it in one line
The home screen listed rooms by name and nothing else, in whatever order SQLite
handed them back -- which for a query with no ORDER BY is rowid, so the list was
ordered by when each room was first written and never moved again. The room
somebody messaged an hour ago sat wherever it was created, indistinguishable
from one nobody has touched since March.
**The query.** `ChatRoomDao`'s room reads now LEFT JOIN each room's newest
`ChatMessage` and order on it. The join is a correlated subquery rather than a
`GROUP BY chatRoomId` with `MAX(createdAt)`:
ON lastMessage.id = (SELECT id FROM ChatMessage
WHERE chatRoomId = ChatRoom.id AND deletedAt IS NULL
ORDER BY createdAt DESC, id DESC LIMIT 1)
`MantraConverters` stores an `Instant` as epoch *seconds*, so lines written in
one second tie -- a ceremony puts a dozen into a room faster than that -- and
the aggregate form resolves a tie arbitrarily, which would leave a room quoting
whichever of its last three lines SQLite happened to reach first. `id DESC`
breaks it on write order, which is the order the transcript shows them in, so
the list and the room it opens agree about what was said last.
A room with nothing said in it sorts on its own `createdAt`. The alternative is
sorting it last, which buries a room the user just made under every conversation
they have ever had.
**The flow now re-emits on message traffic**, because the query reads ChatMessage
and Room invalidates on the tables a query touches. That is the point -- a row's
preview and its place in the order stay current without the list asking for
either -- but it is a real change for the other collector of this flow.
`LiveSubscriptionManager.followGroupMembership` maps to group ids through
`distinctUntilChanged()` before its debounce, so the extra emissions collapse
there and no relay subscription churns on an arriving message.
**The carried line is a `ChatRoomLastMessage`, not a `ChatMessage`.** Embedding
the entity would mean aliasing thirty-odd columns onto every room query, and
colliding with the room's own `id` and all four of its timestamps on the way.
Six columns are everything a one-line preview and a clock can be written from.
It is nullable, and every other way of getting a `LocalChatRoom` leaves it null
rather than paying for a join no screen reads. So a null there means "not asked
for" as often as it means "nothing said", which is why nothing hangs a decision
on it beyond what to draw.
**What that line is rendered as** follows the transcript's own dispatch in
`ChatMessageListViewModel`, because the two must not disagree about what a room's
newest activity was:
- a ritual or chronicle line is nobody's words. Its content is written as a
predicate for an actor's name, so the authored ones get that name in front
("Alice published their share") and the rest stand alone ("The group now has a
shared key"). A name in front of the latter reads as that member having
announced it, which is exactly the misattribution the transcript renders these
as system lines to avoid.
- a direct message with blank content is one this device cannot open. An empty
preview reads as the sender having said nothing, so the line says instead what
the group can in fact see: that a private message was sent, and to whom.
- anything else is somebody's words, prefixed with who said them -- except in a
two-person room, where the only other name is already the row's title and
repeating it says nothing.
Names resolve through the existing `HexKey.memberName` rather than a second copy
of that lookup, so they follow a rename and fall back to a shortened key instead
of dropping the attribution to nobody.
17 new tests. Seven run against a real SQLite, for the parts only it can answer
-- which row the subquery picks, the same-second tie, room scoping, a
soft-deleted last line, and where a room with no messages lands. Ten exercise
the preview text directly, one per shape above plus the unknown-sender fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fd9137ab5a |
feat: a chat list clock that says only as much as it has to
The one timestamp format this app had, `toFormattedTimeAndDateString`, writes "14:05 6 Sep 2026". That is right for a message bubble, where it is the only clock on a line the reader has already stopped at. A chat list row is not that. Its job is to place a message relative to now, in whatever width is left after the room's name and the preview of what was said in it -- and a full date on this morning's message spends all of that saying "today" the long way. So the new format gets coarser the further back it goes, and never coarser than the reader can still resolve: - today, the time of day. Anything less cannot order two of today's rooms. - yesterday, named. A date here is a small arithmetic problem to read. - the rest of the last week, an abbreviated weekday. It stops at six days because the seventh is this weekday again, and "Sun" on a message from last Sunday reads as today. - inside this year, day and month. Past a week the weekday has stopped saying anything. - beyond it, the year as well, for the same reason one rung up: day and month repeat. Reading a rung too far is the failure mode and it is silent -- nothing about "Sun" admits which Sunday it means -- so every boundary is a test. Each one is anchored to the local day rather than to a fixed instant, because the boundaries are local midnights and the test would otherwise pass or fail on the machine's zone. A timestamp ahead of `now` deliberately falls through to a date rather than a time. Relay clocks disagree and an event can arrive stamped in the future; rendering that as "14:05" files it under a today it does not belong to. `now` is a parameter defaulting to `Clock.System.now()`, which is the whole reason any of the above is testable without a clock abstraction. It is sampled once per composition, so a list left open across midnight goes on saying "14:05" until something recomposes it -- acceptable for a list that recomposes on every arriving message, and not worth a ticker to fix. Six new tests, one per rung plus the future case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
49c012bf8b |
fix: a member is not shown the messages sent before they were in the room
A joiner gets the MLS key schedule from their own epoch forward and nothing before it. The relay does not know that and hands them the whole room: negentropy syncs down every kind:445 the group ever published, `indexMarmotGroupEvent` read each one against the group, the outer layer refused, and every refusal wrote an `undecryptableOuterLayer` line. So the room a member had just been invited to opened on a screenful of "Undecryptable Message" above the conversation -- one per message the group had sent before they arrived, none of them ever readable, and the count only grows with how long the group had been talking. **The epoch of a kind:445 is inside the layer that will not decrypt**, so an event this device cannot read cannot be asked what epoch it is from. "From before we joined", "from an epoch we have not caught up to" and "from an epoch that fell out of the retention window" are indistinguishable from the outside, and only the first is permanent. What separates it is not the ciphertext but the clock: it was published before the group made the epoch we joined at. **`ChatRoom.joinedGroupAt` is that moment, written down.** The Welcome's `created_at`, which the inviter stamps as it mints the Welcome out of the Add commit that made us a member -- so it is the group's own account of when our epoch began, not this device's account of when it heard about it. A group this device created sets it to the room's creation; it was a member from epoch 0 and there is nothing behind it to hold back. Stored rather than read off `createdAt`, which today holds the same value in both paths. `createdAt` is row bookkeeping and this decides which of a group's messages a member is allowed to see at all; the two being equal is a coincidence of the current code, and hanging the second off the first makes a future change to when a room row is written into a change in what gets discarded. `memberSince` is `joinedGroupAt ?: createdAt`, so a room joined before the column existed gets the fix too -- and gets it from the value every path that sets the column would have written anyway. **`predatesMembership` draws the line strictly before**, and that is a judgement rather than a fact. Nostr stamps `created_at` in whole seconds, so the second the Welcome was minted holds both the commit that added us -- the last act of the epoch before ours, unreadable by construction -- and any message another member sent the instant they applied it. Only one of the two can be had. An unreadable event kept costs one refused decrypt; a readable event discarded is a message the member never sees. So the second is kept, and a room may still show a single placeholder for the commit that added its newest member. **Two places gate on it.** `indexMarmotGroupEvent` returns before touching the MLS group, so nothing is decrypted, no `MarmotGroupEvent` row is filed for ciphertext whose key this device never had, and no line is written. `reindexMarmotGroupEvents` partitions them out of the sweep entirely: a replay can say in advance that no pass will ever read them, so replaying them only spends a refused decrypt per sweep and reports every one as a failure on a room where nothing is wrong. `MarmotReindexSweep` is untouched apart from carrying the new count -- it decides how many times to go round, not what is worth going round for. **Schema v15, and the migration is the half that fixes devices already showing the bug.** Nothing rewrites a chat line that is already in the transcript, so fixing the write path alone would leave every member who joined a busy room opening it on the same run of placeholders forever. `MIGRATION_14_15` adds the column and deletes the lines: only the two types in `UNRESOLVED_MARMOT_TYPES`, and only where the group event behind them predates the room. Those lines say nothing by design -- they stand in for an event that was never read -- so removing one loses nothing, while every other line is the final word on its group event. The group events themselves stay; this is about what the room shows. The column is left null rather than backfilled from `createdAt`. Null already means "ask `createdAt`", and copying the value would turn a fallback into a claim this migration is in no position to make. It is manual rather than an `AutoMigration` only because of the delete: `ALTER TABLE ... ADD COLUMN` appends, which is where Room's own generated migration for a nullable addition puts one, and Room compares a table's columns by name rather than by position. **The reindex report stopped being true**, so it carries the number now. With the backlog held back, `unresolved` falls to zero and the screen said "Nothing to reindex - 30 event(s) all read" about a room where 27 of them were never this device's to read. `MarmotReindexReport.predatingMembership` is reported alongside `stored`, and the detail screen names it: "3 event(s) all read - 27 from before you joined". A member invited into an old room is the ordinary case, not an anomaly to bury in a total. Seventeen tests. `ChatRoomMembershipWindowTest` holds the boundary, including the same-second case and both directions of the `createdAt` fallback. `JoinedGroupAtMigrationJvmTest` runs the migration's own SQL against v14's three tables and covers what it must not take as carefully as what it must: a placeholder for an event from *after* the join is left to be recovered, a message that was read is left alone however old it is, a line with no group event behind it is out of reach of the rule, and two rooms joined at different times are each measured against their own join. `MarmotPreJoinIndexingJvmTest` drives the DAO against a room with no MLS state, which is what separates "left alone because it predates the join" from "tried and failed". 520 jvm tests and 302 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
98f766fcd3 |
fix: put the invite in the room, so a stuck one can be seen
Inviting a member to a group that already had members put nothing whatsoever in
the transcript. Not "put it in late" -- nothing, and nothing ever if the invite
did not complete. So the one failure the user is best placed to notice, an
invite that never reached the person it was made for, was the one the app kept
to itself.
The line existed. It was written by `MarmotOutboundDao.deliveryWelcome`, which
is the wrong place for it, and the reason is the two paths through
`inviteMember` that docs/marmot-membership.md already describes. A group that is
still only its creator has nobody to inform, so its Welcome goes out immediately
and `deliveryWelcome` runs inside the invite. A group that has members must
broadcast a commit first, and its Welcome waits for a relay to acknowledge it --
`DatabaseNostrRepository.broadcastProcessed` picks the stored `MarmotCommitResult`
back up and delivers then. Every invite after a group's first therefore wrote
its transcript line a relay round trip away from the invite, if at all.
**Four separate silences, not one.** Worth listing because only the first is
about the deferral, and fixing that alone would have left the other three:
1. The deferred path wrote nothing until the ack, and nothing ever without one.
2. The write hung off `getMarmotKeyPackageById(...)?.let { getProfileByPublicKey(...)?.let { ... } }`.
Those two lookups were there to *name* the invitee, and a miss on either cost
the whole line rather than just the name.
3. `deliveryWelcome` wraps its body in `catch (e: Throwable) { logger.e(...) }`
and returned Unit, so a Welcome that could not be built reached the log and
no further.
4. `inviteMemberToChatRoom` is `@Transaction`. An invite that threw -- no MLS
state for the room, a credential identity that does not match the peer --
rolled its line back with everything else, which is right, and left no
account of the refusal anywhere durable.
And the line it did write was `messageType = "message"`, `isUserMessage = true`,
so it rendered as a chat bubble: "Invited Bob to chat", attributed to the
inviter as something they said.
**Three membership types, and the line moves to invite time.**
`ChatMessage.MEMBERSHIP_TYPES` -- `memberInvited`, `memberInviteSent`,
`memberInviteFailed` -- rendered by the transcript as system notices through
`RitualNotice`, the way the ceremony, signing and chronicle lines already are.
`memberInvited` is written by `inviteMember` and by `addMembersToChatRoom`'s
batch path, *when the invite is made*, and deliberately **inside** the caller's
transaction. Both halves of that matter and they pull opposite ways: written any
later and an invite waiting on an ack that never comes shows nothing, which is
the bug; written outside the transaction and an invite that does not survive
`addMember` leaves the room claiming one was made.
`memberInviteSent` is written by `DatabaseNostrRepository` alone. It is not
written on the immediate path, and that is not an oversight: there the Welcome
goes out in the same breath as the invite, so one line is the whole truth. It
would also be a line the transcript could not order -- `MantraConverters` stores
`Instant` as `epochSeconds`, the room query is `ORDER BY createdAt DESC`, and two
rows written in the same second tie. Only the deferred path separates the two
events in time, so only it owes a second line.
`memberInviteFailed` carries the reason, because it is the only copy the user
gets. `deliveryWelcome` now returns `Boolean` and files this line from its own
catch before returning false -- its callers had no other way to see a failure it
had already swallowed, and on the deferred path there is no invite screen left
to fail back to. `addMembersToChatRoom` reads that answer instead of a
`runCatching` that could never catch anything.
**The refusal is written from outside the transaction that rolled it back.**
`DatabaseChatRepository.inviteMember` catches, calls `announceInviteFailed`, and
rethrows. The throw is what puts a message on the invite screen now; the line is
what is still there tomorrow. Swallowing it instead would have popped the user
back to the chat as though the invite had gone out, which is the bug the
existing `runCatching` in `AddMemberToChatRoomConfirmationViewModel` was added
to stop.
**No schema change.** `messageType` is a free-form string column with a default,
so new values need no migration -- unlike the chronicle rename, which had to
rewrite the ones already stored. Nothing reindexes these either: they carry no
`marmotGroupEventId`, so `getResolvedMarmotGroupEventIds` cannot see them and
`UNRESOLVED_MARMOT_TYPES` does not name them.
Six new tests. Four on the DAO: the immediate path leaves a line naming the
invitee where the old code left none, the deferred path leaves one *and* claims
no Welcome sent before any ack, everything an invite writes is a membership type
rather than something the transcript would render as a bubble, and a refused
invite leaves no claim that one was made. Two new ones on
`DatabaseChatRepository`, which had no test file: a refused invite is written
into the room, and the caller still gets the throw.
Still open, and now said plainly in the doc rather than implied: a Participant
row carries no state saying where its invite got to. The transcript narrates it;
the `TODO: Update status of participant Invitation.PENDING -> Invitation.SENT`
is untouched. Nor does an invitee with no published key package reach the room
at all -- that fails in the view model, before there is an invite to write a
line about.
806 tests pass -- 509 jvm, 297 android.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
19d57ef3a5 |
Merge branch 'mantra' into claude/happy-gauss-dbe258
Brings in the Chronicle rename and the deprecation of the row rebuild, and carries the supersession fix across into the new vocabulary. Git followed every rename on its own -- `ArchiveManager` -> `ChronicleManager`, the tests, the docs -- and auto-merged all three files my fix had touched. What it could not do is rename identifiers inside the hunks it merged, so the fix arrived speaking the old language: `ChronicleAssemblyJvmTest` still called `ArchiveManager.assemble` and `ArchiveEvent.decodePage`, which does not compile, and six doc comments in `ChronicleManager` and `GroupSignedEvent` still said "archive" -- the exact ambiguity with archiving a chat that the rename exists to remove. One real conflict, in the design note, and it is the same sentence twice: my correction of "a retranslated passage archives once" against the rename of the uncorrected claim. Resolved to the correction, in the new vocabulary -- the property still holds, it just stopped being free the moment the chronicle was read from `GroupSignedEvent` rather than rebuilt from rows, and `ChronicleManager.currentTranslationsOnly` is what holds it up. `compileKotlinJvm` passes over a test file that does not compile, so it was no evidence here; `compileTestKotlinJvm` is. And the filter was re-checked the way it was written: removing it fails the same three tests, so the merge did not quietly neuter them. 503 jvm tests and 297 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4890906b24 |
Merge branch 'mantra' into claude/rename-archive-chronicle-a4a8e0
The rebuild deprecation landed on mantra while the rename was in flight, and it touched the same files by their old names. Git matched the renames itself, so the only conflict was `ChronicleRoundTripTest`'s header, where both sides had rewritten the same paragraph: mantra's says this file is now the gate on a deprecated fallback rather than on the only path, which is the newer and truer claim, so it wins and the rename is applied on top of it. Everything the merge brought in went through the same substitution as the rest: the nine `@Deprecated` messages and the "Retiring the rebuild" checklist all name `ChronicleManager`, `ChronicleRoundTripTest` and docs/member-chronicle.md, which are the files that now exist. 797 tests pass -- 500 jvm, 297 android. The five new ones are the migration's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ea11e8b233 |
refactor: call it a chronicle, and keep "archive" for what a user does to a chat
Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
87ef129de5 |
fix: archive the translation that stands, not every draft of it
Follow-up to reading the archive out of `GroupSignedEvent` rather than rebuilding it from rows. The two sources do not hold the same thing, and one place where they differ reaches the archive. `ChatMessage.applyInnerEvent` supersedes a translation chunk: retranslating a passage changes the text and so the event id, so the arm drops the row it replaces -- newest by the timestamp the group signed at, id breaking a tie. The record does not, and should not: a signature is the group's statement and discarding one is not that table's business. So a passage translated three times leaves one row and three events. While the archive was rebuilt from rows that difference was invisible, because a sender simply had nothing but the group's current answer to each passage. Read from the record it is not: measured on a seeded room, one retranslation leaves one row and puts **two** payloads in the archive, and it compounds -- every draft a group ever signed would travel in every archive it ever sends, for as long as the room exists. **The rule is the applying arm's, restated rather than approximated.** An archive that shipped one translation as current while the recipient settled on another would have both validly signed and nothing downstream to notice they disagree, so `currentTranslationsOnly` groups by `(translationChapterId, chunkId)` and keeps the maximum by `(createdAt, id)` -- the same comparison, spelled the same way. Dropping the drafts is safe precisely *because* the recipient applies that rule too. This is not what keeps them correct; it is what stops them being sent work they would discard on arrival. Grouped per passage rather than per chapter, or retranslating one passage would take every other passage's translation with it. A translation naming no passage is left alone rather than lumped in with the rest: it is unappliable either way, and letting one stand in for a whole passage would let a malformed event suppress a good one. Three tests, and all three fail if the filter is removed: a retranslated passage leaves one row, two recorded events and one payload; three translations signed in the same second settle on the same id the row keeps, which is what pins the tiebreak to the applying arm's; and two passages each keep their own, which is what a group-by-chapter mistake would fail. Two documentation corrections alongside it. docs/member-archive.md said "a retranslated passage archives once" as a property of the rows, which stopped being true the moment the record became the source -- it now says what makes it true again. And `GroupSignedEvent.verifies()` said a false means the row's columns have drifted from the event they came from. That is the reading worth acting on and it is not the only one: a room never derived from its group's key signs as the bare threshold key rather than as its own id, so a perfectly good event there fails and cannot be made to pass, the key it would need being absent from the row and unreachable from one. 498 jvm tests and 297 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c66f085681 |
refactor: deprecate the row rebuild, and write down what goes with it
`assemble` reads `GroupSignedEvent` now and rebuilds from `Mantra*` rows only what that table does not hold, which is work signed before it existed. The rebuild is therefore on its way out rather than merely second in line, and this says so where a reader will actually meet it -- at the call site, from the compiler -- instead of only in a paragraph they have to find first. **Nine `@Deprecated` markers, and they are load-bearing as documentation.** The eight `toXEvent()` methods and `ArchiveManager.rebuiltEventsOf`, each carrying the same sentence: this is the fallback for pre-v13 work, read the event off the table instead, and it goes when the last such install does. That raises nine warnings in `commonMain` today, all of them inside the walk itself, so the deprecation is visible in every build without anything failing over it. The level is `WARNING` deliberately -- the code is still called, still correct, and still the only thing standing between an older room and an empty archive. **The checklist is a new section in docs/member-archive.md**, because the interesting part of this removal is not the eight methods, it is everything around them that is easy to take out by association or leave behind by accident. *What goes*: the walk and the version-label recovery inside it, the union in `signedEventsOf`, the eight rebuilds, and `ArchiveRoundTripTest` entire -- all ten cases, which exist to hold the rebuild up and cover nothing else. Its own header still opened with "signed events are not stored as events", which stopped being true two commits ago, so it now says what it is: the gate on a deprecated fallback, deleted with what it guards. *Two already-dead cousins to sweep at the same time*, named because they will look like part of the rebuild to whoever does the removal and are not: `MantraTranslation.toTranslationEvent`, which nothing has ever called, and `MantraTranslationChunkProposal.toTranslationChunkEvent`, on a model that is not even a `@Database` entity. *The tests that seed without recording*: in `ArchiveAssemblyJvmTest` the `apply`-only seeding **is** the rebuild path, and two of its cases are about the union specifically and mean nothing without it. `ArchiveApplyJvmTest` seeds its sender the same way but is testing delivery rather than assembly, so it needs the recording call *added* -- otherwise it quietly starts asserting against an empty archive, which is the same silent-success failure this whole feature is about. **What only looks like it goes, which is the half worth writing down.** The `isArchivable` filter in `signedEventsOf` is not part of the rebuild and becomes the only thing standing. It is there *because* of the record: the walk could only ever produce document kinds, so nothing needed filtering while it was the source, and the table holds every kind the group has signed -- starting with the `GroupKeyStateEvent` every room signs as its first act. Dropping it with the walk turns every room's archive into an `IllegalArgumentException` from `ArchiveEvent.build`. Two cases fail with exactly that if it goes, which is the guard against removing it by association rather than by decision. The verify filter in `assemble` stays too. With the rebuild gone it checks events that were verified before they were recorded, so it cannot fail in practice -- which is the argument for keeping it, not against. "Cannot happen" is the state it exists to preserve. `Mantra*.signature` and `Mantra*.publicKey` are explicitly *not* on the list. They were what made a row rebuildable, and since v13 `groupSignedEventId` says whether the group signed a row and points at the proof -- so they are arguably redundant. But four test files assert on them and `MantraTranslationContributor` builds a contributor list out of one, and it is a twelve-table migration with its own tests to rewrite. It should be decided on its own merits, not ride along. **The precondition cannot be checked, and the section says so plainly.** No query answers "does any install still hold pre-v13 work" -- a device that upgraded is indistinguishable from one that never had any, and the rows that need rebuilding are on other people's devices. What is observable is the `signedEventsOf` log line, which fires only when the rebuild actually contributed something; fleet-wide silence is evidence and not proof. The cost of getting it wrong is named as well, because it is not loud: the member keeps their own rows and reads the room normally, and only loses the ability to *answer* a request with the older half of the group's work -- so a newer member asks, is answered, and receives an archive that is quietly short. No behaviour change. 495 jvm tests and 297 android unit tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a69d80d38f |
feat: archive the events the group signed, not rebuilds of its rows
`assemble` read the archive out of `Mantra*` rows, rebuilding each payload with `toXEvent()` and standing or falling on that rebuild being byte-identical to what was signed. It had to: nothing kept the events. `GroupSignedEvent` keeps them now, so `signedEventsOf` reads the record first and rebuilds only what the record does not hold. **The rebuild stays, as the fallback, keyed by id.** A room whose work predates v13 has no events on file, and dropping the walk would silently empty its archive -- the failure mode being that a member asks for the history, a member answers, and nobody notices the answer was blank. So both sources are read and unioned by event id, which is also what a half-upgraded room needs: older work only the rows remember, newer work on file, and neither half complete on its own. The fallback can go once no install still carries pre-v13 work, and `ArchiveRoundTripTest` is what holds it up until then. **The allowlist does real work on the way out now, and this is the part that would have bitten.** The rebuild could only ever produce document kinds, because those are the only rows it walks. The record holds every kind the group has ever signed -- and every room signs a `GroupKeyStateEvent` as its first act, so one is on file in every room that has signed anything at all. `ArchiveEvent.build` refuses a non-archivable kind with `require`, so an unfiltered read does not quietly ship a key state: it throws, and the room's entire archive fails on the one event every room has. `signedEventsOf` therefore filters on `isArchivable` before anything else, which is the same rule `applyPage` applies on the way in. Removing that one line fails two tests with exactly that exception, which is how I know they are load-bearing rather than passing for the reason I expected. **An artifact whose initial version row is missing now archives.** The rebuild has to recover the version label from that row -- `fromArtifactEvent` drops it, so it is not on the artifact -- and logs and gives up without it, which is a hole in the archive for any device that applied half a batch. Read from the record there is nothing to recover: the label never left the event. That is the case that makes the record the better source rather than merely the faster one, and it has a test of its own. **One verify filter over both sources**, because the rule is per event and not per source: nothing leaves that the recipient could not check for themselves. A drop still means different things on each side -- a member's own rumor sitting in the same table as the group's work, versus a row that has drifted from the event it recorded -- and the comment now says so, since the log line cannot. **Ordering is unchanged where it matters and looser where it does not.** `inApplyOrder` is a stable sort by dependency rank, so the union only affects order *within* a rank: a room holding some work both ways can order two chapters differently from a member holding one way only. Pages are idempotent and applied payload by payload, and two members already differed by the order their rows were written in, so this costs nothing. `rebuiltEventsOf` still runs on every archive even where it contributes nothing, because there is no way to tell a complete record from a partial one without doing the walk, and it is a handful of indexed queries against a room's own rows. 495 jvm tests and 297 android unit tests pass. Five new cases in `ArchiveAssemblyJvmTest`, which seeds through the real inbound path and now records the same batch the way `FrostSigningManager.complete` does: payloads compared byte-for-byte against what was signed, work held both ways travelling exactly once, a genuinely room-signed key state left behind, a signed kind the archive has no arm for left behind, and the artifact the rebuild has to leave out archiving from the record. The existing assembly and end-to-end tests seed without recording, so they go on covering the rebuild fallback unchanged -- which is why they all still pass, and why that is evidence rather than luck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f9e4de82e |
feat: keep what the group signed, and the path it signed as
A quorum signing something is the most expensive thing this app does and, until now, the least recorded. `FrostSigningManager.complete` verified the signature, handed the event to `ChatMessage.applyInnerEvent`, and let it go. What survived was whatever the row it became happened to keep -- an artifact keeps its `signature` and `publicKey`, a translation contributor list keeps nothing at all because that arm is still a TODO, and a kind this build has no arm for keeps nothing anywhere. The signature is the group's statement; the rows are one reading of it. `GroupSignedEvent` is where the statement itself now lives, at schema v13 behind an `AutoMigration(12, 13)`. **The columns are `NostrEvent`'s, not a summary of one.** `id`, `publicKey`, `kind`, `tags`, `content`, `signature` and the event's own `created_at` as `createdAt`, so what is stored is an event rather than a description of one. That is what makes `verifies()` answerable from the row alone: it delegates to `GroupKeyStateEvent.isSignedByRoom`, which asks whether the author is the room, whether the id is the hash of the fields sitting next to it, and whether the signature checks out. No ceremony, no key state and no path have to be on hand first -- which is exactly the position a member added after the ceremony is in. **The derivation path is the point of the exercise.** `publicKey` is the group's threshold key walked to `derivationPath`, and for a room that walk is also the room id -- see docs/shared-key-derivation.md, where those are one value. Without the path there is no way back from a signature to the ceremony behind it: a threshold key alone does not say which of a group's rooms signed, and a room id alone cannot be walked backwards. `GroupKeyState` records the path for the room; this records it for the event, so an event stays checkable after the room's state is gone or was never known. Null means the untweaked threshold key, the same meaning it carries on `FrostSigningSession.derivationPath`, which is where the signing path is copied from -- resolved from the room by `signingPath`, never from a proposer. **Two writers, and both file only what they have already checked.** `FrostSigningManager.recordSignedEvents` files a whole batch in one write, after every item's signature has verified and before any of them is applied -- a session's events are one decision by one quorum, so half a batch on file is a state no reader should have to reason about. `ArchiveManager.applyPage` files each payload it accepts, after the allowlist and `GroupKeyStateEvent.isSignedByRoom`, reading the room's path once per page from `GroupKeyState` rather than once per payload. Neither failure is the caller's: recording throws are logged and swallowed, because a ceremony that succeeded must not be reported as failed over a row this device could not write down. **The archive half is what makes a recipient more than a dead end.** A member handed their history used to end up holding the rows and none of the events -- able to read the group's work, unable to prove any of it, and unable to build a page for the next member to arrive. Now the events land too. **`record` merges rather than overwrites, and that direction is deliberate.** The same event reaches a device twice by design: once when the session that made it completes, once from any archive page carrying it. The second arrival is the poorer one -- an archive knows no session, and on a member who joined after the ceremony no derivation path either -- so the incoming row fills gaps and never empties them. The event's own fields are not merged because they cannot disagree: the id is the hash of them, so two rows under one id either hold the same event or one of them is not the event it claims to be. **Every `Mantra*` row points back at it.** `groupSignedEventId` on all twelve entities that carry `marmotGroupEventId`, stamped by `ChatMessage.applyInnerEvent` through a new defaulted parameter. On a group-signed row it is the only provenance there is: both Marmot ids are null, because there is no group event and no inner event behind one -- a signed event authored by the threshold key cannot travel as an inner event at all, since the outbound pipeline re-authors rumors as their sender and would strip the signature off. The column is only set when the record actually landed, so a row never points at an event that is not there. **`ArchiveManager`'s own doc said something that is no longer true.** It opened with "signed events are not stored as events", stated as present-tense fact and load-bearing for the paragraph under it. Corrected there and noted at the head of the same section in docs/member-archive.md, which is a phase history and so gets a note rather than a rewrite. Assembly still rebuilds payloads from rows via `toXEvent()` and the round-trip gate still holds it up: a room whose work predates v13 has no events on file, and rebuilding is the only way to reach it. Reading assembled events from the table is worth doing once that fallback can be dropped. **Two things this deliberately does not touch.** `ChatMessage` gets no such column -- it is not a `Mantra*` row and already carries `frostSigningSessionId` for the lines that need to name a session. `MantraTranslationChunkProposal` has a `marmotGroupEventId` but is not a `@Database` entity and nothing in `composeApp/src` references it, so it was left as the dead code it is rather than grown a column. Rows are not backfilled by the migration. The events they came from are gone, and minting an id for one would point a row at a signature nobody can produce; null reads as "this device does not hold the event behind this row", which is true of every row written before today. 490 jvm tests and 297 android unit tests pass. `GroupSignedEventDaoJvmTest` is eight cases against a real 2-of-3 quorum rather than a stub signature, because a fake one would satisfy every column assertion and prove nothing -- it covers the round trip, the path walking back to the row's own author, the merge in both directions, batch ordering, and a row edited after the fact no longer verifying. `SignedGroupKeyStateTest` adds the end-to-end claim over two devices: a batch of three signed in one session lands as three events on both, each at `m/9420/0/0` that neither device was told and both derived from the room they stand in. `ArchiveApplyJvmTest` asserts the receiver ends up holding the events and not only the rows, and that the four forgeries in its adversarial page become no signed-event rows either -- a forgery filed there is one the recipient goes on to hand to everybody else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
47aa79ebc7 |
feat: archive the translated text too, now that the group signs it
The merge brought in two commits that close the gap this feature was written around, so the allowlist grows from six kinds to eight. `feat: sign an artifact's first version with it, not derive it after` makes the version the second item of the artifact's own signing batch. `feat: ask the group to sign a chunk's translation, not just save it` puts a quorum behind the prose. Both were done for their own reasons and neither was about the archive, but they are exactly what the archive was missing: an archive can only carry what its recipient can check, so a derived version and a member-authored translation could not travel. A new member got the whole structure and none of the words. **30301 and 30309 do not go on the end of the list.** The order is the foreign keys: a version sits between its artifact and the chapters hanging off it, and a translated chunk hangs off both a source chunk and a translation chapter, so that one really is last. **`toArtifactVersionEvent` had the bug this predicted it would.** It emitted [artifactId, alt] where `build` emits [alt, artifactId], so the id did not round-trip -- the same fault fixed on `MantraArtifact.toArtifactEvent` in Phase 3, in the second of the three unused rebuilds, and for the same reason: nothing had ever called it, so the "tag order matches build" claim in its comment was never checked. `toTranslationChunkEvent` was already correct. Both now have a round-trip case, which is what makes the difference between a rebuild that is right and one that has not been contradicted yet. **`signedEventsOf` walks two steps further**, emitting each version and the translation chunks under each translation chapter. A retranslated passage archives once: the arm that applies a translation chunk drops the one it supersedes -- newest by the timestamp the group signed at, id breaking a tie -- so what a sender holds, and therefore what travels, is the group's current answer to each passage rather than its drafts. **The seeds had to change with it.** Both database tests derived the artifact's first version by applying the artifact, which is exactly what stopped happening; they now sign it through `ArtifactVersionEvent.initialVersionOf`, the way the batch does. That also removes the one exception in the end-to-end assertion: every archived row is now authored by the room and carries a signature, where the artifact version used to have to be excused for having neither. 480 tests pass. The plan's Phase 3 table, its built-vs-plan table and its "what this does not do" section are updated -- what an archive cannot do is down from two things to one, and the remaining one is that it still cannot make its recipient able to sign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
54091099a9 | Merge branch 'mantra' into claude/happy-gauss-dbe258 | ||
|
|
f3984e838c |
test: prove the catch-up row by row, and say what an old build makes of a page
Phases 8 and 9 of docs/member-archive.md. The tests ran in the phases where the code they cover first existed -- the way the batch-signing note's did -- so this is what was missing from them, plus the rollout note, plus the plan marked built. **Compared row by row, not by count.** The end-to-end test asserted the two databases held the same *number* of artifacts, chapters and chunks. That is not the claim: two databases can hold the same counts and disagree about every row, and a rebuild that lost the group's signature -- or re-authored a row as whoever sent it -- would pass a count and fail the only thing an archive is for. It now compares `(id, author, signature)` per row across every archived kind, and then asserts each one is authored by the room and carries a signature. The artifact version is the one exception, and it has to be: nobody signs it, it is derived from the signed artifact on arrival. Which is exactly why it is not archived, and why a chapter's foreign key survives without it. **An old build does not ignore an archive page, it renders it.** Phase 9's first draft said an old build "files it as unsupported, exactly as it does today for anything it does not know" -- true, and it reads better than it lives. An unsupported row's content is `event.toJson()` and it renders as an ordinary chat bubble, so every member on an old build sees each archive page as a raw-JSON bubble of up to `MAX_PAGE_BYTES`, once per page. Nothing breaks and nothing is lost, but a group mid-upgrade gets a genuinely unpleasant transcript, and that is worth knowing before the first archive goes out. So the rollout rule is stated rather than implied: the receiving half ships safely on its own -- phases 1-4 send nothing -- and no member starts sending until every member understands kind 30327. The mitigation if that ever proves unacceptable is the one the appendix rejects for other reasons, and it is named there so the trade can be weighed rather than rediscovered. **The plan is marked built**, with a table of the five places the implementation chose differently from the plan and why: nine archivable kinds became six, a count cap that could never fire, queueing moved a phase later, a re-read that was never needed, and the rollout note above. Phase 8 also records the three tests that were not in the first draft, each written because something passed for the wrong reason -- a cap that could not fire, an out-of-order test on an archive that was never out of order, and a sweep whose "still missing" count included failures a later pass had already fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a315b86918 |
feat: say in the transcript that a member is being caught up
Phase 7 of docs/member-archive.md, in part. Three chat types -- `TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- so a room that fills itself in explains itself once. Without this the archive is entirely silent by design: it files no line per applied payload, because `ChatMessage` has an `autoGenerate` primary key and every payload would mint a fresh row on every pass of the sweep. The result was a member joining a working group and watching a room populate with no account of where any of it came from, which is worse than the noise it avoided. **One line per archive, not per page.** The received line is written when the request stamp is cleared, which is as close as this can get: an archive's pages are not distinguishable from each other at apply time, and clearing the stamp is exactly the moment a catch-up stops being pending. There is a test that delivers a payload per page, backwards, so the sweep runs repeatedly over many pages, and asserts the transcript holds two lines. **A push behind a Welcome writes nothing**, because the room was never asked. It lands before the member has opened the room, and "caught up on work you have not seen yet" is a line about nothing. Also tested. **The received line names no sender.** An archive can be assembled from pages sent by more than one member, so attributing the catch-up to one would be a guess dressed as a fact. The sent line does name its recipient, written into the content the way the invite line writes one -- which does not follow a rename, and is the accepted cost for a line about something that happened once. **Content is whole sentences**, so these stay out of the AUTHORED sets and nothing prefixes a name to them. And they are added to `ARCHIVE_TYPES` with a matching arm in the transcript, because the failure mode for a missed set is silent: the line renders as a chat bubble, looking exactly like a member having said "Caught up on 12 items". Icons per type rather than the `PanTool` fallback. **Two items from this phase are deliberately not done**, rather than written without the app in front of me: the banner saying a room is catching up, and a "Send history" action on the member row. The first is UI state plumbed through a view model into a layout and the transcript line covers the same ground; the second is a convenience, since both real paths are already automatic. Both are written up in the plan as outstanding, along with the thing this phase was also meant to say and does not: that an archive does not make its recipient able to sign, and does not carry the translated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d8866c2b0 |
feat: offer a new member the group's work alongside their welcome
Phase 6 of docs/member-archive.md, and deliberately the phase after the one that makes it unnecessary. `deliveryWelcome` now queues an archive for the member being invited, so in the ordinary case they have the group's signed work before they think to ask for it. **This is a latency optimisation, not the mechanism.** A page queued behind the Welcome is not delivered after it: they are different transports -- a relay-borne gift wrap and a kind:445 -- with no ordering between them, and a page that overtakes the Welcome is from an epoch ahead of the invitee's, so `MarmotInboundManager` drops it outright rather than deferring it. Nothing retries and the inviter sees a success. That is the failure in docs/marmot-membership.md wearing new clothes, and the only thing that closes it is the invitee asking once they are demonstrably in the group, which Phase 5 already does on their first open of the room. So nothing here reports failure to the inviter. A push that does not land is the ordinary case the pull exists for, and it sits inside `deliveryWelcome`'s own catch alongside the Welcome it rides behind. A room with nothing signed queues nothing and still invites. **One call, two occasions.** `ArchiveManager.answer` becomes `sendTo`: answering a request and pushing behind a Welcome are the same operation and differ only in who decided, so it is named for what it does rather than for either occasion. Also corrects the plan. Phase 6 claimed the room had to be re-read between the invite and the assembly, for the same reason sequential invites re-read it. It does not -- that rule is about the MLS snapshot a commit is built on, and this runs downstream of the commit over `Mantra*` rows, which no commit touches. Two tests against a real `deliveryWelcome`: inviting into a room with signed work queues exactly one archive page addressed to the invitee, and inviting into a room with none queues no page while still writing the Welcome's gift wrap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
873203e4b4 |
feat: let a member with none of the group's work ask for it
Phase 5 of docs/member-archive.md, and the half that makes the whole thing reliable. A device opening a room it holds no signed work for asks the group; any member holding the work answers, addressed to whoever asked. **A request cannot lose the race a push loses.** Pushing an archive at an invitee is an application message in the epoch the add created, and one that overtakes the Welcome is dropped rather than deferred -- silently, while the inviter sees a success. That is marmot-membership.md's failure mode arriving in a new costume. Sending a request cannot lose it, because being able to send one is the proof it was won: a device that can put an application message into the room has processed its Welcome and is at the group's epoch. It also covers three things no invite-time push reaches, and they are answered by one rule because they are indistinguishable from inside the database: a member added after the work, a reinstall whose invite is long past, and a second device that was never invited at all. Hence the crude condition -- no dialects and no artifacts -- rather than anything that tries to tell them apart. **Anyone may answer and nobody is elected to.** A duplicate answer costs bandwidth and nothing else: pages are idempotent and every member who is not the named recipient ignores them. So the stand-down that would avoid the waste is an optimisation to add later rather than a correctness gap to close now. A member with nothing signed answers nothing at all, which is the honest reply from one still catching up themselves, and beats an empty archive that looks like an answer. **Queued with no chat line**, the way a signing message travels. Broadcast does not depend on one -- `encryptAndSendMarmotInnerEvent` inserts its `BroadcastNostrEventRequest` unconditionally, which is what let the FROST rounds travel with no transcript -- and an archive that filed a line per page would put a row of envelopes in the room's history. One line per archive is the right number and it is not writable from here, since the pages are indistinguishable from each other at this point. **Schema 11 -> 12**: `ChatRoom.archiveRequestedAt`, nullable, so Room generates the migration. It stops a device asking again on every launch while an answer is in flight. Rooms written before it read back null, meaning "never asked", which is true of all of them and harmless. Cleared as soon as an archive applies anything -- not when a sender's page count claims the archive was complete. A page count is the sender's word about the transfer rather than about the group's record, so a member who left work out must not get the last word on whether to ask again. A partial answer is followed by another request rather than by silence. The trigger is opening the room, through `ChatRepository` rather than from the view model into the database. Cheap to call every time: it stops at a room that already holds work and at one still waiting. A failure is not reported, because nothing acknowledges a request and the next open asks again. Eight tests: a device with nothing asks and does not ask twice, a device with work does not ask, answering queues pages addressed to the asker with every archivable kind in them and no chat line, a member with nothing signed answers nothing, a member does not answer themselves, an applied archive clears the stamp so a partial answer can be followed up, and the whole round trip -- ask, answer, apply -- leaves the joiner holding the sender's rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ed4410b972 |
feat: apply an archive a member is sent, and sweep what arrived too early
Phase 4 of docs/member-archive.md, and the half where the security lives. A member who holds no share, took part in no signing session and cannot decrypt a word of the room's history now ends up with the same rows as everybody else -- and gets there without trusting whoever sent them. **Intercepted in `fromGroupEventResult`, not in `applyInnerEvent`.** An archive is neither a document nor a submission, and deciding whether to act on one needs the active key, which `applyInnerEvent` has no business knowing. That is the same reason the gift wrap above it is handled there, so it sits next to it. **Verification per payload, framing per page.** A forged payload costs itself and nothing else -- the rule `MarmotInboundManager` already uses for a forged direct message, and for the same reason: this runs inside the inbound transaction and one bad event must not take the room down with it. Refusing the whole page would also let a single forgery deny an entire archive. The page's own framing stays all-or-nothing, because a page that will not parse has lost the thing that says what it contains. **The allowlist runs before the signature check, and it is not a formality.** Verification admits an event to the apply path on the strength of the group's signature, which makes every kind the group has ever signed replayable by any member at any time. There is a test that puts a genuine, still-verifying `GroupKeyStateEvent` in a hand-rolled page -- `ArchiveEvent.build` refuses to make one, which is the outbound half of the same rule -- and asserts the receiver's key state does not move. **The chat line is dropped, deliberately.** `ChatMessage` has an `autoGenerate` primary key, so there is no id to dedupe on and every applied payload would mint a new row: a synthetic transcript dated now, and another one on every pass of the sweep. The archive restores the work. The conversation is forward secret and stays gone. **A device that is not the named recipient does nothing.** It can read the page -- it is an ordinary group message, and it is the group's own history -- but it already holds the work, and re-applying would rewrite every one of its rows to point at an archive page rather than at the event that introduced it. That is also what bounds the sweep: only the member being caught up ever builds the list. **The sweep needs no table.** Pages arrive over relays in no order, so page 3 can land before page 2 and its chunks have no chapter to hang off. Those throw a foreign key violation and would be lost -- except the inbound path already stores every inner event it decrypts, so re-reading them is the same shape `FrostSigningManager.replayStoredMessages` has, for the same reason: nothing was lost, it just had nowhere to go at the time. Two things about the loop, the second found by a test: Progress is measured by *failures falling*, not by rows written. "Repeat while a pass applied something" does not terminate, because every write is an upsert and succeeds forever. What strictly decreases is the count that threw. And the result is the last pass rather than the sum of them. Accumulating counts a payload once per pass it survived and reports failures a later pass went on to fix, so `failed > 0` stops meaning "still missing" -- which is the only question a caller asks it. Caught by strengthening the out-of-order test to assert that the page completing an archive leaves nothing behind, rather than only that the rows matched: without that, the test passed while reporting fourteen failures on a fully converged database. Seven tests over two real databases with the pages carried by hand. The one that matters puts four forgeries in a page beside one honest dialect -- the room's id as author with a made-up signature, a real quorum of another group, an event edited after signing, and a member's own rumor, which is what everything on the wire looks like today -- and asserts the receiver ends with exactly the honest one. The rest: a full catch-up matches the sender row for row with the group's signature intact, pages delivered backwards converge and are asserted to have really failed first so the test cannot pass for the wrong reason, an archive files no chat lines, a bystander applies none of it, and applying the same archive twice changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
acff66a22e |
feat: rebuild the group's signed record out of the rows it left behind
Phase 3 of docs/member-archive.md. `ArchiveManager.assemble` walks a room's rows, rebuilds each into the event the group signed, drops anything it cannot prove, and cuts the rest into pages. Nothing sends one yet. **The gate found a real bug, which is why it was the gate.** Signed events are not stored as events -- `FrostSigningManager.complete` applies one and what survives is a `Mantra*` row -- so an archive has to rebuild them with `toXEvent()` and stands or falls on that being byte-identical to what was signed. Every `toXEvent()` in the codebase turned out to be unused in production, written for exactly this and never called, so the "tag order matches build so the event id round-trips" comments on them were claims nothing had ever checked. One was wrong. `MantraArtifact.toArtifactEvent` put the alt tag last where `ArtifactEvent.build` puts it first, and left out the version metadata tag altogether -- because that tag is not on the artifact row at all. `fromArtifactEvent` reads the artifact's own fields and drops the version label, which `applyInnerEvent` has by then turned into the artifact's first `MantraArtifactVersion`. So the label is now a parameter, read off the initial version: the one whose `createdAt` is the artifact's, since `initialVersionOf` derives it from the same event. Neither fault would have surfaced as an error. Both produce a well-formed artifact whose id no longer matches its fields, which every receiver drops as a forgery, silently, one kind at a time. `ArchiveRoundTripTest` now signs each archivable kind with a real quorum, files it as a row, rebuilds it and asserts the signature still covers what comes out -- plus the negative case, that rebuilding with the wrong version label fails as a forgery rather than as a mistake, which is why the assembler reads the label rather than defaulting it. **The allowlist narrows from nine kinds to six, and this is the finding to read.** Only six of the thirteen nip30303 kinds ever reach a signing session; the rest travel as member rumors, vouched for by the MLS frame they arrived in and by nothing that survives leaving it. An artifact version is derived rather than signed -- which is fine, because applying the archived artifact derives it again and the chapters hanging off it keep their foreign key. Nothing builds a `TranslationEvent` at all. The contributor lists have no arm in `applyInnerEvent` that writes a row. And `TranslationChunkEvent` -- **the translated text itself** -- is submitted by `MantraDao.saveTranslation` as its author's rumor, because a translation is one member's work rather than a group decision. So an archive restores everything a translation hangs on and not the translation: a new member gets the dialects, the artifacts, the chapters, the source chunks, which translations exist and their chapter scaffolding, and none of the prose. That is a real limit rather than a detail, so it is written into the allowlist's own doc comment, into the plan's "what this does not do", and into a test named after it -- with the three ways out sketched and none of them taken here, because the cheapest gives up the property the rest of this rests on and the best is a product decision about whether translating is an act of the group or of a member. **Nothing unverifiable leaves.** Every rebuilt event is checked with `isSignedByRoom` against the same room id the recipient will use. Not politeness -- the receiver checks anyway -- but so the page count says what will actually arrive: a row from a member's rumor is dropped here rather than by the recipient. **Walked down the tree, not queried per kind.** Only dialects and artifacts have a by-room query and the rest hang off a parent, and the walk is also what puts an artifact's version label within reach. Order is settled afterwards by `inApplyOrder` rather than by the walk, since the walk groups by artifact and the foreign keys are by kind. **Paging is greedy against both caps**, because they bind different archives: a room of one-line dialects hits the count first and a room of chapters hits the bytes. An event too large for a page of its own is dropped with a log rather than failing the archive -- a chapter nobody can archive is a hole, a member who gets nothing is a bigger one. Assembling only; queueing moved to Phase 5, where the thing that decides when to send lives. That keeps this testable against a real database with no outbound path in the way. Seven tests over a real in-memory database seeded through `applyInnerEvent` itself, so what is archived is what a member's device really holds rather than rows built to suit the test: every payload verifies, all six kinds appear exactly as often as they were signed, the whole archive is in dependency order end to end, a member's unsigned dialect sitting in the same room is left out, an empty room archives nothing without failing, and two archives of identical rows do not share an id -- which is what stops two members answering one request from having their pages counted towards each other's total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e04f6c7af |
feat: give the group's signed record an envelope it can travel in
Phase 2 of docs/member-archive.md. Two kinds, three tags, a codec and two caps.
Nothing sends or applies one yet -- that is phases 3 and 4 -- so this changes no
behaviour at all.
`ArchiveEvent` (30327) carries a page of the group's signed events, each whole,
keeping its own id, author and signature so the receiver checks it rather than
believing it. `ArchiveRequestEvent` (30328) is how a device with none of it asks.
**Why not one `SubmissionEvent` per event.** The envelope fits and the meaning
does not. A submission is an *act* -- this member is putting this event in front
of this group -- and an archive asserts nothing; it re-delivers what the group
already agreed. On one kind a four-hundred-event backfill is indistinguishable
from four hundred new submissions and every device has to guess which it is
reading. It would also be one inner event and one kind:445 per payload where a
page is one, and the submission arm of `applyInnerEvent` files a chat line per
payload, which an archive must not.
**Why 3032x and not 30313.** 30313 is free beside the nip30303 document kinds
and is not used, on `FrostSigningEvents`' own advice: the DKG's 30310-30316
already overlap that range and are told apart only by living in NIP-17 gift wraps
instead, which it calls "an accident of routing rather than a decision, and the
next family added should not rely on it." This is that next family. 30327 is also
the right neighbourhood on the merits, next to `GroupKeyStateEvent` at 30326 --
an archive is a statement about the record rather than a document kind.
**One list is the apply order and the allowlist both**, because a separate
allowlist is one more thing that can disagree with the order it is applied in.
The order is Room's rather than nostr's: every archivable kind has a foreign key
on the one before it, and kind order is not dependency order -- a dialect (30304)
has to land before an artifact (30300), and a translation chapter (30308) hangs
off a translation artifact version (30306) which hangs off an artifact version
(30301). So it is a list, not a `sortedBy { kind }`, and there is a test that
fails if anybody makes it one.
It is an allowlist first. Verification admits an event to the apply path on the
strength of the group's signature, which makes every kind the group has ever
signed replayable by any member at any time. A `GroupKeyStateEvent` is
group-signed and passes verification perfectly, so an archive carrying an old one
is a validly signed statement about what the room signs with, replayed by whoever
kept a copy. Nothing but this list stops it. The contributor-list kinds (30305,
30307, 30310) are left out on the same principle from the other side:
`applyInnerEvent` has no arm that writes a row for any of them, so archiving them
would cost bytes and restore nothing.
**All-or-nothing parsing, per-payload verification.** These are not in tension;
they answer different questions. A page that will not parse has lost its framing,
and one silently shortened by an element would report a complete archive on its
page count while holding less than it says. A payload whose signature does not
verify is a well-framed page with one bad event in it, and costing its honest
neighbours would let a single forgery deny an entire archive.
**The count cap was 256 and 256 can never fire.** An event carries 64 characters
of id, 64 of pubkey and 128 of signature before it says anything, so the floor is
about 370 bytes and a 64 KB page cannot hold much past 170 of them -- the byte
cap always binds first and the count cap is a check that never runs. Found by
writing the test that a page at exactly the cap still decodes, which failed. Now
128, where both bind something: the count stops a page of many small payloads,
the bytes stop a page of few large ones. That test is what fails if somebody
later raises one number without the other, and the doc comment says they have to
move together.
**The `p` tag is a hint, not access control**, and `ArchiveRecipientTag` says so
where it is defined. The page is an ordinary group message and every member can
read it, which is right, because it is their own history going back to them. What
it decides is who *acts*: a device that is not named applies nothing, since it
already holds the work and re-applying would rewrite every one of its rows to
point at an archive page rather than at the event that introduced it.
`ArchivePageTag` refuses an index outside its own count rather than clamping it.
The pair is how a receiver decides it has everything, so a repaired one would let
a truncated archive read as complete.
Twenty-one tests over the codec, both caps, the allowlist, the order and the
tags. Also corrects the phase-2 section of the plan, which still said 256.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bd5e0413f0 |
refactor: check a group's signature against the room id, not against a key
Phase 1 of docs/member-archive.md. No wire change, no behaviour change, and one function where there was one. `GroupKeyStateEvent.isSignedByGroup` did three things: walk a threshold key to the room it derives, compare that to the event's author, and check the id and the signature. Only the first of those needs a key. The other two need the id the walk produces -- and a room's id *is* that value, held from both ends by `GroupKeyState.verifies` and `FrostSigningManager.signingPath`. So the walk splits off and `isSignedByRoom(event, chatRoomId)` is what remains: the same three checks, with the one input a caller might not have already resolved. `isSignedByGroup` becomes the one-line caller that walks first, and every existing call site and test is untouched. **Why this is worth a commit of its own.** A member added after the ceremony holds no `DkgSession`, no share, and -- until a state is re-announced, which nothing does -- no `GroupKeyState` row either. Under the old signature they could not check a group signature at all, and an archive of the group's work would have had to be believed because a member said so. Under the new one they check it against the id in their own Welcome, and the sender of an archive stops needing to be trusted. That is the property phases 2-9 are built on, so it lands first and lands alone. **The catch moved and had to be kept.** `marmotGroupId` is now called outside `isSignedByRoom`, so `isSignedByGroup` keeps a `runCatching` of its own. Without it a threshold key that is not a point stops being a refused state and becomes an exception in the middle of the inbound path -- every input here is off the wire, and the whole contract of these functions is that malformed means no. There is a test that fails if the catch is dropped. **Tests**, added to `GroupKeyStateTest` where the FROST key material, the second group and the real-quorum `groupSignature` helper already live: - a room's id is the only key its signature verifies against -- the same group's sibling room fails, and so does another group entirely; - the verifier does not care what kind it is looking at, over four kinds including `GroupKeyStateEvent` itself. That last one is not incidental: a key state signed by the room passes exactly as a dialect does, which is why the archive needs an allowlist of kinds on top of this and cannot read "the group signed it" as permission to apply it; - a rumor nobody signed is not a group signature -- empty sig, member author, which is what every nip30303 event on the wire looks like today; - claiming the room as author proves nothing without the signature. The room id is in the h tag of every kind:445 the group has sent, so writing it into `pubKey` is free; the author check and the id check both pass and the signature is the whole feature; - an event edited after signing fails on the id, not on the signature -- and the original still passes, which is why the id check is not redundant; - malformed input is a no rather than a throw, on both forms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
042313ea88 | Merge branch 'mantra' into claude/new-member-archive-events-e3e074 | ||
|
|
c8b62e606e |
feat: sign an artifact's first version with it, not derive it after
A chapter attaches to a version rather than to an artifact, so the first version is the parent of everything a group later translates. It was not signed. Every device rebuilt it from the artifact on arrival, which put a row on disk naming the group as its author and carrying no signature to show for it -- a parent vouched for by its own signed children rather than the other way round. The reason was written into both ends: a first version proposed on its own would cost a second quorum for one form. That is an argument against a second session, and it stopped being an argument at all once a batch existed. `proposeSigningBatch` is one ceremony, one approval and one transcript whatever k is. The same reasoning was already overturned once, for the same shape. A chapter's chunks were briefly derived from the signed chapter's text for exactly this reason, and they carry their own signatures now. The artifact version is the case that was left behind, and it needs the same form: `ArtifactVersionEvent` names the artifact it is of, and that id is a hash over the group's key at the room's path, so it cannot be known until the proposal is authored. `initialVersionOf` takes the lead the session built, mirroring `ChunkEvent.splitOf`, and the artifact is item 0 because a version row whose artifact does not exist yet is a foreign key violation. Two things had to move with it, and both would have been silent. `ChatMessage.applyInnerEvent` no longer derives a version under an artifact. The derived row and the signed one hash differently -- different author, different timestamp -- so keeping both would have stood two versions against one artifact and let a chapter hang off whichever it found. The `ArtifactVersionEvent` arm no longer writes a chat line. It never used to reach one: a derived version wrote nothing. Signed, it would have put "Added 1.0 to artifact versions" under every "Added In Detention to artifacts", which is the noise the chunk arm already declines to make beside a chapter. An artifact signed before this keeps a version label nothing turns into a row, so its version does not appear. That is what the chapter's chunks cost too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4855d31c9c | Merge branch 'mantra' into claude/translation-chunk-frost-signing-b01596 | ||
|
|
90c6db7827 |
refactor: drop the local path a chunk's translation no longer takes
The translation editor was the only caller of `saveTranslationChunk`, and it stopped calling it when it started proposing. What is left behind is dead: the method on `MantraRepository`, its no-op for previews, its implementation in `DatabaseMantraRepository`, and `MantraDao.saveTranslation` underneath them. Deleting it rather than leaving it is the point. Two ways to create a translation chunk, one of which bypasses the quorum, is one too many -- the next screen wanting one would find it and take it, and the group would end up with a translation in its name that nobody signed. The rule it enforced does not go with it. Keeping one translation per source chunk moved to `ChatMessage.applyInnerEvent`, where the row is now made, in the commit before this one -- and covers more there than it ever did here, since the group's other members were always able to leave a duplicate behind. `MarmotInnerEventDao.deleteByPayloadEventId` loses its only production caller here and stays. It is a DAO query rather than a private helper, the invariant behind it is still true and still tested -- a submission's id is the envelope's, so a superseded payload cannot be un-queued by its own id -- and `MantraDao`'s remaining `addDialect` and `addArtifactVersion` are in the same position: reachable now only from `MantraDaoJvmTest`, and a decision about the whole submit-to-group path rather than about this screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
abbb84bb29 |
feat: ask the group to sign a chunk's translation, not just save it
Everything else a group's library is made of -- the artifact, its dialects, its
chapters, the translations of it -- is signed into existence by a quorum. The
translation of a chunk was the last thing still being saved: `saveTranslation`
wrote the row on this device and queued a submission, and the group's only
recourse afterwards was social.
That is the wrong way round for this one in particular. An artifact is a link
and a name; a translated passage is a claim about what somebody else's words
mean, made in the group's name, and it is what every reader of that translation
reads instead of the original. If anything in the library deserves a quorum it
is this one.
So the screen proposes rather than saves, the way `AddArtifactScreen` does.
Nothing is written when the button is pressed. What goes out is a proposal to
sign a `TranslationChunkEvent`, and the translation appears on every member's
device at once -- authored by the room's own key rather than by whoever typed
it, since signing runs at the path the room was derived at -- when enough
members have signed.
**The form knows whether it can sign before it offers to.**
`TranslateChunkUIState.Loaded` now carries the room and
`frostSigningRepository.canSign`, so the view model has something to propose
with and the button has something to check. Greyed out with
`semantics { disabled() }` when the group holds no shared key, and the screen
says why: a group without one cannot translate here at all, and that is a dead
end to say up front rather than a proposal to be told about afterwards. The
disabled colours are borrowed from `ButtonDefaults` because M3 gives a FAB no
`enabled`, which is what the artifact and dialect forms already do.
**The index is read off the source chunk**, as the DAO did before it. A chapter
is translated a passage at a time and in no particular order, so counting what
is translated so far would number the translations by who got there first.
**Onto the session, not back to the chapter.** The editor is popped and replaced
by the signing screen: nothing has been translated yet, so a table still showing
the passage untranslated would read as a failure. Back from the session lands on
the chapter table, which is deliberately left alone -- the old flow popped and
reloaded it to reflect a save, and there is no longer a save to reflect.
**`ProposedEvent` learns kind 30309.** Without it, members would be asked to put
the group's name to "Event of kind 30309". A translated passage is summarised as
its position and then the translation itself: the words are the whole of what is
being decided -- signing this is agreeing they say what the original said -- and
the position is what tells the reader which passage to weigh them against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
76b4a78581 |
fix: keep one translation per source chunk, however it arrives
A translation chunk is never edited. Retranslating a passage means a new event
carrying new words, and an event's id is a hash over its content -- so the
second translation is a row of its own rather than an overwrite of the first.
`MantraDao.saveTranslation` knew that and dropped the row it superseded, but it
only ever saw this device's own retranslations. Everything arriving from the
group went through `ChatMessage.applyInnerEvent`, which upserted and nothing
else. A member retranslating a passage somebody else had already translated left
two rows behind, and `TranslationChapterViewModel` pairs chunks with their
translations by `associateBy { it.chunkId }` -- one of the two wins, and which
one is whatever order SQLite happened to return them in for a query ordered on a
column they share.
So the rule moves to where the row is actually made, and now covers the group's
signatures and the relay's deliveries alike.
**Newest wins by the timestamp the group signed at, not by arrival.** Two
devices catching up read the same events in whatever order their relays hand
them over, and they have to end up holding the same translation either way. An
older translation arriving after the one that superseded it is dropped rather
than allowed to overwrite it. Ties break on the event id -- arbitrary, but the
same arbitrary on every device, which is the whole requirement.
**Matched on the source chunk, not on the chapter.** A chapter holds one
translation per passage, not one translation; matching on the chapter alone
would leave a chapter that could only ever show its most recently translated
paragraph. `getTranslationChunksByChunkId` is the query that says so.
**Tests.** `TranslationChunkApplyJvmTest` covers the three cases against a real
database: a retranslation replaces what it supersedes, a translation arriving
after the one that superseded it is dropped, and two chunks of one chapter each
keep their own. The first two fail against the plain upsert this replaces; the
third is what stops the fix from over-deleting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8a23a28e54 |
fix: let the untranslated side read as text, not as a button
The translation cell was a `TextButton` with its content padding zeroed, which took care of the padding and left everything else a button brings: Material's pill shape, a 40dp minimum height, and a ripple rounded to match. So a chapter's two columns -- the same text, one side not yet translated -- did not read as two columns of one table. One was prose and the other was a control, and the thing being offered is not a control, it is the text with an invitation to write it. It is a plain `Row` now, laid out like the original cell beside it. The click moves up onto the cell's `Box`, before the 12dp padding rather than inside it, so the tap target is the whole cell rather than a button indented within it and the ripple is the rectangle the cell already was. `TableRow` grows a `rightModifier` to carry that, which is where a modifier for that cell belongs. Same greyed-out placeholder, same chevron, same destination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dbdff55ee0 |
feat: open the proposal a transcript line is about, not the room's latest
Tapping a signing line in the transcript opened whichever session the room was running, resolved by `liveSessionForChatRoom` -- the newest one not yet finished, or failing that the newest one at all. That is a guess, and it was a good one exactly as long as a room had one proposal to guess at. With a chapter and its translation open together, half the lines in the transcript led to the other proposal. The line now says which session it belongs to, so there is nothing left to guess: it carries `frostSigningSessionId` through to the route. A line written before that column opens the room's proposal list instead, which is the honest answer to a line that cannot say what it meant -- every proposal with its own state, and the reader picks -- rather than a guess dressed as an answer. That empties `FrostSigningRoute.sessionId` of its reason to be optional, so it is required, and `liveSessionForChatRoom` goes with it from the interface, the implementation and the no-op. `FrostSigningViewModel` loses its resolution step and the "This group is not signing anything right now" error underneath it -- which was never the right thing to say to somebody who had just tapped a line about a specific session. The transcript keeps doing the one job it is good at: showing a proposal as it happens, and saying whether it is still asking something of you. What it stops doing is standing in for a list of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
116cc96de4 |
feat: give a group's proposals a screen of their own
The transcript is where a proposal is met, and it is a bad place to keep one. It answers one question -- is this still asking something of you -- in the middle of everything else the room said that day, and then it scrolls. Until now the only other way in was a signing screen that resolved "the room's live session", so a room with two proposals open had one of them reachable and the group's history had none of it. So: one row per session, newest first, live. The ones still waiting on the reader are gathered under "Waiting for you" and the rest follow, because they are two different kinds of thing to read -- the rest is what the group has done, those are what it is waiting on this member for -- and because burying the second open proposal under the first one's history is the whole failure this screen exists to answer. **Each row carries its own state, from the same rule the signing screen uses.** Whether a proposal still has a decision in it is asked of `FrostSigningManager.isAwaitingApproval` rather than worked out again here, so the list and the screen behind it cannot come to different answers about the same session. The rest of the status is the session's own stage said briefly: you agreed and it is waiting on n of m, you are one of the signers, enough members took part without you, signed, or -- for an abandoned one -- its failure reason, because "declined on this device" and "the group could not agree" are different things to have happened and the reason is the whole content of the ending. **Named after the lead item.** A batch's first item is the one the rest hang off -- a chapter's chunks carry the chapter's id -- so the chapter names the row and the count says the rest of it. An item that could not be read is said on the row rather than left for the screen behind it: a batch is all-or-nothing, so an unreadable item is a reason to refuse the whole proposal. **One query, not one per row.** `LocalFrostSigningSession` embeds the session and relates its items and its proposer, and `observeSessionsForChatRoom` becomes a `@Transaction` query over it -- the repository already declared that method and nothing called it, so this is the shape it should have had rather than a second query beside it. The model sorts the items rather than the query: Room does not order a relation, and item order is protocol rather than presentation, since two devices reading a batch in different orders aggregate against different messages. The proposer is joined for the reason the transcript joins a sender -- so a rename follows, and a member seen only as a pubkey is not stuck on the placeholder their profile was created with. **Derived once per emission.** Every row's events come out of stored JSON. That is not work to repeat on each recomposition of a scrolling list, so the view model does it when the flow emits and the screen renders what it is handed. Reachable from the group's detail screen, beside Shared Key and gated the same way: a shared threshold key only means anything in a room where every member is an equal admin, and a room with no key to sign with has nothing to propose. **Tests.** FrostSigningSessionDaoJvmTest covers what the list reads -- two sessions in a room come back newest first, each with its items in `itemIndex` order despite the relation's own order, and with the proposer resolved to a name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |