Commit Graph

63 Commits

Author SHA1 Message Date
Kgothatso Ngako
05e80bf099 feat: hold every screen's content to a readable line, and centre it in the window
Phase 6, step 5, first half. Every one of the 40 screens rendered a single column
that filled whatever width it was given, so on a 1800dp desktop window a
paragraph became a 1800dp line -- long enough that the eye loses the start of the
next one -- and a six-character text field stretched to 1700dp.

M3: *"across all breakpoints, adjust margins and type styles to keep text between
40–60 characters per line."*

**The measure is derived, not written down.** `readableContentWidth()` is
`bodyLarge`'s font size converted through the current density, times half an em
per character, times sixty: 480dp at the default text size. Writing `480.dp`
instead would be the same number today and wrong for anybody who has turned text
size up -- at 200% the same column holds thirty characters, silently, because the
text still fits. Deriving it means the column widens with the type and keeps its
sixty. `AverageCharacterAdvance` is the one estimate in it, named and documented,
because a proportional face has no character width and half an em is the standard
figure for mixed-case Latin prose.

Only the ceiling is enforced. The floor needs nothing: a 400dp compact window
less its two 16dp margins holds about 46 characters, which is inside the range,
and no cap can add characters to a window that has none. There is a test for
exactly that, so the claim is checked rather than asserted in a comment.

**The column is centred; the text is not.** Those are opposite things and it is
worth being explicit, because "centre it" is how the second one gets done by
accident. A centred column still has one straight leading edge for every row,
avatar and icon to align to, which is what the grids-and-spacing page asks for.
Centred text has none. The 91 `TextAlign.Center` uses are a separate question and
a separate commit.

**Applied at 49 sites in one pass**, at the point every screen consumes its
`Scaffold`'s padding -- the one place in each file that is reliably the top of the
content. Below 480dp it is not a cap, an inset or a centring; it is nothing, so
no phone layout moves.

**Verified by measuring a real composition, not by reading the code.**
`readableContent()` is `fillMaxWidth` then `wrapContentWidth` then `widthIn`, and
every permutation of those three compiles and renders something that looks right
in a phone-width preview. This needed `compose.desktop.uiTestJUnit4` in `jvmTest`
-- pinned to the same 1.11.1 as the rest of Compose Multiplatform, test-only --
and `runDesktopComposeUiTest(width = 1400)`, which gives a window that genuinely
is 1400 pixels across at density 1.

Four assertions, and they bite: swapping the last two modifiers makes the
1400dp case report `Actual width is 1400.0.dp, expected 480.0.dp`, which is the
"centred but never capped" failure the doc comment names. The same test also
pins `currentBreakpoint()` to the real window at all five widths -- 400, 700,
1000, 1400, 1800 -- with the screen margin following. A version of it that
measured the parent's constraints rather than the window would answer `Compact`
everywhere and pass every unit test in the suite.

37 theme tests green; android and desktop both compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:31:33 +02:00
Kgothatso Ngako
03d1e8e3b1 feat: give the app the five breakpoints, and let the screen margin follow them
Phase 6, steps 1 and 2. The app had no notion of window width at all -- two
`BoxWithConstraints` in 30,000 lines of UI, both inside a view model -- so every
layout decision in it was made once, for a phone, and then rendered unchanged
into a 1800dp desktop window.

**The dependency question the plan asked to settle first.** `material3-adaptive`
publishes multiplatform under `org.jetbrains.compose.material3.adaptive`, with
android, desktop and ios variants; the ios ones carry `ios_arm64` and
`ios_simulator_arm64` attributes despite the `uikit*` artifact names, so the
targets this project declares on a mac resolve. Version **1.2.0**, not the newer
1.3.0-beta02, because that is the version the pinned material3 itself resolves:
`material3-adaptive-navigation-suite:1.10.0-alpha05` names `adaptive:1.2.0` in
its pom, and 1.3.0 would pull window-core 1.5.0 in beside the 1.4.0 the pinned
material3 compiled against. Nothing is lost by staying: 1.2.0 already computes
the large and extra-large breakpoints through `supportLargeAndXLargeWidth`, and
carries `ListDetailPaneScaffold` for the pane work. So steps 3-4 can use the
library scaffolds rather than a hand-rolled equivalent.

**`Breakpoint`** is the five-value enum -- compact / medium / expanded / large /
extra-large at 0 / 600 / 840 / 1200 / 1600dp -- with `ofWidth` as a pure function
so the thresholds are assertable without a Compose runtime. `TorchTheme`
classifies once and provides `LocalBreakpoint`, so no two screens can disagree
about the window they are both in.

It reads `currentWindowDpSize()` rather than `currentWindowAdaptiveInfo()`.
The latter also computes a `Posture` from the platform's fold state, which on
android reaches for `WindowInfoTracker` and an activity; this call sits in
`TorchTheme`, which wraps all 51 `@Preview` bodies in the tree, and a preview
context is not an activity. The pane scaffolds ask for posture themselves, at
the one place a fold changes the answer.

**Spacing now adapts, and exactly one value moves.** M3 publishes a margin per
breakpoint -- 16dp compact, 24dp everywhere wider -- and publishes nothing else
that varies with window width. The scale itself is absolute: `space200` is 16dp
on a phone and 16dp on a desktop, and what adapts is which token a job reaches
for, not the token. So `screenMargin` goes 16 -> 24 at medium and holds there,
and `containerPadding`, `itemGap` and the rest do not move -- a card does not
become a different component because the window grew. Widening all of them is
the "everything breathes on a big screen" instinct, and it reads as a zoomed
phone rather than as a layout. A test asserts the non-movement, because that is
the edit a later reviewer would wave through.

Mechanically this made the eight semantic names constructor parameters instead
of `get()`s over the scale, so a breakpoint can reassign one without moving the
stop underneath it. Kotlin resolves a default expression against the parameters
before it, so each still reads its stop by name and still follows it when the
scale is overridden -- phase 2's `Spacing(space200 = 24.dp)` assertion holds
unchanged. The two instances are singletons because `LocalSpacing` is a
`staticCompositionLocalOf` and invalidates on identity, not equality.

**A test found a real defect while being written.** `ofWidth` was
`entries.last { width >= it.minWidth }`, which throws `NoSuchElementException`
below 0dp. A desktop window reports a zero size for the frame before its first
layout pass, and this is called from the theme on every composition, so the
crash would have arrived on a resize rather than on anything a user did. Now
total.

`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` both
green; 28 theme tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 07:15:43 +02:00
Kgothatso Ngako
4fe47c7d46 fix: derive every call-site colour from its container, ending nine contrast failures
Phase 3, first step, of docs/material-design-conformance.md. The generated palette was
already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every
failure in the app came from a colour reached for at the call site instead of derived from
what it sits on.

**The worst one made the app's most important rows invisible.** `ProposalListScreen` put a
`ListItem` inside a `Card` and overrode only the card's container:

    Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) {
        ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent),

`cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so
`LocalContentColor` inside the card was correct. `ListItem` does not read
`LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is
`onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`.
Measured on that card:

    headline (onSurface)          1.00:1     invisible
    leading icon (primary)        1.22:1
    supporting (onSurfaceVariant) 1.84:1
    "could not be read" (error)   2.67:1
    onPrimaryContainer            4.61:1     the only one that worked

Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` --
the proposals waiting on your signature. Dark was fine throughout, because there
`primaryContainer` is black, so this only ever showed in the light scheme.

The card's colours are now computed once and everything inside derives from
`cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the
"Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight
so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would
read more like "this needs you", and that is a design call recorded in a comment rather
than taken here.

On the highlighted card the failure state loses its red, because `error` is 2.67:1 there.
The signal survives in the icon and in the sentence "could not be read", which is the more
robust cue anyway and the only one available to somebody who cannot distinguish the red.

**`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer`
with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on
a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help.

**Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.**
The audit called them caption text; they are `CircularProgressIndicator` colours, so the
threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the
wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at
timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant`
at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to
become one.

**The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1,
under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be
read and is 6.46:1.

**The avatar picker used a content colour as a background.** `onSurface` at 50% composited
to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was
close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being
straight about the limit: that role is 1.65:1 against the surface in this palette, which M3
accepts because its own selected states carry a second cue, an outline or a checkmark. This
grid has neither. Adding one is component work, and the comment and the plan both say so
rather than leaving it looking finished.

**Three colours stay hardcoded, and each says why at the site.** A new
`// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and
the audit honours it:

  - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance
    contrast between the modules and their background, and under dynamic colour
    `onSurface`/`surface` could be two mid tones and unscannable.
  - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so
    no role is safe behind it. A translucent scrim with white on it is M3's own
    full-screen media treatment and the only pairing that holds over both a white sky and
    a black one.
  - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With
    no placeholder the surface is known and the role is used.

Exemptions belong at the call site: the reason travels with the code and a reviewer sees it
in the diff that adds it, rather than in a list of file names in the audit script.

**Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's
`Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every
one of this app's six schemes. Same bytes, and the value now travels with the theme.

**A new assertion for the case the others structurally cannot catch.** A translucent
container has no contrast ratio of its own -- it has one only once composited -- so
`ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted
containers over `surface` and measures the result, in all six schemes, naming the call site
in the failure. The pairings this commit *fixed* are not restated: once the proposal card
derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`,
which the first assertion already walks.

**Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file.

**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348.
`:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
Kgothatso Ngako
73d99f9a41 feat: put M3's spacing scale in the theme, with semantic names over it
Phase 2, first step, of docs/material-design-conformance.md. 527 `.dp` literals in the UI
tree and no record of what any of them is for. This is what they migrate onto; the sweep
that moves them is the next commit.

