Kgothatso Ngako 085e598ec5 feat(subgroups): a key state that names its parent, or is dropped for claiming one it cannot back
Phase 3 of docs/subgroups.md. `GroupKeyStateEvent` grows two optional tags and
`GroupKeyStateManager.stateFrom` grows the checks that make them mean something.
Every state signed before this reads exactly as it did: the tags are emitted only
when a parentage is passed, and the new checks fire only on a state carrying one.

```
["parent_group",      <the parent room's id>]
["birth_certificate", <the parent's signed 30329, whole, as JSON>]
```

**Both or neither, and the type says so.** They arrive as one `SubgroupParentage`
rather than as two nullable parameters a caller could half-fill, because a parent
named with no certificate is a claim with the checkable part removed and a
certificate with no parent named beside it has nothing to be an index of. The
certificate is the claim; the parent tag is an index into it, since the
certificate already carries the same value in its own tag and as its author.

**Four checks, and a failure drops the whole state.** Both tags present and
readable; the certificate parses; `SubgroupBirthCertificateEvent.certifies` says
the named parent signed it for *this* room; and the certificate's `subgroup_key`
is the state's own threshold key. The last is belt and braces -- the room's id
already derives from that key and the certificate's id already derives from the
key it names -- and is stated anyway because the two facts live in different files
and a change to either should have to notice this one.

Keeping a failed claim as a *parentless* state was the alternative and is worse.
It is not a state with one field wrong; it is a device asserting a relationship
the parent never agreed to, and filing it would record a group as top-level here
and as a subgroup on every device that could check the certificate.

**`claimsParentage` exists because absent and unreadable are not the same.** A
`birth_certificate` tag carrying `{not json` reads as absent through
`parseBirthCertificate`, so a state claiming a parent in a form nothing can check
would otherwise be filed as an ordinary top-level group. It looks at the tag names
alone, which is the only reading that can tell the two apart.

**The three outcomes needed a wrapper.** `parentageOf` returns `Parentage?` where
null means refused and a present null value means none claimed -- a bare nullable
carries two of the three, and flattening "cannot prove it" into "did not claim
one" is exactly the bug the paragraph above describes.

`propose` takes the same optional parentage and re-runs `certifies` before
opening the session. Every device that receives the state runs that check and
drops it when it fails, so proposing one this device would not believe spends a
quorum's attention on a statement nobody will keep.

**The certificate travels whole rather than as its signature**, argued in
`SubgroupBirthCertificateTag`: a signature plus a rule for rebuilding the event it
covers breaks silently the first time the certificate's shape changes, since a
rebuild differing by one byte hashes to an id whose signature fails and is
indistinguishable from a forgery. Every state already signed would stop being
believed at once, for a reason nothing logs.

Ten tests appended to `GroupKeyStateTest`, using the existing `signedByRoom`
helper so the certificates are real FROST signatures by a real second group: the
happy path keeps both fields; no claim keeps both null; each half alone is
dropped; an unparseable certificate is dropped; a real certificate for another
child stapled on is dropped; one signed by this room's own group rather than the
named parent is dropped; the index disagreeing with the claim is dropped; a
certificate naming another key is dropped; and the tags round-trip, with a
top-level state carrying neither. 396 common tests and 683 jvm tests pass.

The end-to-end through a real session and two databases lands with Phase 9, once
`SubgroupManager` exists to drive one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:21:38 +02:00
2026-07-05 23:21:02 +02:00
2026-07-28 09:10:20 +02:00
2026-03-24 04:47:19 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00
2026-03-23 01:41:39 +02:00

This is a Kotlin Multiplatform project targeting Android, iOS, Desktop (JVM).

  • /composeApp is for code that will be shared across your Compose Multiplatform applications. It contains several subfolders:

    • commonMain is for code thats common for all targets.
    • Other folders are for Kotlin code that will be compiled for only the platform indicated in the folder name. For example, if you want to use Apples CoreCrypto for the iOS part of your Kotlin app, the iosMain folder would be the right place for such calls. Similarly, if you want to edit the Desktop (JVM) specific part, the jvmMain folder is the appropriate location.
  • /iosApp contains iOS applications. Even if youre sharing your UI with Compose Multiplatform, you need this entry point for your iOS app. This is also where you should add SwiftUI code for your project.

Build and Run Android Application

To build and run the development version of the Android app, use the run configuration from the run widget in your IDEs toolbar or build it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:assembleDebug
    
  • on Windows
    .\gradlew.bat :composeApp:assembleDebug
    

Build and Run Desktop (JVM) Application

To build and run the development version of the desktop app, use the run configuration from the run widget in your IDEs toolbar or run it directly from the terminal:

  • on macOS/Linux
    ./gradlew :composeApp:run
    
  • on Windows
    .\gradlew.bat :composeApp:run
    

Build and Run iOS Application

To build and run the development version of the iOS app, use the run configuration from the run widget in your IDEs toolbar or open the /iosApp directory in Xcode and run it from there.


Learn more about Kotlin Multiplatform

Description
Mantra as a kotlin multiplatform project
https://mantra.press
Readme 8.2 MiB
Languages
Kotlin 100%