**The scale is M3's own**, transcribed from m3.material.io/m3/pages/spacing/tokens: an 8dp
system where `space100 = 8dp`, including the sub-8 nested units (2, 4, 6) and the
non-multiples (10, 14, 20, 36) that Material defines because its own components need them.
Eighteen stops.

Worth being precise about what the audit found, because it changes what this phase is for.
The two dominant values in the tree are `10.dp` (132 uses) and `20.dp` (115), and **both
are already on the scale** -- `space125` and `space250`. Only 89 of 527 are genuinely
off-grid. So this is not mostly a sweep for wrong numbers. It is that nothing records
whether a given `10.dp` is padding, a gap or a margin, which are three things the spec
gives different rules to, and none of them can be adapted per breakpoint or per density
while they are literals.

**A `data class` behind a composition local, not a file of constants.** Nothing scales it
today and `Spacing()` is provided unmodified. It is shaped this way because two things are
coming that need it: spacing adapts across breakpoints, and M3 has a density setting for
data-heavy views. Both become a matter of providing a different instance rather than
touching a call site -- but only if the values arrive through the local. Top-level `val`s
would read identically and adapt to nothing, which is the version of this that looks done
and is not.

**Eight semantic names, because `space125` is no more readable than `10.dp`.** It says the
size and not the job. `screenMargin`, `containerPadding`, `compactPadding`, `relatedGap`,
`itemGap`, `sectionGap`, `emphasisGap`, `targetGap` say the job, and they are what call
sites should reach for; the raw stops are for the cases none of them fits.

They are split along the distinction the spec draws -- padding is inside an element, a gap
is between elements in a container, a margin is outside one -- and there is exactly **one**
margin, for the screen edge. That is deliberate: "define padding and gaps on the parent
container", "avoid defining margins on child elements as they usually aren't uniform, and
require more tokens". A semantic layer with a margin per element would have re-created the
problem in better-sounding names.

**Six assertions, and three of them are about failure modes that are invisible in review.**

  - Every stop matches its published value. `space175 = 15.dp` would look entirely
    plausible in the source, compile, and put every call site one unit off the grid.
  - The token name predicts the value: the number after "space" is the value as a
    percentage of the 8dp base, so `space250` is 20dp. A stop that does not obey that is a
    stop nobody can predict from its name.
  - Every semantic name resolves to a stop that is actually on the scale. The layer stops
    being a scale the moment one of them is handed a literal, which is easy to do and
    invisible to review.
  - `targetGap` is at least 8dp, M3's minimum separation between adjacent touch targets --
    the one semantic name with an external floor, and the one phase 3 will apply between
    icon buttons.
  - A scaled instance moves the semantic names with it. This is what the data class is
    *for*: if a semantic name were a hardcoded `Dp` rather than a reference to a stop it
    would stay behind at a wider breakpoint and the layout would half-adapt, which is worse
    than not adapting.

**Tests.** 936 pass, 594 jvm over 72 classes and 348 android over 44, up from 930/588/342.
`:composeApp:compileDebugKotlinAndroid` builds. No call site changed, so no pixels moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:26:05 +02:00
Kgothatso Ngako
47bdb6f976 fix: promote the two pill colours to extended roles, fixing both contrast failures
Phase 1, step 5 of docs/material-design-conformance.md. `BluePill` and `RedPill` were raw
`Color` values in `Color.kt`, paired at the call site with `Color.White` and
`Color.DarkGray` by eye. Both pairings were below the 4.5:1 floor, and one of them was not
the colour it looked like.

**This step could not leave the pixels alone, and it is the only one so far that changes
them.** `Color.DarkGray` on `BluePill` measures **2.90:1**. `RedPill` was
`Color(230, 32, 32, 191)` -- the four-Int constructor, whose last argument is alpha, so it
is `#E62020` at 0.749. Opaque, white on it is 4.57:1 and passes; composited over the
surface as it actually renders, it is **3.50:1** and does not. Any correct version of these
two buttons is a visible change, so "adds, does not restyle" does not apply here and the
plan already said the call sites would move in this step.

**What M3 asks for here is an extended colour**, not a literal: a brand colour promoted to
a full role family -- `color` / `onColor` / `colorContainer` / `onColorContainer` -- so
that contrast is a property of the family rather than a decision repeated at each use.
`ColorFamily` was already declared in `Theme.kt`, unused, alongside an
`unspecified_scheme`; Material Theme Builder emits both, and this is what they are for.

**Derived by the same rule as the gold palette**, which the fixed-roles commit established
and verified: maximum in-gamut chroma at the source colour's Lab hue, sampled at M3's role
tones. BluePill's hue is 277.0 and RedPill's is 36.3.

    role              light      dark        blue light   red light
    color             tone 40    tone 80     #0060AB      #C00012
    onColor           tone 100   tone 20     #FFFFFF      #FFFFFF
    colorContainer    tone 90    tone 30     #D7E2FF      #FFDAD3
    onColorContainer  tone 10    tone 90     #001C39      #390C00

The buttons take `color`/`onColor`: 6.46:1 for the red pill and 6.44:1 for the blue, from
2.90 and 3.50.

**A side effect worth having.** At tone 40 the two pills are the same lightness, so they
now read as a matched pair. Before, `#E62020` sat beside `#5D8DD6` -- a saturated red next
to a soft periwinkle -- and the blue looked like the lesser option. On a screen whose whole
content is "commit, or wipe and leave", weighting one choice by accident is a defect of its
own.

**They travel on a composition local, not on `isSystemInDarkTheme()`.** `ColorScheme` has
no slot for extended colours, so `LocalExtendedColors` is provided by `TorchTheme` from the
same `darkTheme` it chooses the scheme with. Reading `isSystemInDarkTheme()` at the call
site would have been one line shorter and subtly wrong: it ignores a caller who passed
`darkTheme` explicitly, so a preview forcing dark would show light pills. The local
defaults to the light families rather than to `unspecified_scheme` -- nothing composes
outside `TorchTheme` today, and an invisible button is a worse way to discover that than a
light-themed one.

**No medium- or high-contrast variants, deliberately.** The entire surface is two buttons
on one screen, and the light family's weakest pair is 6.44:1 -- clear of the floor by more
than the contrast schemes would add. 32 more values for that would be out of proportion,
and the comment in `Color.kt` says so rather than leaving the omission to be read as an
oversight.

**`QRCodeView` lost its constructor default.** `QRCodeBackgroundPainter` defaulted
`backgroundColor` to `BluePill` -- a colour picked outside the theme for a surface that is
almost never seen, since at the default `padding = 0.dp` the logo painter covers the rect
it fills. The default is gone and the one call site passes it, so the choice is visible
rather than buried.

**Two new assertions, one of which is about the constructor.** `ColorSchemeContrastTest`
grows to 9. The first checks both pairs of every extended family at 4.5:1. The second
checks that every extended role is **opaque**, because `RedPill`'s alpha is what made the
first assertion insufficient: a translucent container has no ratio of its own -- it has one
only once composited -- so a contrast test would have measured a colour the user never
sees. That is the bug this commit fixes, and it would have passed a naive contrast test.

**The audit stopped counting its own commentary.** Fixing these call sites left a comment
*explaining* what `Color.White`/`Color.DarkGray` had been, and `m3-audit.sh` counted it as
a hardcoded colour -- so the file stayed in the report after being fixed. The script now
drops comment lines before counting. Budget ratcheted 11 -> 9: the two real sites, plus the
false positive the filter removes.

**Tests.** 930 pass, 588 jvm over 71 classes and 342 android over 43, up from 926/586/340.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build,
`m3-audit.sh --check` exits 0. The nine remaining hardcoded colours are phase 3's, and are
listed by the audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:23:31 +02:00
Kgothatso Ngako
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>
2026-09-08 00:10:06 +02:00
Kgothatso Ngako
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>
2026-09-07 23:59:34 +02:00
Kgothatso Ngako
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>
2026-09-07 23:51:34 +02:00
Kgothatso Ngako
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>
2026-09-06 21:21:46 +02:00
Kgothatso Ngako
ff1ecc1194 Merge branch 'mantra' into claude/torch-intro-boot-hang-76b0a8 2026-09-06 20:44:42 +02:00
Kgothatso Ngako
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 0a5a219 (2026-07-02).

**The gate is always open.** GlobalPrefs.kt, in the lightning-kmp-app submodule:

    /** True if the intro screen must be shown. True by default. */
    val getShowIntro: Flow<Boolean> = safeData.map { it[SHOW_INTRO] ?: true }
    suspend fun saveShowIntro(showIntro: Boolean) = data.edit { it[SHOW_INTRO] = showIntro }

`saveShowIntro` has no caller anywhere in this repository -- its definition is
its only occurrence. SHOW_INTRO is therefore never written, `?: true` is the
answer every time, and the gate fired on every cold boot rather than once ever.
Phoenix clears it when its onboarding carousel finishes. Mantra has no
onboarding carousel; the destination is a loading placeholder. Nothing clears
it because there is nothing to have been shown.

**Leaving the screen is what stops the wallet.** The gate was the first thing in
the composable, ahead of the Box that does the actual work:

    val showIntro = sovereignWalletStartupViewModel.getShowIntroFlow().collectAsState(initial = null)
    if (showIntro.value == true) {
        LaunchedEffect(Unit) { onNavigateToWalletIntroPage.invoke() }
    }

Startup is not something that happens behind this screen; it *is* this screen.
Reaching setActiveWallet means descending ListWalletState.Success -> a non-empty
availableWallets -> wallet metadata and default wallet -> StartupViewState.Init
-> LoadWallet, whose produceState reads getLockBiometricsEnabled and
getLockPinEnabled before its LaunchedEffect calls doLoadWallet -> startupNode.
Navigating away disposes that composition and cancels the LaunchedEffect that
was going to make the call. A screen that has been left behind does not start a
wallet, so activeWalletInUI stayed null.

**And the state machine had already gone quiet.** NavigationViewModel.
observeProfile collects activeWalletStateFlow with collectLatest. Null wallet,
so it had already put StartupPhoenix into _navigationUIState -- which is how the
user arrived at the startup screen in the first place. With activeWalletInUI
pinned at null the flow never emits again, so collectLatest never re-runs; and
even a re-run would `getAndUpdate { NavigationUIState.StartupPhoenix }` onto a
MutableStateFlow already holding that same `data object`, which conflates. No
emission, so MantraNavHost's collector never fires. Nothing was watching
anything any more.

**The one remaining exit was commented out.** onNavigateToWalletIntroPage
launched loadNostrProfile(route) alongside the navigate, and that was the whole
plan: park on a placeholder, work out where the user actually belongs, go
there. In 0a5a219, NostrRepository.observeProfile was renamed
observeLocalAccount and the call stopped compiling, so the branch was commented
out where it stood:

    } else {
    //            if (startupRoute != null) {
    //                val localAccount = nostrRepository.observeProfile(
    //                    publicKey = activeUserPublicKey
    //                ).firstOrNull()
    //
    //                processLocalAccount(localAccount)
    //  ...
    }

Note where the comment markers fall. The surviving `if` covers only
`activeUserPublicKey == null`. For anyone who already had an account,
loadNostrProfile read the database, decided nothing, and returned -- a suspend
function whose entire contract is to leave the navigation state pointing
somewhere, doing so for exactly one caller out of two.

**Why "sometimes".** Two axes.

The first is a race between DataStore reads. The gate is a single read; the
startup path is a chain of them. Usually the gate wins and nothing starts. When
doLoadWallet did fire first, startupNode runs on
SovereignWalletStartupViewModel.viewModelScope, scoped to the back stack entry
-- and the intro navigate used no popUpTo, so the entry survived the departure.
The node came up anyway, setActiveWallet fired, activeWalletInUI went non-null,
collectLatest re-ran and routed properly. Boot looked fine.

The second is whether there is an account. With none,
`getLocalAccounts().firstOrNull()?.profile?.publicKey` is null, the surviving
branch returns Landing, and the user lands on the create-a-profile screen. Only
a device that already had a profile could reach the dead end. Fresh installs
looked healthy, which is a good way for a bug to stay hidden.

**The other dead end, which this one was hiding.** availableWallets.isEmpty()
means no seed on the device, and it called onNavigateToWalletLandingPage, which
navigated to LoadingRoute("Loading... Torch") and launched nothing at all. Not a
race, not a rename -- just a loading screen with nothing left to load. It was
never noticed because on a device with no seed the intro gate got there first
and reached Landing by the account check above. So the accidental path was the
only working route a new user had to creating a wallet, and removing the gate
without fixing this would have broken first run.

**The change.** Three files.

- SovereignWalletStartupScreen: the gate and the onNavigateToWalletIntroPage
  parameter are gone. Startup runs to completion here; everything downstream
  reads the node's key manager, so departing before the node is up cannot work
  regardless of where it departs to.
- MantraNavHost: onNavigateToWalletLandingPage navigates to LandingRoute with
  popUpTo(0), which is where making or restoring a seed lives, and matches how
  every state-driven navigation in this file clears the stack.
- NavigationViewModel: the branch restored against observeLocalAccount, as
  `else if (startupRoute != null)`, so every path out of loadNostrProfile leaves
  the navigation state somewhere.

Neither "... Torch" loading string exists any more.

**Left in place.** getShowIntroFlow -- the expect/actual across android, ios and
jvm, and the accessor on SovereignWalletStartupViewModel -- now has no callers.
It is kept because a real intro screen will want it, but it has to be a step
*inside* this flow, arriving before the wallet is needed and continuing to
startup, not a detour around it. Wiring it back where it was would restore this
bug exactly.

**Not covered, deliberately.** loadNostrProfile still keys off
`getLocalAccounts().firstOrNull()?.profile?.publicKey`, so an account whose
kind-0 is not yet indexed reads as no account and answers Landing, while
observeProfile -- which uses the key manager's pubkey and does not need a
Profile row -- answers with the real state a moment later. Last write wins,
self-correcting, and unchanged by this commit; it predates it and wants the
account lookup rethought rather than patched here.

**Tests.** NavigationRoutingTest, four of them in commonTest, against
loadNostrProfile directly. NostrRepository.NO_OP_NOSTR_REPOSITORY throws from
all 41 of its members, so `by` delegation over it gives a device that answers
getLocalAccounts and observeLocalAccount and fails loudly on anything the call
was not supposed to touch. Each test starts the view model on
NavigationUIState.Loading("Introducing... Torch") -- the screen people were
stranded on -- and asks whether the answer moved.

Three of the four fail with the branch commented back out, and the headline one
fails saying "boot stopped on the screen it was asked to move off. Actual:
Loading(text=Introducing... Torch)", which is the bug report. The fourth, a
device with no account reaching Landing, passes either way: that branch was
never broken, and it is here so that losing it would not be free.

The unqueued-profile case earns its place because the headline assertion is weak
alone -- a constant would satisfy it. Two accounts differing only in signedAt
come back ProfileLoaded and UnqueuedProfile, so what is pinned is that the
destination is read off the account rather than being one fixed answer for "has
an account".

Both entities default their timestamps to Clock.System.now() and compare them in
equals, so the fixture pins them. Without that, building the expected Profile a
second time builds a different Profile, which is how the first run of these
failed.

**Not tested.** The other two files. Removing the showIntro gate and pointing
onNavigateToWalletLandingPage at LandingRoute are Compose and NavHost wiring,
and there is no Compose UI test infrastructure here to hang them on. Note where
that leaves the coverage: the race that decides whether a given boot hangs lives
in the untested half. What these tests hold is that the boot has somewhere to
land once it arrives -- the half that turned a lost race into a dead end rather
than a delay.

Verified: :composeApp:compileDebugKotlinAndroid succeeds; 515 jvm tests, 511
before these four, 0 failed. ChronicleApplyJvmTest "an answered catch-up leaves
one line, whatever it took to deliver" is flaky independently of this change --
it failed with these files reverted to their committed state, and has both
passed and failed on identical code since. Filed separately, not touched here.

The two untested files are read, not run: I did not put the app on a device.
What is asserted about them is the code -- that saveShowIntro has no caller,
that the commented branch was the only exit from that route, and that both
replacements compile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:39:33 +02:00
Kgothatso Ngako
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>
2026-09-06 20:12:49 +02:00
Kgothatso Ngako
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>
2026-09-06 18:21:19 +02:00
Kgothatso Ngako
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>
2026-09-06 18:20:57 +02:00
Kgothatso Ngako
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>
2026-09-06 18:18:16 +02:00
Kgothatso Ngako
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>
2026-09-06 16:29:23 +02:00
Kgothatso Ngako
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>
2026-09-06 16:27:36 +02:00
Kgothatso Ngako
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>
2026-09-06 16:06:17 +02:00
Kgothatso Ngako
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>
2026-09-06 15:13:43 +02:00
Kgothatso Ngako
54091099a9 Merge branch 'mantra' into claude/happy-gauss-dbe258 2026-09-06 15:06:13 +02:00
Kgothatso Ngako
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>
2026-09-06 14:12:29 +02:00
Kgothatso Ngako
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>
2026-09-06 14:00:30 +02:00
Kgothatso Ngako
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>
2026-09-06 13:52:53 +02:00
Kgothatso Ngako
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>
2026-09-06 13:44:28 +02:00
Kgothatso Ngako
4de87edf12 fix: tell one proposal's transcript lines from another's
A room with two proposals open showed "Review" on both, and then dropped it from
both the moment either one was decided. The second proposal was still waiting on
the reader, still had a decision in it, and had nowhere left to be reached from.

Two proposals at once is not a corner case any more: a chapter and the
translation scaffolding beside it are proposed as separate sessions, on purpose,
and they run at the same time. Both write the same line types into the same
stretch of transcript.

`answeredRequests` matched a request against any later line of the fulfilling
type, and `settledRequests` against any later ending. That reads a room signing
one thing at a time exactly right -- the nonce after the request is the answer to
it, because there is nothing else it could be an answer to -- and a room signing
two things at once exactly wrong. Nothing else on the row could separate them:
same type, same room, same minute, and `ChatMessage` carried no session.

So the session goes on the row. `ChatMessage.frostSigningSessionId` is nullable,
added as schema v11 through `AutoMigration(10, 11)`, and stamped by
`FrostSigningManager.announce` -- the one place every FROST line is written, so
there is no line that can be forgotten. Both rules read it when both rows have
one and fall back to the clock when either does not.

The fallback is not a compromise, it is the right reading of the rows it applies
to. A line written before this column has no session and never will, and the
rooms that wrote those lines could not run two sessions at once, so the clock is
the whole truth there. A ceremony line falls back too and always will: a room
runs one ritual at a time, and a DKG step is either taken or still waited on.

**This reverses a call `FrostSigningRoute` argued for.** Its note said a chat row
carrying a session id was "a poor trade for a lookup the screen can do". That was
right when the lookup could only be wrong about which of one session it meant.
The batch work made two sessions ordinary, and the lookup and the rules both
became guesses at the same moment. A column on the table every message uses is
the cost; two proposals, one of them unreachable, was the alternative.

**Tests.** Three in TranscriptRequestStateTest for what the column buys: a nonce
answers its own session's request and not the other's, one session completing
settles nothing in the other, and a line naming no session is still read by the
clock. TranslationBatchProposalJvmTest proves the other half against a real
two-session proposal -- every FROST line the manager writes names its own
session, and neither session's lines are attributed to the other. The rule is
tested on rows and the stamping is tested on a database, because a rule that is
right about rows nothing writes correctly is worth nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:41:01 +02:00
Kgothatso Ngako
df058dc61c feat: let a translation ask for the chapters it is missing
A translation is scaffolded from both ends now -- the chapters that existed when
it was proposed, and each chapter signed afterwards putting itself in. Neither
end closes the gap on its own, and no snapshot taken at proposal time can.

Two ways they miss each other. A chapter and a translation proposed at the same
moment each read what exists when they are proposed, so neither sees the other
and nothing retries. And a scaffolding session that fails to reach a quorum
leaves nothing behind to try again with -- the chapter's id is spent, since a
re-proposed chapter is a different one.

So the translation's own screen says what it is missing and offers to ask for
it. Above the chapter list rather than below, because a chapter that is not in a
translation is invisible from a list of the ones that are: the whole failure is
that nothing looks wrong.

**Matched on the chapter named, not counted.** `chaptersMissingFrom` compares
which source chapter each translation chapter stands for. A count would read a
translation that is missing its second chapter but picked up its third as one
missing its last, and would then scaffold the wrong chapter -- leaving the real
gap open and a duplicate beside it. It also means running a catch-up on a
translation that is already complete proposes nothing at all, rather than a
second copy of every chapter under fresh ids.

**More sessions rather than a cap.** The missing chapters are chunked at
MAX_BATCH_SIZE, one session each. A translation far enough behind to need more
than a batch holds is not one to refuse; it is one the group answers for more
than once. They share a timestamp, so a catch-up reads as the one act it is.

The screen lands on the first session -- the rest are beside it in the room's
list -- and the card stays until a quorum arrives, which is honest: the chapters
are still missing until then.

**Tests.** Two more in TranslationScaffoldTest, both about the matching rather
than the counting: a gap in the middle, and a complete translation being missing
nothing. Checked against a broken implementation -- taking the missing chapters
as the tail after a count passes on a translation that fell behind at the end,
which is the easy case, and is caught by the gap.

Not covered: `catchUpMissingChapters` itself, which is plumbing over the
templates those tests pin and the batch API the jvm tests pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:03:54 +02:00
Kgothatso Ngako
359f54812f refactor: give the translation/chapter join one home
TranslationScaffold owns the rows that join a translation to the chapters it is
a translation of. Pure refactor: the same events go out in the same order,
`TranslationBatchProposalJvmTest` passes unedited apart from the call it makes,
and no screen behaves differently.

The move is worth making before anything is built on it. A translation chapter
carries no words -- it is `(translation, chapter, position)` and nothing else,
and it exists so a translated chunk has somewhere to hang. Both of the things
it joins arrive on their own schedule: a chapter is signed into an artifact
that already has translations, a translation is started on an artifact that
already has chapters. So the same cross product has to be built from either
side, and a second copy of it is a second chance to disagree about what a
translation covers -- a disagreement that shows up as a chapter nobody can
translate rather than as anything that looks like a bug.

**Over ids, not rows.** `chaptersOf` takes translation ids and a
`SourceChapter`, which is a chapter reduced to which one and where it sits,
rather than a `MantraChapter`. Neither end is always a row: a translation being
proposed exists only as the unsigned event a session is about to sign, and so
does a chapter. `SourceChapter.of` is there for the callers that do hold a row.

**Two things it decides rather than leaves to a caller.** Item order is apply
order, so the nesting is fixed here -- translations outer, chapters inner, which
keeps one translation's chapters contiguous and in reading order. And
`createdAt` is taken once rather than read per template, so a scaffolding
proposed as one act reads as one rather than as events that happen to share a
minute.

The index is the source chapter's own, never the position in the list handed
in. They agree when the list is a whole version in order and stop agreeing the
moment a caller holds a subset, and only one of them is what the group signed.

**Tests.** TranslationScaffoldTest covers it as the pure function it is: the
nesting, both directions it is built from, one timestamp for the lot, the empty
cases, and the index surviving a non-contiguous subset. Checked against a broken
implementation -- taking the index from the list position passes every test that
uses a whole version in order, and is caught by the subset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:01:42 +02:00
Kgothatso Ngako
2e133dd337 feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the
group signs the lot at once. Every row a member ends up with is signed: a
translation is of a chunk, and a chunk that carries the group's signature over
its own words can be checked by anybody holding it, rather than only by
re-deriving it from the chapter it came out of.

This replaces the derivation two commits ago, which split the chunks out of the
signed chapter's text on each device and left them as rumors. That was the
right shape when a chunk could only have its own signature by having its own
quorum. Batch signing removed that, and this is the other side of the trade
`MantraChunk.chunksOf` was weighed against.

**A batch whose items name each other.** A chunk carries its chapter's id, and
that id is a hash over the group's key at the room's derivation path -- neither
resolved until the proposal runs. A caller computing it would be recomputing
`signingPath`, the one input in this protocol that must never come from a
proposer, since the path decides which key the group signs as. So
`proposeSigningBatch` gains a second form: a `lead` template, and a
`dependents` builder handed the lead *after* it is authored, returning the
events that reference it. Every id still comes out of `unsignedEventOf`, which
makes an item naming a chapter nobody signed something that cannot be built
rather than something to be tested for. `AddChapterViewModel` passes
`ChunkEvent::splitOf` and nothing else.

The lead is item 0. Items apply in `itemIndex` order and a chunk row whose
chapter does not exist yet is a foreign key violation, so what is referenced is
signed first as well as named first.

**The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the
chapter takes one place, so a chapter is capped at 63 paragraphs and a longer
one has to be split in two. That is a real limit on real prose. The form counts
chunks against the cap as the text is typed, colours the count when it is past,
says what to do about it, and will not propose -- because the alternative is an
IllegalArgumentException after the fact. The manager still refuses
independently; the screen is not what enforces it.

**What went away.** `MantraChunk.chunksOf` and the derivation it did inside
`ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and
go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent`
still carries the whole text beside chunks that hold the same words: chunk
boundaries are a decision about how to divide the work, and a chapter that kept
only the pieces could never be divided differently again.

**Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what
each chunk names, counts and carries. `SignedChapterTest` signs a real batch,
one FROST instance per item, and checks every chunk row is authored by the room
and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the
real proposal against a real database, which is where the sharp edge is: item
order, the chunks naming the chapter as the group will author it, and both ends
of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session
behind. Checked against broken implementations: putting the lead last, naming
the wrong chapter, and stamping the chunks off the clock are each caught, in
both suites.

`jvmTest` runs on linux again as of the merge, which is what made the
database-backed test possible.

Dropped a nonce-reuse test that was in the first draft of this: it asserted
over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest`
already hold the manager to giving every item its own nonce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:34:12 +02:00
Kgothatso Ngako
e081d14f37 refactor: weigh the chapter's chunks against batch signing, and keep deriving
Batch signing landed on mantra while this branch was open, and it makes the
argument this change was built on obsolete as written. MantraChunk.chunksOf
said the chunks "cannot be events proposed on their own -- that would cost a
quorum per paragraph". They can now: proposeSigningBatch would carry the
chapter and a chunk per paragraph through one quorum, and every row would hold
a signature of its own.

Weighed and declined, and the KDoc now says so rather than leaning on a reason
that stopped being true. MAX_BATCH_SIZE is 64, which caps a batched chapter at
63 paragraphs and fails an ordinary one outright; the text would go on the wire
twice, whole on the chapter and again split across the chunks, and the proposal
is the term that cap is sized against; and all-or-nothing over k items would
make a long chapter less likely to be signed than a short one, for no reason a
member could see. The signature it would buy is redundant besides -- the
appendix rejects the manifest shape because an item then needs a lookup to be
checked, and here that lookup is a foreign key: a chunk is a pure function of
its chapter and cannot be stored without it.

docs/frost-batch-signing.md records this under the slot Phase 7 leaves open --
"deciding *what* to batch" -- because the next caller will reach for the same
shape. The rule it leaves behind: batch siblings, not derivations. Events that
could each have been authored separately are worth a batch; events that are a
function of another event in the same batch are worth deriving instead.

**The merge.** Only SignedChapterTest broke: the five per-item columns moved
off FrostSigningSession onto FrostSigningItem, so it builds an item and calls
signedEvent(item, sig), which is how SignedArtifactTest was ported in the same
commit. Nothing in the flow itself moved -- proposeSigning kept its signature
as the one-event form, and complete() applies each signed event through
ChatMessage.applyInnerEvent, so the chapter's chunk derivation works the same
whether the chapter arrives alone or as one item of somebody else's batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:13:09 +02:00
Kgothatso Ngako
60abf243ed Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64 2026-09-06 10:07:40 +02:00
Kgothatso Ngako
2309879153 test(frost): cover the batch's failure modes and its crypto without a database
Phase 6 of docs/frost-batch-signing.md. 361 jvmTest and 227 testDebugUnitTest
pass.

## Inbound path (SignedGroupKeyStateTest)

Both drive the manager with a hand-built inner event rather than one the other
device queued, which is the only way to be a faulty or dishonest member in this
harness.

- A one-value nonce offered for a three-item batch does not count towards the
  threshold: the coordinator never reaches a signer set. The length check is all
  that stands between a batch and a signer whose contribution lines up against
  the wrong messages, so truncating or padding would produce partial signatures
  aggregated against events nobody agreed to. The test then pumps the real nonce
  and the batch completes -- it is a stall, not damage, which is
  FrostSignerMessage's composite key doing its job.
- A second proposal under the session's own id changes neither its event ids nor
  its seeds. Every seed is already committed to its item's message; a different
  batch under the same id would have those seeds produce a second partial
  signature over a second message, which is how a share is extracted.

## Real FROST, no database (FrostSigningRoundTest)

- A k=3 batch from one signer set, all three verifying against the room's key --
  the manager's shape with the database taken out of the way.
- Item 0's signature does not verify against item 1. Signing three events in
  lockstep must not make any of them interchangeable.
- Both halves of the no-shared-nonce property, because either alone is enough to
  be relied on by accident: SecretNonce.generate mixes the message in, so one
  seed under two messages already gives two nonces -- and the manager mints
  distinct seeds regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:58:48 +02:00
Kgothatso Ngako
59c34263b3 feat(frost): let one signing session carry a batch of events
Phase 3 of docs/frost-batch-signing.md. A session can now be proposed over
several events, and the whole batch is signed in one round of four group
events with one approval. 356 jvmTest and 224 testDebugUnitTest pass.

## The wire, and the compatibility rule that shapes it

FrostSigningEvents.encodeProposal serialises a batch of one as the bare event
object it always was, and only a genuine batch as a JSON array. That is not
tidiness. A build predating this reads an array with Event.fromJsonOrNull, gets
null, and drops the proposal -- so an old device refuses a batch outright
rather than signing part of one, while single signing keeps working right
through a mixed-version rollout. Emitting an array unconditionally would break
every one-event session for those devices and buy nothing.

decodeProposal accepts both forms permanently: proposals in the old shape do
not stop arriving because this build stopped writing them. It is
all-or-nothing -- an array with one unreadable element is refused rather than
silently shortened, because the batch's length is what every later payload is
checked against, and a proposal that quietly lost an event would have every
signer's contribution rejected for being the wrong size: a stall with nothing
to blame.

## MAX_BATCH_SIZE, checked twice

64, enforced in proposeSigningBatch and again, independently, in
acceptProposal. The second check is the one that matters. A proposal is the
only place in this protocol where a remote party decides how much work everyone
else does -- k native key generations, k signatures, and a group event carrying
k payloads, from a single message -- and until batching that was bounded only
by never being more than one.

## acceptProposal over a list

Each element is rebuilt from its own fields under this device's own reading of
the room's path and checked against the id it claims, exactly as before but per
item, and the whole proposal is dropped if any one fails. The write-once rule
widens from "the event this session signs" to "the ordered list of events this
session signs": a second proposal under the same id whose list differs anywhere
is logged and ignored.

## The API

FrostSigningManager.proposeSigningBatch(events: List<EventTemplate<*>>) is
public here rather than in Phase 4, because without it there is no way to
produce a k>1 session and everything above would ship untested. proposeSigning
keeps its signature as the one-event form, so no caller moves. Each template
carries its own createdAt.

## Tests

- FrostProposalCodecTest (new, commonTest): a batch of one is byte-for-byte the
  old JSON object -- the assertion that stands in for the old build nobody can
  run here -- plus order preservation, old-form decoding, and refusal of empty,
  malformed and partly-unreadable arrays.
- SignedGroupKeyStateTest: a k=3 batch between two devices over two databases.
  Three signatures verifying against the room, three dialects applied on both
  devices in order, five messages from the coordinator and two from the other
  signer, and one approval line rather than three.
- The negative test that matters: no two items of a batch share an aggregated
  nonce or a seed, and the two devices' seeds do not intersect. Every positive
  test still passes if two items share a nonce -- the signatures verify fine;
  what sharing costs is the secret share.
- The cap is refused when proposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:48:24 +02:00
Kgothatso Ngako
dff41d417d feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.

## What moved, and why it had to

A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.

So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.

itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.

No itemCount column. The count is a COUNT(*), for the same reason signerIds is
derived from the ceremony's participant order rather than stored: a
denormalised count is one more thing that can disagree with the rows.

## Migration 9 -> 10

Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.

Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.

## Invariants established here for the phases that follow

- signerIds and every item's aggregatedNonce are one write-once unit, applied
  by applyAggregate() -- items first in one transaction, then the session, so
  "some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
  reading a flag.
- complete() verifies every signature before applying any event, so a batch is
  all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean
  an off-by-one in index handling produces a session that fails to aggregate
  rather than one that signs two messages under a single nonce.

signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.

## Tests

- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
  replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
  database, asserting the seed and aggregate values survive -- not merely that
  a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00
Kgothatso Ngako
eb34c8edb3 feat: sign a chapter into the artifact instead of submitting one
Adding a chapter no longer creates one. It opens a signing session over a
ChapterEvent, and the chapter appears -- on every member's device at once,
authored by the room's shared key rather than by whoever pasted the text --
when enough members have signed. The same trade the dialects and artifacts
made: a submission says "I am putting this in front of the group" and the
group's only recourse afterwards is social, while a signature is the group
saying it and it takes a quorum to say. The text everybody translates from is
the group's, so the second is the honest one.

**The chunks.** This is the part chapters had that dialects and artifacts did
not. A chapter was submitted along with a ChunkEvent per paragraph, and that
cannot survive the change: a translation is of a chunk rather than of a
chapter, so a chapter without them cannot be worked on, but the chunks cannot
have their own quorum without costing one signing session per paragraph, and
they cannot be invented locally -- an invented id differs on every device, so
members would silently disagree about which chunk a translation is of while
every screen showed the same chapter.

So the chunks are split back out of the signed chapter's own text when it is
applied, in MantraChunk.chunksOf, on the pattern
MantraArtifactVersion.initialVersionOf already set. Same bytes in, same rows
out, everywhere. They are rumors, because nobody signed them; what the group
signed is the chapter they were split from.

It splits only what the group signed. A chapter that arrived as a submission
was sent with its own chunk events, written under the submitter's key, and
deriving a second set beside them would leave every paragraph in the chapter
twice under ids nothing reconciles -- including on a marmot reindex, which
replays a room's group events without anybody adding anything.

**The index.** Where a chapter sits in its version is read at proposal time
and signed into the event, rather than derived on arrival like the chunks
are. A device applying the chapter cannot recount it: it would be counting a
version other members may have added to in a different order, and the count
has to be the one the group put its signature to. The window between proposal
and quorum is longer than the old write-and-submit window was, so two chapters
proposed at once can still land on one index -- the same race as before, wider.

**What went away.** MantraDao.addChapter and its way up through the repository.
Nothing called it once the screen proposed instead, and leaving a path that
authors a chapter under a member's key while the UI insists on a quorum would
have double-created the chunks besides. MantraRepository.getChaptersForArtifactVersion
replaces the one thing it did that is still needed: counting the index.

**The screens.** AddChapterScreen loads the room, the artifact's latest
version and canSign up front, disables the FAB when either is missing the way
the dialect and artifact screens do, and on success lands on the session
rather than on an artifact the chapter is not in yet. FrostSigningScreen
described a chapter proposal by name alone, so a member was asked to sign text
whose size they could not see; it now reads name, word count and chunk count,
the way an artifact shows its url.

**Tests.** Two files, and each was checked against a broken implementation
rather than only against a working one: deriving the chunks from the clock,
inheriting the chapter's counts across every chunk, authoring the derived rows
as their reader, and losing the paragraph position are all caught, as is
splitting a chapter that arrived as a submission. SignedChapterTest runs a
real 2-of-3 quorum over an actual proposal, because the claim worth holding --
the chapter is the group's, carries proof of it, and every device splits it
into the same chunks -- is invisible when it breaks.

Not covered: applyInnerEvent's upserts, which need a database no test here
stands up, and AddChapterViewModel, which is plumbing across two dispatchers
over a template the tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:15:36 +02:00
Kgothatso Ngako
a8f6638325 feat: sign a room's key state into being, at the room's own key
Two changes that turned out to be one. A room's key state stops being something
its creator announces and becomes something the group signs, and every FROST
signature moves from the group's root threshold key to the key derived at the
room's own path -- which is the room's id. The second is what makes the first
worth having: a key state is now signed by the very key it names.

Supersedes the announcement introduced in a909108, and changes the author of
every event the group signs, including the artifacts of 786c060.

## The key state is proposed, not announced

a909108 had the room's creator write the GroupKeyState row, say so in the room
on kind 30326, and every receiver keep it if the room's id rederived from the
key it named. That check was sound and is still here -- a state that does not
rederive its own room is dropped, whoever sent it -- but it left the first thing
a group ever does as the one thing a single member decides alone.

So the key state goes through the door everything else the group says goes
through. GroupKeyStateManager.announce becomes propose, which opens a
FrostSigningEvents.PROPOSAL over an unsigned 30326 and writes no row. The state
comes into existence when a quorum has signed it, on every device at once,
applied by FrostSigningManager.complete like any other signed proposal:

    creator  --[ 30320 proposal over an unsigned 30326 ]-> everyone
    ...members approve, nonces, signer set, partials, aggregate...
    everyone --applies the signed 30326 locally-->  GroupKeyState row

Nothing waits on it. Between creating a room and that session completing there
is no state to read, and completedKey's rederivation scan -- kept from before
the table existed -- is what keeps the room signable in the meantime, including
for the key-state session's own members. That is the only reason a bootstrap
here does not deadlock, and the scan's doc now says so rather than describing
itself as legacy.

proposeSigning gains an optional `key`, for the one caller that cannot be asked
which key the room signs with because establishing that is its whole job. It is
honoured only if this device actually holds a share of it, so naming a ceremony
cannot talk a session into signing with material it has not got.

## Everything signs as the room, not as the group's root key

unsignedEventOf and advance now build from SharedKeyDerivation.derive at the
room's path instead of TweakCache.create on the bare threshold key. Both halves
had to move together: a signature aggregates against whatever the cache carries,
so the cache and the author on the event have to be the same derivation or
nothing verifies.

Since marmotGroupId(K, path) *is* derive(K, path).hex, the pubkey on every event
a room signs -- dialect, artifact, chapter, key state -- is now that room's id.
A reader checking one needs no lookup at all: the key they expect is the id of
the room they found it in. marmotGroupId's doc now carries that second meaning,
and there is deliberately no second name for the value; "the room's id" and "the
key it signs as" are one function because they are one key.

The path is resolved by FrostSigningManager.signingPath and never taken from a
proposal, because it decides which key the group signs as -- a proposer able to
choose it could have every signer put their share behind an author of the
proposer's choosing. Three candidates in descending order of knowledge (the
room's GroupKeyState, the path in its MIP-01 description, the app's default),
and one is accepted only if walking it reaches the room's id, which makes the
resolution self-checking rather than trusting. acceptProposal runs the same
resolution independently on every device.

Null is a real answer, not a failure: completedKey will still find a key for a
room that was never derived from it -- a ceremony held in that very room, the
fallback kept for rooms the app no longer makes -- and such a room has no key of
its own to sign as, so it signs as the threshold key, which is what it always
did.

## What a receiver now checks

GroupKeyStateManager.stateFrom asks two independent questions, and a state has
to answer both:

  - Is it true? The room's id is the key derived at the path, so a state that
    does not rederive its own room names a key the room was not made from.
    Unchanged, and still the half that safety rests on. It knows nothing about
    who is speaking, and that is deliberate: a member with no share can state a
    true state and it is still true.

  - Did the group say it? GroupKeyStateEvent.isSignedByGroup: the author must be
    the key the content walks to at the path in the tags, the id must hash the
    fields sitting next to it, and the signature must verify. Since that walk is
    the room's id, a passing state is signed by the room it is about.

The second does not make a state truer -- the derivation already settled truth.
It makes the record of what a room signs with a thing a quorum agreed to. The
practical effect is that a true state nobody signed is now refused, which is the
behaviour change worth knowing about: an unsigned 30326 from an older client is
stored as an inner event and dropped as a state.

## Restart safety, and a nullable column

FrostSigningSession gains derivationPath, and the database goes to v9 on an
auto-migration. It is an input and is stored for the same reason nonceRandom is:
the cache is rebuilt on every pass of advance, and a session that resolved a
different path after a restart would regenerate a different nonce from the same
seed -- publishing a partial signature against an aggregate nobody else
computed.

Nullable, meaning no derivation at all: the untweaked threshold key. That is
both the honest answer for a room not derived from the key and what sessions
predating the column read back as, so a session caught mid-flight by the
migration finishes under the key it began under rather than switching between
two of its own rounds.

SharedKeyDerivation.derive now takes its key back out of the cache rather than
from the point, so an empty path is a real answer equal to what a session
created from that cache signs against. No behaviour changes for a non-empty
path, where the walk overwrites it on the first step.

## One place that files a key state

ChatMessage.applyInnerEvent records it, which it must: the signed 30326 reaches
every device through applySignedEvent, and the branch there previously returned
null and dropped it. The now-duplicate dispatch in NostrDao is removed, so the
locally applied signature and any wire-borne 30326 take the identical path.
Still no chat line -- standing state, and the session already wrote the
transcript of it happening.

## Elsewhere

DkgRepository.announceGroupKeyState becomes proposeGroupKeyState, taking the
room and returning the signing session rather than the state, since the state is
not what the call produces any more. DkgRitualViewModel calls it after members
are added, unchanged and for the unchanged reason: adding them commits a new
epoch, and a proposal published before it reaches nobody who could sign it.

FrostSigningScreen describes a key-state proposal as the group's shared key with
its path and ceremony, rather than "Event of kind 30326" -- a member deciding
whether to sign should be shown the thing.

## Tests: 217, 0 failures

  - GroupKeyStateTest is rewritten around real quorum signatures from
    Frost.trustedDealerKeygen. New: a true state nobody signed is dropped, a
    member's own signature over one is dropped, one group signing about another
    group's key is dropped, a state edited after signing is dropped, a state
    signed at the wrong path is dropped, and the room signs as its own id.
  - SignedArtifactTest pins that an artifact's author is the room it was signed
    in, and explicitly not the group's root key.
  - FrostSigningRoundTest runs both rounds against the tweaked cache now, which
    is the part most likely to be silently miswired -- a badly built cache
    produces a signature that simply fails to verify, on every device, quietly.
  - SharedKeyDerivationTest pins the migration contract: a walk of no steps
    lands on the threshold key, and a null derivationPath reads back as that
    empty walk rather than as the default path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:18:58 +02:00
Kgothatso Ngako
607ef72bc3 Merge branch 'mantra' into claude/marmot-group-reindex-events-96a0d0
# Conflicts:
#	composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt
#	composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt
2026-09-06 01:52:10 +02:00
Kgothatso Ngako
925099125b feat: read a room's group events again when they arrived out of order
Relays impose no ordering, so a kind:445 can turn up before the group can
read it: an application message encrypted under an epoch whose commit has
not landed, or a commit for an epoch ahead of the local one. Both are
stored and then dropped -- MarmotInboundManager refuses an out-of-epoch
commit precisely so it does not half-mutate the group -- and nothing goes
back for them once the missing event fills the gap. The message is on
disk, readable, and never read. A "Reindex Events" button at the bottom
of the group's detail screen is that second look.

Only events with nothing to show for them are replayed: no chat line at
all, or one of the two placeholder types. A room where nothing went wrong
is left exactly as it was, which is what makes the button safe to press
on a hunch. Passes repeat while a pass recovers something, because
created_at order is not epoch order and a commit recovered by one pass is
what lets the next read the messages that were waiting on it.

**Replaying was not safe as it stood.** Every row the path writes is keyed
on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent,
and the nip30303 entities -- with one exception. ChatMessage's primary key
is autogenerated, so writing a freshly built line always inserts, and a
re-read would have left the room showing each recovered message twice,
once as "Undecryptable Message" and once as itself.
ChatMessage.reconcileMarmotLine matches on the group event id instead, so
a re-read is an update, and refuses to let a placeholder overwrite a line
that says something. That last rule is what protects the line this device
wrote on the way out for a message it sent: our own kind:445 cannot be
read back, since the sender ratchet has consumed the generation, and
without the rule a replay would have replaced our words with
"Undecryptable Message".

The MLS group itself was already safe to replay against, which is worth
saying because it is the part that looks dangerous: a commit behind the
current epoch is rejected as a duplicate before it touches the group, one
ahead is refused, and a consumed ratchet generation throws before
mutating anything. The exception was quartz's EpochCommitTracker, which
does not dedupe and only empties when a commit applies -- so replaying a
held commit just grew the list and left it pending forever.
forgetPendingCommits drops the room's entries first, and the sweep feeds
the events back in the order CommitOrdering picks a winner in, so a
contested epoch resolves the same way it would have on every other
device.

**What is testable, and what is not.** The DAO is not: testDebugUnitTest
is plain JVM and Room's in-memory builder wants an Android Context. So
the two pieces carrying decisions are lifted out where they can be run
without one -- MarmotReindexSweep for the stopping rule, and
reconcileMarmotLine for which of two lines wins -- and the DAO is left as
query, sweep, write. The filter tests pin why the query's `tags LIKE` is a
prefilter and not a test: an event belonging to another room can mention
this one in a q tag, and its own h tag is what rejects it.

**Not recovered by any of this.** A message whose key is gone -- one the
ratchet has already advanced past, or one from an epoch predating this
device's join. And events that never reached disk at all: storeNostrEvent
is a single transaction, so a kind:445 arriving before its room exists
rolls back its own insert along with the failed indexing, and only a
re-sync brings it back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:49:57 +02:00
Kgothatso Ngako
6aff34c5c7 Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6 2026-09-06 01:38:08 +02:00
Kgothatso Ngako
786c0602da feat: sign an artifact into the library instead of submitting one
Adding an artifact no longer creates one. It opens a signing session over
an ArtifactEvent, and the artifact appears -- on every member's device at
once, authored by the group's shared key rather than by whoever typed it
-- when enough members have signed. The same trade the dialects made: a
submission says "I am putting this in front of the group" and the group's
only recourse afterwards is social, while a signature is the group saying
it and it takes a quorum to say. A library is the group's.

**The first version.** This is the part the dialect had no answer for. An
artifact was creating an initial ArtifactVersion as a second submitted
event, and that cannot survive the change: a chapter attaches to a
version rather than to an artifact, so an artifact without one is inert,
but a version cannot be submitted before the artifact it points at
exists, cannot have its own quorum without costing a second signing
session per form, and cannot be invented locally -- an invented id
differs on every device, so members would silently disagree about which
version a chapter hangs off while every screen showed the same artifact.

So the label rides on the artifact as an `artifactVersion` tag and the
row is derived from the signed artifact's own fields when it is applied.
Same bytes in, same row out, everywhere. It is a rumor, because nobody
signed it; what the group signed is the artifact that declares it.

**What went away.** MantraDao.addArtifact and its way up through the
repository. Nothing called it once the screen proposed instead, and
leaving a path that authors an artifact under a member's key while the UI
insists on a quorum would have double-created the version besides.

**Tests.** Three files, and each was checked against a broken
implementation rather than only against a working one: deriving the
version from the clock, dropping the label from the proposal, authoring
the derived row as its reader, and losing the signature on the way out of
the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum
over an actual proposal, because the claim worth holding -- the row is
the group's, and carries proof of it -- is invisible when it breaks.

Not covered: applyInnerEvent's two upserts, which need a database no test
here stands up, and AddArtifactViewModel, which is plumbing across two
dispatchers over a template the tests already pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:37:58 +02:00
Kgothatso Ngako
024da99404 test: pin what a member who never took part needs to finish a session
e22a8ae hoisted completion above the approval gate on the strength of one
claim: closing a session needs nothing secret, and nothing the member would
have had to publish. Were that false -- were the aggregated nonce, the
signer set or a share needed to check the result -- the gate would have to
stay where it was, and the member a quorum did not need would go on being
asked to sign something already signed. Nothing checked the claim.

FrostSigningCompletionTest builds a real 2-of-3 signature from members 0
and 1, then works entirely from member 2's row: never approved, not in the
signer set, aggregatedNonce and signerIds deliberately null. From that
alone it pins that they can verify what the group signed, that the finished
event is the proposed one unaltered rather than rebuilt or rehashed, that
there is no finished event before the signature arrives, that the arrived
signature is what stops the session asking, and that a signature over a
different event is refused -- which is what the check in complete() is for.

Both new assertions about isAwaitingApproval were mutation-checked: with
the `signature != null` guard removed, exactly two tests fail and the rest
of the suite still passes, so they guard the change rather than restating
it.

Not covered, and not coverable here: advance() itself -- that the branch
fires on an inbound SIGNATURE rather than stopping at the gate. It is
Room-backed, and this project has no harness for that (no Robolectric, and
the in-memory builder's android actual needs a Context). The pure half of
the claim is what this pins instead.

Also corrects e22a8ae's message, which said sixteen new tests. It was
eleven: eight in TranscriptRequestStateTest and three added to
FrostSigningSessionTest, which has eight in total.

Verified: :composeApp:compileDebugKotlinAndroid succeeds, and
:composeApp:testDebugUnitTest passes -- 176 tests across 24 classes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:26:52 +02:00
Kgothatso Ngako
b87e6e4ed5 Merge branch 'mantra' into claude/frost-proposal-review-visibility-8f6fab 2026-09-06 01:13:47 +02:00
Kgothatso Ngako
e22a8ae4cd fix: stop asking a member to review a signature the group has settled
The transcript's "Review" affordance is a promise: tapping it leads to a
decision still there to be made. For a FROST signing proposal it was only
ever withdrawn one way -- and a proposal can be processed three.

**How a request was closed.** RitualNotice drops the tint and the call to
action when the request is answered, and a request counts as answered when
the step it asked for has since been published by this device:

    FROST_REQUEST_FULFILMENTS = mapOf(TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE)

Approving publishes a nonce, so approving closes it. Nothing else does.

**Declining.** decline() fails the session and broadcasts a FAILURE. It
publishes nothing of the member's own, by design -- a refusal is a refusal.
So no fulfilment line is ever written, and the request went on asking, in
primary tint, for a decision the member had already made. Tapping it
reached a screen with no buttons on it, which was the screen being right.

**A quorum that did not need them.** A t-of-n key finishes without
everybody. The coordinator takes the first t nonces, and a member whose
phone was in a pocket is simply not among them -- but advance() returned at
the approval gate on their device, so the arriving SIGNATURE was stored and
nothing was done with it. Their session sat at COLLECTING_NONCES forever.
The request stayed lit, the screen still offered Sign and Don't sign, and
both answers were wrong: a nonce nobody was waiting for, or a refusal that
would flip a COMPLETE session to FAILED on every device and announce
"Nothing was signed" to a group holding the signature. fail() writes the
stage with update() rather than moveTo(), so that last one was reachable.

**The transcript.** A request is now closed by being *answered* or by being
*settled* -- a frostComplete or frostFailed line after it. The two are kept
apart deliberately. Answered keeps the tick; settled does not, because the
member never answered and crediting them with a signature they refused, or
were never asked for, is worse than the summons was. Both rules moved out
of the composable onto ChatMessage, where they are stated once and tested.
Settlement is signing-only: a ceremony step can only be taken or waited
for, so a DKG request has no equivalent and reading one from a signing
session's end would drop a summons the ritual is still stalled on.

**The session.** The transcript alone could not close the third case: the
device that never approved wrote no terminal line to read. advance() now
completes on a signature that has already arrived, ahead of the approval
gate rather than below it. That gate is there to keep this device's own
material off the wire, and finishing puts none there -- it verifies the
aggregate, applies the event and announces, all from what is already
stored. Everything it now skips on that path is work the signature made
pointless anyway: a late nonce, a partial signature nobody will aggregate.

Three things follow. isAwaitingApproval reports false, so FrostSigningScreen
hides the buttons -- it now asks the manager rather than re-deriving the
rule, which had drifted into a second copy of it. A late "Don't sign"
cannot abandon a signature that exists. And the signed event finally lands
locally for a member who never approved: applySignedEvent sat below the
gate and was being skipped, so a dialect the group signed without them
never reached their store.

Verified: :composeApp:compileDebugKotlinAndroid succeeds, and
:composeApp:testDebugUnitTest passes -- 165 tests, 16 of them new. Eight
cover the transcript rules against a hand-built row list; eight cover
isAwaitingApproval, including the settled-signature case. What stays
uncovered is advance() itself, which is Room-backed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:13:42 +02:00
Kgothatso Ngako
02117643c4 fix: send a group event because it was queued, not because the chat mentions it
No FROST signing message has ever reached another participant. The proposal
was built, MLS-encrypted, wrapped under the exporter secret, signed as a
kind:445, written to NostrEvent and MarmotGroupEvent, and its queue row
marked processed -- and then never handed to a relay, by a branch that was
never about delivery at all.

**The gate.** The tail of MarmotOutboundDao.encryptAndSendMarmotInnerEvent
looked up the transcript row for the queued rumor and did everything else
inside it:

    val chatMessageOrNull = database.chatMessageDao()
        .getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id)
    chatMessageOrNull?.let { chatMessage ->
        ... relation, marmotGroupEventId ...
        val ids = database.broadcastNostrEventRequestDao().insert(...)
    }

The BroadcastNostrEventRequest rows are the only thing that puts a kind:445
on a relay -- observeBroadcastNostrEventRequestsByStatus("pending") is what
the broadcaster watches, and nothing else inserts them for this path. So the
question "does the chat have a line for this?" was silently answering the
question "should the group receive this?".

**Why FROST always lost.** A signing message has no ChatMessage by design.
FrostSigningManager.broadcast queues the rumor alone, and announce() writes
its milestone lines with marmotInnerEventId = null on purpose: each device
writes its own transcript from the messages it has already received, so the
lines cost no traffic and cannot disagree with the session they describe.
The inbound half states the same intent from the other side --
ChatMessage.applyInnerEvent returns null for every FrostSigningEvents kind,
because a row there would be a second, worse account of what the manager
already narrates.

That is every kind in the family, not just the proposal: nonces, the signer
set, partial signatures, the finished signature and the failure notice all
go through the same broadcast(). A session could not have completed even if
a proposal had somehow arrived.

**GroupKeyStateManager.announce had it too.** Same shape, same silence: a
room's kind:30326 announcement of which key it signs with was queued,
encrypted and dropped. a909108 added it so members would stop rederiving;
no member has ever received one.

**Why the neighbours worked, and hid it.** The DKG rides NIP-17 gift wraps
through a different path entirely, so a room could finish a ceremony, hold a
real shared key, and report canSign() == true with the signing transport
dead beneath it. nip30303 submissions work because MantraDao.sendMarmotInnerEvent
pairs every queued rumor with a ChatMessage carrying its id -- not as a
delivery mechanism, just because a submission is also something a member did.
FROST was the first traffic to use the group path without a chat line, which
is why this reads as a FROST bug and is not one.

**Why it went unnoticed.** Nothing failed. The coordinator's own device is
fully convinced: proposeSigning writes the session, announceStarted puts a
line in the chat, advance() runs, and publishOwn records the coordinator's
own nonce and announces that step too. From the proposer's side a session
nobody else can see is indistinguishable from one waiting on slow peers.

Unlike 65e4a3a, the queue did not block. marmotGroupEventId is set before
the transcript lookup, so the row left the queue cleanly and the next one
was picked up. Every message was lost individually, in silence, with no
backlog to notice.

**The fix.** The broadcast insert is hoisted out of the branch, and the
decision it was tangled with is lifted into MarmotDelivery.plan: given a
group event, a relay list, and a chat message or null, what has to be
written. A group event is sent because it was queued; a chat line is linked
because a member said something. The DAO now computes that plan and executes
it, with the insert as a plain unconditional statement ahead of the
bookkeeping that legitimately does depend on there being a line.

The extraction is not decoration. encryptAndSendMarmotInnerEvent is
Room-backed and cannot be stood up in a unit test, which is exactly how the
gate survived; separating the decision from the filing of it is the same
move MarmotDirectMessage.classify exists for, and for the same stated
reason.

**Tests.** MarmotDeliveryTest, six of them. The two that matter are "a
signing proposal goes out, though nothing in the chat points at it" and "the
send does not depend on the transcript", which asserts the broadcast list is
identical with and without a chat message. The rest pin the supporting
facts: one request per relay naming the event, every request written pending
because that is the only status the broadcaster looks at, the linkage that
does depend on a chat line, and an empty relay list as the sole legitimate
way to produce an empty broadcast list -- so that an empty list always reads
as "nowhere to send it" and never as "nothing to send".

**Not covered, deliberately.** These pin the decision, not the call site. Re-
nesting the insert inside chatMessageOrNull?.let would leave MarmotDelivery
correct and every test passing. Closing that needs the DAO itself under
test: BundledSQLiteDriver is on the classpath and getInMemoryDatabaseBuilder
exists, but its android actual wants a real Context, testDebugUnitTest is
plain JVM, and there is no androidUnitTest source set or Robolectric. That
is its own change, not one to smuggle in here.

Verified: :composeApp:compileDebugKotlinAndroid succeeds; 160 tests pass,
154 before these six. The inbound half was read rather than assumed --
NostrDao dispatches FrostSigningEvents kinds to processSigningPayload,
inbound rumors are stored with marmotGroupEventId set so they cannot re-enter
the outbound queue, and the out-of-order replay path is intact. Outbound was
the only break. That two participants now actually see a proposal is
inference from the code, not an observation: it wants two devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:06:05 +02:00
Kgothatso Ngako
39eac61838 Merge branch 'mantra' into claude/long-running-chat-sync-8983dc
mantra had moved on ~30 commits, several of them in exactly this area — and it
turns out both branches independently found the same bug and drew the same
conclusion about the same filter.

**The overlap.** f38a5f1 fixed the three kind:1059 filters that named the wrong
pubkey, including the two `authors=[userPublicKey]` requests in NostrDao that
could never match a wrap signed by a throwaway key. This branch deleted those
same two blocks, inverting the same `if` to the `== null` case, for the same
reason. The code merged to the same shape; only the comments conflicted, and
they are combined.

**Nip17Filters wins, and the live subscription now defers to it.** ad3304a
extracted the inbox filter to one definition precisely because it had been wrong
in three call sites, with the no-`since` reasoning this branch arrived at
separately. Keeping a fourth copy inside LiveSubscriptionManager would recreate
the problem that commit exists to solve, so:

  - queueCatchUpSynchronization now calls Nip17Filters.inbox() instead of
    building an identical SynchronizationFilter with its own limit constant,
  - Nip17Filters gains liveInbox(), the same shape as a quartz Filter for a REQ
    rather than a SynchronizationFilter for the queue, and giftWrapFilter()
    defers to it.

Two types for one filter is not duplication worth removing — the queue stores
one and hashes it for computeId, a live subscription puts the other on the wire
— but they belong side by side, because drift here means one of them quietly
stops matching mail.

**ChatMessageListViewModel keeps this branch's resolution.** mantra had it
refresh our own inbox on open (Nip17Filters.inbox on our DM relays, purpose
"chat"); this branch removed that call entirely. Both were right when written,
and the merge is where the second becomes true: LiveSubscriptionManager holds
exactly that filter open on exactly those relays for the whole account and
reconciles it on every foreground, so opening a chat has nothing left to ask
for. The redundancy is now recorded in the comment where the branch used to be,
so it reads as superseded rather than dropped. Discovery — the kind-10050 lookup
for a participant we cannot yet address — is untouched, and the purpose is no
longer a conditional now that only one case reaches it.

The commonTest coroutines-test dependency arrived on both sides; the comment
gives both reasons.

Verified: 154 tests pass, both branches' suites included — Nip17FiltersTest and
the marmot direct-message suites alongside this branch's 46.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:58:40 +02:00
Kgothatso Ngako
bcdfd2ec94 Merge branch 'mantra' into claude/marmot-direct-message-type-7a0473
Twenty-two commits had landed on mantra since this branch left it, several
of them in the same files. Merged this way round so mantra stayed untouched
until the result compiled and its tests passed.

The migration had to be renumbered, and this is the conflict that mattered.
mantra is at database version 7 and already has its own 5.json -- for
MarmotInnerEvent.payloadEventId, nothing to do with direct messages. This
branch had also written a 5.json, for a different schema. Resolved by
restoring mantra's 5.json untouched and moving the direct message columns
to an AutoMigration(7, 8) with a regenerated 8.json. Taking either 5.json
over the other would have left every device validating a migration chain
against a schema it was never built from; keeping version = 5 would have
made a v7 install refuse to open at all.

The regenerated 8.json is two ADD COLUMNs and nothing else, same as before.

fromGroupEventResult was restructured on mantra: the kind switch moved into
applyInnerEvent, and a SubmissionEvent envelope now wraps nip30303 payloads.
Took that structure and re-applied the direct message branch ahead of it
rather than inside it -- a gift wrap is not a nip30303 payload to apply, and
what happens to it depends only on whether this device's key opens it, so it
does not belong in a function about applying submissions.

The isUserMessage fix was re-applied to the eight call sites mantra's
version has, up from the six it had here.

ChatMessageListViewModel and ChatRoomMessagingScreen took mantra's versions
with the composer state, the two renderings and the reply action layered
back on.

docs/README.md keeps both new rows and mantra's closing note about the
skipped-keys document.

108 tests pass, up from 50 here and 83 on mantra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:45:08 +02:00
Kgothatso Ngako
a909108300 feat: announce which key a room signs with, instead of rederiving it
A signer holds a different secret share under every ceremony it took part
in, and signing with the wrong one produces a partial signature that
cannot aggregate. Nothing said which was which: FrostSigningManager
found a room's key by walking every ceremony this device holds a share
for and rederiving each one's room id until one matched.

That search can only find rooms derived at the one path the constant
names. SharedKeyDerivation.parsePath was written to lift that limit and
was never called, so a room derived anywhere else was invisible to
signing.

So the coordinator now says it. GroupKeyStateEvent (kind 30326) carries
the threshold public key, the ceremony that made it and the path the
room's id came from, posted into the room as its first application
message and filed as a GroupKeyState row. completedKey reads that row
first and follows it to the share.

Nothing secret travels. Every member of the room can read the event, so
a share on it would be each member holding everyone else's -- a 1-of-n
key wearing a t-of-n's clothes. The event names the ceremony; the share
stays in DkgSession.secretShare on the device that generated it.

The coordinator is untrusted, as everywhere else in the ceremony, so a
state is verified rather than believed: the room's id *is* the threshold
key derived at the path, and one that does not rederive its own room is
dropped. That is the same guarantee the rederivation gave, kept rather
than traded for a lookup. The old scan stays behind it for rooms that
predate the table.

Announced after the members are added, which is the only order that
works -- adding them commits a new epoch and MLS will not let a member
read what was encrypted before the one they joined at. A member invited
later still misses it and falls back to the scan, which is where every
member was before this existed.

Replacement is this app's job. These are rumors inside a Marmot group
event, so no relay applies the 3xxxx rule, and the DAO keeps the newest
announcement per room so a backfill cannot walk a room backwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:36:57 +02:00
Kgothatso Ngako
f5eb744ca7 test: cover the long-running sync, and open the seams needed to do it
The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.

**What is covered**

RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.

LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.

LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.

RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.

**Seams opened to get there**, each a readability win on its own terms:

  - NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
    behind it, so the pool can be driven by a fake socket.
  - RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
    observed rather than raced.
  - LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
    methods) rather than RelaysSocketManager, which observes the active wallet in
    its init and cannot be stood up in a test at all.
  - Its pure planning helpers move to the companion as `internal`, and its
    launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
    SynchronizationViewModel already launches observe() on IO, so nothing moves —
    but a coroutine that picks its own dispatcher cannot be driven by a test
    scheduler.
  - reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
    parameter, so the arithmetic can be pinned without randomness.
  - endsLiveSubscription names the live-subscription termination rule next to
    isTerminalFor, which is the one-shot rule. Having both named makes the
    difference between them reviewable rather than implicit.

kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.

The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:31:11 +02:00
Kgothatso Ngako
0319f1613b Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467 2026-09-05 23:30:28 +02:00
Kgothatso Ngako
5321e4af72 Merge branch 'mantra' into claude/distracted-franklin-e95ba4 2026-09-05 23:24:45 +02:00
Kgothatso Ngako
fb21678813 test: pin where a commit's bytes land when the row recording it is written
The mis-routed `framedCommitBytes` fixed in the previous commit was invisible for
one reason: nothing anywhere covered the persisted row. The bytes that reach a
relay come off the in-memory `CommitResult`, so the wire path stayed correct and
the stored path was wrong, and no test looked at the stored path.

## Why the mapping moved before it could be tested

A test that built `MarmotCommitResult` itself would have been writing its own copy
of the mapping and asserting against that. It would have passed against the buggy
code, because the bug was at the call site the test was not using.

So the mapping is now `MarmotCommitResult.from`, called by
`MarmotOutboundDao.inviteMember` and exercised directly by the test. That also
removes the shape that produced the bug rather than just the instance of it: the
old call site listed its named arguments in an order different from the
declaration, which is what put `preCommitExporterSecret` and `framedCommitBytes`
two lines apart. `from` lists the payload in declaration order, in one place, so
there is no second site to get wrong.

## What is covered

Four tests, each payload given a distinct self-identifying value so that a field
arriving in the wrong column names both halves of the mistake instead of comparing
equal by accident:

  - every payload field lands in its own column.
  - the framed commit column never holds the exporter secret -- the regression,
    stated as an invariant rather than an equality so it keeps holding for a
    `CommitResult` this test did not anticipate.
  - a `CommitResult` that never framed its commit still stores a commit. quartz
    defaults `framedCommitBytes` to `commitBytes` and the entity repeats that
    default; the fallback must not quietly become the secret either.
  - the bookkeeping `DatabaseNostrRepository` reads back on acknowledgement is
    carried through. `id`, `chatRoomId`, `userPublicKey` and
    `peerKeyPackageEventId` are all 64-char hex, so two of them swapped in `from`
    would typecheck exactly as silently as the original bug.

Checked by reintroducing `framedCommitBytes = commitResult.preCommitExporterSecret`
into `from`: three of the four fail. A green suite that would stay green against
the bug it names is not coverage.

## What is not covered, and why

That the bytes published equal the bytes stored -- the property one level above
this one -- still is not. It needs the DAO, and the DAO needs Room: `commonTest`
carries only `kotlin.test`, the room3 KSP processor is registered for the android
and ios targets alone with `kspJvm` commented out, and `getInMemoryDatabaseBuilder`
wants a `PlatformContext` no unit test has. That is a Robolectric or instrumented
target, which is a larger change than this fix earns and is better decided on its
own merits than smuggled in here.

The ack-triggered rebroadcast that would have turned the bug into a live fault does
not exist yet, so there is nothing to test there either. When it is written, the
invariant it needs is already asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:24:21 +02:00