Skip to content

Repository files navigation

Lapis Cloud

Lapis Cloud — federated membership management with meritocratic governance

Standard membership management, libertarian-extended.
Full association and party administration as the foundation, meritocratic governance as the differentiator.

CI Version Kotlin License Issues Pull Requests Stars Sponsors

Table of Contents

About Lapis Cloud

Lapis Cloud is the successor to the PZB (PdV Party Central Bank) — a federated server network with a private-law-association character for the membership management of associations and parties. Every server is sovereign but can federate with other Lapis Cloud instances.

Lapis Cloud covers two orthogonal layers:

  1. Standard membership management — everything a modern association or party administration needs (member master data, contribution management, events, communication, GDPR, reporting, donation receipts, etc.), at a level that makes parallel specialist tools unnecessary.

  2. Innovative libertarian extensions — meritocratic voting, an internal currency ("Libertaler"), internal crowdfunding, pseudonymity with transparency, a right of secession via federation.

The explicit ambition: to become the de facto standard for membership management of associations and parties — completeness over elegance, compliance-first (GDPR, association law, party law, tax law), usable without prior IT knowledge.

Originally developed for the Partei der Vernunft (PdV), Lapis Cloud is open source and open to any organization — political parties, traditional associations, professional bodies, cooperatives, foundations, NGOs.

Tip
Conceptual details, roadmap, and design decisions are maintained in the project vault, not in this repository. This README describes only the technical scope.
  • Lapis Net — a fully decentralized P2P sister project, sharing the same libertarian core principles (meritocratic scoring, self-organization, voluntary association) but differing in implementation (P2P nodes instead of federated servers, cryptographic trust instead of server federation).

  • PZB (https://gitlab.com/pdv7/pzb) — the existing predecessor repository. Kept as a reference (architecture decisions, code patterns) but not adopted 1:1 — Lapis Cloud is a clean reimplementation.

  • Lapis Cloud Mobile — companion app (Kotlin Multiplatform, Android + iOS): login, video-conference join, a home hub that opens the main web sections (including events), and the member-card download. Not in any app store yet. Its section and video features need the optional WebView bridge on the server (off by default, see CHANGELOG.md [0.22.0]).

  • kUML — the modeling language used for every diagram in this repository (see below).

  • lapisproject-dev/Lapis-Cloud-Ops (private) — the real, filled-in deployment config (.env, rendered LiveKit/coturn configs, certs, branding) for the instances this project’s maintainer actually operates. This repo’s own deploy/example/ is the generic, public template anyone can self-host from; the Ops repo is the maintainer’s own real-world instantiation of it, kept private because it identifies real organizations and infrastructure. See the NOTE block in deploy/example/README.adoc for the split.

Technology Stack

  • Language: Kotlin

  • Server framework: Ktor

  • Database access: Exposed (Kotlin SQL framework)

  • Database migrations: Flyway

  • Web UI framework: KVision (Kotlin/JS)

  • Frontend-backend communication: Kilua RPC (type-safe RPC between the KVision client and the Ktor server)

  • AI assistance / agentic layer: Koog (JetBrains, Kotlin-native agent framework, multi-LLM without lock-in)

Status

As of tag v0.10.0, eight major waves are complete (see CHANGELOG.md for full detail). v0.9.0 itself is a hardening/retrofit release, not a new numbered wave: it closes the V0.7.2 ANTRAG membership-gate audit’s disclosed gaps (including the addCommitteeMember/appointElectionBoard/ tally root cause and a related session-revocation gap in rejectApplication), a DNS-rebinding TOCTOU gap in the federation SSRF guard, a GoBD hash-chain timestamp-precision bug, and adds two retrofits to earlier waves — Änderungsantrag (amendment motion) support for Governance (V0.2.6) and the V0.6.4 guest/Gast rating-basket closure (Politician Guest Rating, V0.8.5). See CHANGELOG.md [0.9.0] for full detail on every item.

  • V0.1 — Project foundation & standard membership management: Gradle multi-module structure, Ktor server, KVision client, Kilua RPC wiring, CI/CD, member master data, join/leave workflow, membership tiers, contribution management, document storage, communication (mailing lists, direct messages), GDPR basics.

  • V0.2 — Governance foundation: committee/working-group and meeting management (agenda, motions, resolution log), meritocratic voting, democratic elections, and Systemisches Konsensieren (systemic consensus-building) as voting modes.

  • V0.3 — Accounting core: SKR42 chart of accounts with double-entry bookkeeping, GuV/Bilanz/ Jahresabschluss derivation, the four-sphere Gemeinnützigkeit (non-profit) separation, §55/§62 AO Mittelverwendungsrechnung and Rücklagenbildung, a GoBD-informed Kassenbuch, and cost-center accounting.

  • V0.4 — Mail-merge & postal mail: a PDF mail-merge engine (membership dues invoices, §50 EStDV donation receipts, invitations) and a Letterxpress-based postal-dispatch path for members without email.

  • V0.5 — Compliance bundle: a §25 PartG donation-acceptance check for political parties, §20 GwG Transparenzregister board-change reminders with a beneficial-owner data completeness report, a hash-chained GoBD audit log, full-organization backup/restore/export, and the DSGVO-Vollausbau (AVV register, TOM documentation, DPIA template, data-breach-incident workflow).

  • V0.6 — LTR economy (complete, tag v0.6.0): a real, ledger-backed LTR balance (LtrLedgerEntry, replacing the earlier balance stub), Internes Crowdfunding (member-submitted, board-reviewed projects with a silence-is-approval clock, Like/Dislike-driven monthly EUR donation-pool distribution), an English proxy-bid LTR auction (disabled by default, requires an ADMIN to acknowledge a versioned legal-risk disclaimer before enabling — see "Auction opt-in" below), direct member-to-member LTR peer transfers (with a board/admin arbitration-correction path), Politiker-Profile und Politiker-Ranking (member-only Like/Dislike trust weighting of granted politician status, computed as a single shared LTR pool apportioned proportionally across all active politicians), and a Price-Oracle for the anchor-asset peg with a load-bearing donation → LTR conversion boundary.

  • V0.7 — Authentication, membership & usable UI (complete, tag v0.7.1): real password login and server-side revocable sessions (bcrypt, HttpOnly`Secure`SameSite=Strict cookies), replacing the X-Member-Id header stand-in every prior version ran on. Self-registration with board approval (a real Beitrittsvertrag/Satzungs-acceptance step, never silence-is-approval), admin-side direct member creation, a member-initiated exit workflow (AUSGETRETEN), and a real password-reset token mechanism (email delivery honestly not wired to any real SMTP transport — see "What doesn’t work yet"). A real multi-screen web UI (login, registration, dashboard, member administration, contributions, documents, mailing/direct-messages) replacing the previous four-service tech demo, served same-origin from the Ktor server.

  • V0.8 — Federation (complete, tag v0.8.0): server-to-server content-federation protocol Grundgerüst (ActivityPub-hybrid core + a namespaced lapis: JSON-LD extension vocabulary for Meritokratie-specific data, HTTP-Signature-verified inbox/outbox, no content type wired in yet); OIDC-based individual-member guest access (every server is both an OIDC Issuer for its own members and a Relying Party for guests from other instances, Authorization Code Flow with PKCE, Dynamic Client Registration as the open default — a guest is represented as a real Member(status = GAST) row, closing the identity-model gap the V0.6.4 note below used to flag); Trust-Anchor-Governance (a deliberately single-level OpenID Federation 1.0 subset — explicitly a UX-comfort layer, never a security gate for federation/guest-login/DCR); and a navbar guest badge (violet indicator + hover/focus/tap popover disclosing the guest’s home server, WCAG-AA contrast-verified, screen-reader accessible). Content/Timeline-level guest marking is not built — this codebase has no "Timeline"/"Post" content entity yet, see "What doesn’t work yet".

v0.10.0 is a UI-only release, closing the two highest-priority items a V1.0-readiness review found with this project’s two real pilot organizations: 13 backend domains had zero client UI, reachable only via Kilua RPC / raw HTTP — a treasurer or board member without developer access could not use any of them. The pilots picked Governance and Accounting as the two highest-priority gaps to close first. Both are done: a Governance UI (committees/working-group membership, meeting management with a live-recalculated quorum display and a printable protocol/Beschlussbuch draft, and the full motion/amendment/voting workflow) and an Accounting UI (the SKR42 chart of accounts and journal with a draft-then-post workflow, GuV/Bilanz/Jahresabschluss, the four-sphere Gemeinnützigkeit report and the §55/§62 AO Mittelverwendungsrechnung, cost centers, and external donors/§50 EStDV donation-duty reporting) — both real KVision screens over the already-tested RPC backends, both went through this project’s mandatory UI/UX-Design-Team review before implementation. See CHANGELOG.md [0.10.0] for full detail on both waves.

Every wave went through this project’s mandatory plan → implement → independent review → independent security audit pipeline before merging — the V0.8 waves additionally went through a dedicated adversarial live-attack phase each, given the project’s own "most security-critical version in the backlog" classification for federation work; V0.8.1’s pass found and fixed a real IPv6 Unique-Local-Address SSRF gap live. Known limitations (Letterxpress wire format and the oracle’s price-source wire formats not verified against live docs, no Sammelbestätigung aggregation, no full GoBD tamper-evidence/TSE, no persistent oracle halt-queue, no guest/Gast participation in the LTR economy yet) are tracked in CHANGELOG.md. The DNS-rebinding TOCTOU gap in the federation SSRF guard, disclosed in this list until v0.9.0, is now closed — see CHANGELOG.md [0.9.0]. See domain model documentation (covers the V0.1.5 Contributions/Documents/Communication slice, the V0.3 accounting slices, the V0.4.1 mail-merge slice, and the V0.6.5 Price-Oracle slice — not updated for every wave since) and CLAUDE.md for further detail.

Note
The V0.6.4 guest/Gast rating basket cut (PoliticianProfileDto exposed memberTrustWeight only, not the concept’s three-way Mitglieder-/Gäste-/Gesamtbewertung) was accepted scope (product-owner sign-off 2026-07-22) for as long as no operational Gast identity model existed in this codebase. Closed: once V0.8.2 gave every guest a real Member(status = GAST) row, the guest/combined rating basket was implemented (guestTrustWeight/combinedTrustWeight, a deliberately unweighted vote-count mechanic for the guest side — see CHANGELOG.md for why an LTR-weighted guest pool isn’t possible yet, since no guest LTR-earning mechanism exists). No client UI for Politician Profiles exists yet (backend/RPC-only, unchanged from V0.6.4). See CHANGELOG.md [0.6.0] for the original scope-cut reasoning and [0.9.0] for the closure.

Eighteen further tags have shipped since v0.10.0, two of them (v0.16.0/v0.17.0) cut back to back in the same release session: v0.16.0 was placed at the historical commit boundary matching an already-published website release-notes page for that version, and v0.17.0 immediately after it for the newer work.

v0.11.0 releases the complete Videokonferenzen (Kleinsitzung) module in one tag — Wave 1 (basic real-time audio/video/screen-share/chat, described below under "Videokonferenzen") together with seven further waves, built sequentially and shipped together: server-side recording via LiveKit Track Egress with asynchronous ffmpeg gallery composition (Wave 2); RTMP-composited external live-streaming to YouTube/Twitch/PeerTube/generic RTMP destinations, protected by this codebase’s first at-rest-encryption primitive for stored stream keys (Wave 3); UI polish closing three items deferred from Wave 1’s own design review — single-button room creation, a camera/microphone permission preflight, and a named connection-state machine (Wave 4); federated OIDC guest join under a versioned, two-layer DSGVO consent flow (Wave 5); moderator-driven breakout rooms (Wave 6); a shared live whiteboard (Wave 7); and shared, block-structured collaborative meeting notes with per-block optimistic-concurrency control (Wave 8). Every wave went through the mandatory review/ security pipeline plus live-browser verification against a real running LiveKit stack — the latter is how several real client bugs invisible to review/security passes (neither mounts real DOM) were found and fixed. See CHANGELOG.md [0.11.0] for full wave-by-wave detail.

v0.12.0 is a production-infrastructure and internationalization release: an 8-language navbar switcher (German source plus English, French, Spanish, Italian, Dutch, Polish, Russian) via a custom I18nCatalogManager, written because KVision’s own kvision-i18n module crashed the app on load against this project’s Kotlin/JS toolchain; a two-stage Docker production deployment (root Dockerfile + deploy/example/docker-compose.yml) replacing the earlier bare-JVM/systemd/ native-PostgreSQL setup, migrated end to end on the project’s test VPS with the prior installation fully uninstalled; Videokonferenzen Wave 1 wired into that Docker stack and live-verified against a real external network; and a redesigned, role-gated dropdown navbar with Lapis Cloud branding (a "faceted gem" mark matching the marketing site), replacing a 20-entry flat list that had begun overflowing the viewport. A real IP-spoofing gap behind the reverse proxy was also found and closed — every IP-keyed rate limiter now uses Ktor’s useLastProxy() instead of the spoofable useFirstProxy() default, verified live against a forged X-Forwarded-For header.

v0.13.0 adds MemberStatus.FRIEND (wave "V0.11.0" in the project’s own wave numbering, released in this tag) — a self-registerable, board-approval-free account scoped strictly to per-room opt-in video-conference access, with no membership, governance, accounting, or LTR rights (see "What doesn’t work yet" below for how far this reaches into the LTR economy). Building it surfaced and closed several pre-existing authorization gaps, the most severe being that OIDC federation token issuance had no organization-membership gate at all — any authenticated caller, including the new self-service FRIEND, could obtain a federation ID token asserting membership to a partner server — plus missing membership-status gates on direct messages, mailing-list subscriptions, and document-folder listing. All five MemberStatus literals were also renamed from German to English (ANTRAG→APPLICATION, AKTIV→ACTIVE, GAST→GUEST, AUSGETRETEN→WITHDRAWN, ABGELEHNT→REJECTED) — a breaking change for any external integration that pattern-matched the old wire values. This release also closes out Videokonferenzen Wave 9 "Stream-Pause bei geheimen Abstimmungen" (automatic live-stream pausing while a secret ballot is open, hardened across six cascading security-audit rounds against the same finding class — a finalizing write trusting a status flag instead of the specific confirmed LiveKit egress id), migrates the pilot’s reverse proxy from Apache to Caddy, and fixes several production Videokonferenzen infrastructure bugs (GRID/SPEAKER egress hairpin-NAT and shared-memory failures). See CHANGELOG.md [0.13.0] for full detail.

v0.14.0 and v0.15.0 deliver Soziales Netzwerk (V1.1), the platform’s first public social-posting layer, across five sub-waves. V1.1.1 adds the post core: a member composes a post with a positive LTR stake bound from their own free balance across three visibility tiers (PUBLIC/ MEMBERS_ONLY/MEMBERS_AND_EXTERNAL), decaying 10%/day; a post is immutable once published and can only be hidden — never edited or refunded — by its own author. V1.1.2 adds threaded comments (full posts in their own right, depth-capped at 64), monetary "boosts", and recursive total-weight aggregation as the timeline’s sort key, computed in pure Kotlin rather than SQL to keep the decay math off the database. V1.1.3 opens the first unauthenticated HTML read path in this codebase (GET /s, GET /s/{id}, a sitemap, robots.txt) — server-rendered, with its own stricter size caps, ETag/CSP/ security headers, and IP-keyed rate limiting, SEO-indexable by design. V1.1.4 widens posting, commenting, boosting, and LTR self-service to the new FRIEND status via a new LTR_ELIGIBLE capability set — deliberately narrow, every other LTR-gated domain (governance, crowdfunding, elections, peer-transfer sending, conferencing) stays ACTIVE-only — under an explicit, user-confirmed legal-coupling condition that V1.1.5 would ship immediately next, with nothing in between, given the DSA/liability exposure of letting identity-unverified accounts publish public, search-indexed content. V1.1.5 honors that commitment: legal removal (REMOVED_LEGAL, RFC 7725 451 responses with a public reason and no original content), a DSA Art. 16 report mechanism reachable both from an authenticated RPC and a plain, JavaScript-free public HTML form, and a post-level DSGVO Art. 17 erasure request/decision workflow independent of the existing member-wide erasure path. See CHANGELOG.md [0.14.0] and [0.15.0] for full detail on all five sub-waves.

v0.16.0 is a backfill/consolidation release, not a single new numbered wave — it bundles what had accumulated since v0.15.0, plus several client-UI-only follow-ups and one earlier wave revisited: SEPA direct-debit mandate management and collection runs with pain.008.001.08 file generation (V1.2.2, building on the V1.2.1 posting-bridge foundation that first let a manually-marked- paid contribution actually reach the ledger); automated dunning with a configurable escalation ladder, poller-driven issuance, and optional postal dispatch of PDF reminders (V1.2.7); the platform’s first real outgoing SMTP transport for password-reset and FRIEND email verification, replacing logging-only stubs in place since V0.7.2/V0.11.0 (V1.2.3); a public transparency landing page with two independently revocable, DSGVO-consented public rankings (top LTR holders, top donors) behind a minimum-cohort-of-5 floor (V1.3.0); a rebuilt member administration (privileged paginated/searchable roster, full editing across three separately-authorized RPCs, race-safe last-admin protection) plus a one-time operator-run CSV import of the PdV membership CRM export and the ability to grant an existing CSV-imported member a login account after the fact (V1.2.11–13); white-label branding (deployment-configurable title/logo with a permanently unremovable "Powered by Lapis Cloud" attribution) and a second, fully independent Lapis Cloud instance for the ELB pilot, co-located on the same host (V1.2.5/V1.2.6); video-conferencing full-screen mode and a mobile-optimized icon-only control bar with auto-hide and bottom-sheet panels (V1.2.9/V1.2.10 — an unrelated wave from the payment-checkout V1.2.9 landing in v0.17.0, see the numbering note below); and gold (GOLD_XAU) and fiat EUR price-oracle anchors alongside the existing Bitcoin anchor, which also closed two latent Price-Oracle orchestrator bugs that would only have surfaced once a second anchor existed (V0.6.6). See CHANGELOG.md [0.16.0] for full detail on every item.

Note
This project’s own wave numbering has a documented collision — two unrelated pieces of work both landed the label "V1.2.9": the video-conferencing full-screen mode above (bundled into v0.16.0) and an unrelated Stripe checkout UX-hardening pass (bundled into v0.17.0, see below). Both are named here purely to avoid confusion when cross-referencing CHANGELOG.md.

v0.17.0 adds a public, read-only REST API for third-party integration (V1.3.1 "API-Fundament, lesend", see "Public REST API" below) and the platform’s first PSP integration: online card payments via a Stripe-hosted checkout for contributions and donations, which post themselves into the ledger through the existing ContributionPostingBridge/a new DonationPostingBridge, webhook-verified and idempotent against redelivery (V1.2.8, GitHub issue #6, see "Payment service provider" below). A follow-up checkout-UX-hardening pass fixes a maximum-donation-amount gate that had wrongly also blocked large legitimate contributions, and closes a security-audit finding that neither checkout- creation RPC was rate-limited despite each triggering a real outbound Stripe API call (V1.2.9, the payment-checkout wave — see the numbering note above). See CHANGELOG.md [0.17.0] for full detail.

v0.18.0 adds outbound webhooks for the V1.3.1 REST API (V1.3.2, see "Outbound Webhooks" below); the "Öffentliche Website-Integration" pair — an embeddable login/join widget with a same-origin popup so no session cookie ever crosses the partner site’s origin (V1.4.1a), and an anonymous, unauthenticated donation widget on the same infrastructure (V1.4.1b, see "Website Integration" below); and a standalone Interessenten-/Sympathisanten-CRM for people who are not members, which extends the DSGVO erasure/export framework beyond members for the first time rather than repeating the external_donor gap (V1.4.2, see "Interessenten-/Sympathisanten-CRM" below). See CHANGELOG.md [0.18.0] for full detail.

v0.19.0 completes V1.4 "Externer Auftritt, CRM, Veranstaltungen, Mitgliederlebenszyklus, Buchhaltungs-Integration" and adds a fifth public route family plus a navigation redesign. Events (V1.4.3): a public, unauthenticated event-registration flow with optional fees (settled through the existing Stripe checkout bridge) and capacity/waitlist handling under row-level locking (V1.4.3.1), followed by hashed ticket codes with SVG/PDF QR delivery and a door-scan check-in screen (V1.4.3.2). Mitgliederlebenszyklus (V1.4.4): a per-member contribution/donation history view (V1.4.4.1); a board-only upcoming-birthdays-and-anniversaries list with leap-day handling (V1.4.4.2); honors/ merit-award tracking with self-honoring-safe DSGVO erasure counting (V1.4.4.3); family memberships with exactly one payer and any number of dues-free dependents, plus an upcoming-majority worklist (V1.4.4.4); and a § 38 BGB deceased-member workflow — deliberately declaratory, not constitutive, with no notification automation and no automatic write-off of a deceased member’s outstanding dues (V1.4.4.5). Bank/accounting integration (V1.4.5): CSV/MT940 bank-statement import with four match-then-post rules, an IBAN-checksum payment-reference code printed on every invoice, and encrypted-at-rest counterparty IBANs (V1.4.5.1); a byte-verified DATEV-EXTF Buchungsstapel export for the Steuerberater with an all-or-nothing blocker model (V1.4.5.2); and live, incremental accounting-export bridges to both Lexware Office/lexoffice and sevDesk behind one provider-neutral adapter interface (V1.4.5.3/V1.4.5.4). Public presence (V1.4.6/V1.4.7): GET / is now a server-rendered, SEO-indexable landing page (member/LTR/post counters plus top-post teasers) — the member SPA moved from / to /app as a breaking change for any bookmarked /#/…​ link or embedded website snippet, softened by a same-origin hash-bridge redirect for already-circulating links; GET /impressum and GET /datenschutz render the statutory German Impressum/ Datenschutzerklärung from operator-supplied LAPIS_LEGAL_* configuration, degrading to an operator-addressed notice (never a 500, never a misleadingly empty page) when unset; and all three unauthenticated public route families (/, /s, /transparenz) now share one chrome component with an 8-language switcher. Icon navigation + overviews (V1.9.11): the shared chrome’s nav switched from text links to icon-only links (CSS-class-bound, a CSS-only tooltip via aria-label), and two new, conditional tabs — GET /aktuelles and GET /veranstaltungen, server-rendered index,follow overviews of published articles / upcoming public events — appear only once there is something to show (PublicNavAvailabilityProvider, a 30-second process cache). Navigation (2026-09-08/09): the six role-gated navbar dropdowns became a persistent, collapsible desktop sidebar / mobile offcanvas drawer — a wave that surfaced and fixed four real KVision/Bootstrap-Offcanvas integration bugs across three follow-up rounds, the last of which (sidebar disappearing after navigating to any page but the Dashboard) was live-reported by a real pilot admin, initially misdiagnosed as fixed against a stale dev-server bundle, and only confirmed fixed after a second live reproduction against a properly reloaded build. The Startseiten <h1> now shows the operator’s own organization name (branding.title) instead of a generic tagline, closing a gap where an installation with a configured logo showed that name nowhere as readable text on its own landing page. See CHANGELOG.md [0.19.0] for full wave-by-wave detail.

v0.20.0 closes three items. An admin-triggered password reset for a member who already has a login account (V1.4.9) — two deliberately distinct paths: an immediate, session-revoking "set a temporary password" action, and a non-revoking "send reset email" action that reuses the existing unauthenticated token mechanism, both ADMIN-only and never usable on the admin’s own row. A Bedienoberfläche (/bank-import) plus full 7-language i18n for the bank-statement import feature that had shipped server-only in v0.19.0’s V1.4.5.1 (V1.4.5.1.1) — upload, per-line status filtering/pagination, manual assignment of an open line to a contribution or donation, and structured rejection/warning codes instead of raw server prose. And a fourth embed widget, event registration (V1.4.3.3, see "Website Integration" below), closing the gap the V1.4.3.1 wave had deliberately deferred: `POST /api/embed/v1/event/{slug}/registration collapses the full registration-outcome type down to four visitor-facing outcomes only, specifically to avoid opening a membership-enumeration oracle over guest email addresses. See CHANGELOG.md [0.20.0] for full detail.

v0.21.0 is a broad feature release. PayPal joins Stripe as a second payment service provider (V1.2.8b, see "Payment service providers"); accounting gains payables/receivables with open-item matching and a debtor dunning run (V1.4.15; the client screens for open items, netting and debtor dunning followed in V1.4.21), several bank accounts per organization with file import per account plus read-only FinTS/HBCI live retrieval (never verified against a real bank, see docs/architecture/bank-account.adoc) and a bank-accounts management screen (V1.4.14, waves 1 and 2; the FinTS poller is off by default), and a VAT pre-return helper (V1.4.13); membership dues can be deferred, waived or reduced (V1.4.10), and board members can claim travel expenses (V1.4.11) and the volunteer/instructor allowance (V1.4.12). The event toolkit gains rooms, catering, external invoicing and volunteer shifts (V1.4.3.4 to V1.4.3.7) plus a subscribable iCal calendar feed. Video conferencing gets a TURN relay fallback. Server-side support for the mobile companion app (V1.5.1; its bridge routes were later found vulnerable to session fixation and are switched off by default since v0.22.0), a dark mode, a configurable upload limit with progress display and footer links round it out. See CHANGELOG.md [0.21.0] for full wave-by-wave detail.

v0.22.0 adds a downloadable PDF membership card in credit-card format with a QR code that opens a rate-limited public check page (Flyway V43, member numbers like M-2026-00001); the documents screen gets file icons, sizes, a download counter, folder counts and search (Flyway V40), and TREASURER now has BOARD-level document rights for folders, uploads and deletions (releasing a document into the AI knowledge base stays BOARD/ADMIN); three additional Vorstand titles (Flyway V42); a stored Price-Oracle history with an hourly snapshot poller and a price chart (Flyway V41; the poller is off by default and must be enabled on at most one instance, see deploy/example/README.adoc, "Price-Oracle Snapshot Poller"); a BOARD/ADMIN screen for creating and managing events; and support for a dedicated staging deployment with fictitious seed data only (see deploy/example/README.adoc, "Staging seed mechanism"). V1.6.1 optional AI assistance (statute Q&A, Flyway V44) ships off by default and is not enabled on any shipped deployment, see "AI assistance" below. For the companion app, the server has a generic WebView bridge (/api/mobile/v1/webview-session) that is header-authenticated (the token is never in a URL) and disabled by default behind LAPIS_MOBILE_WEBVIEW_BRIDGE_ENABLED, whose section keys are a closed server-side allowlist (MOBILE_SECTION_TARGETS; my-events, the member events page, was added in V1.9.52, and an app newer than the server gets 400 for unknown keys, which is expected — the app should treat it as "server too old"), forwarded by deploy/example/docker-compose.yml since 2026-09-27 (previously it wasn’t — shipped instances from before that date need the same line added to their own compose file, see deploy/example/docker-compose.yml). The switch and the header-only design close a session-fixation weakness in the bridge routes shipped in v0.21.0; companion apps built before V1.5.2 (which send ?token=) no longer work against a server with the bridge switched on. Security: a disabled AI route now answers a typed error instead of HTTP 500, the server-wide AI opt-in default was removed so no member is pre-consented, and a lock-order deadlock in family-member removal was fixed. Fixes: the camera picture froze after clicking "More" in a video conference, empty device ids were offered and persisted, 50 screens overflowed narrow viewports, and the participants list covered the video on phones. See CHANGELOG.md [0.22.0] for full detail.

v0.23.0 is dominated by a full UI/UX overhaul of every screen against the project’s own written guideline (docs/architecture/ui-ux-guideline.adoc): six waves put the hot list screens on real data tables (V1.4.25 to V1.4.27), moved every form onto one shared form grammar with field-level errors, consistent required-field marking and double-click guards (V1.4.28 to V1.4.30, completed for the last seven screens in this release), unified empty/loading/error states and page headers (V1.4.31), and made money amounts render in the viewer’s own locale and compute in whole cents rather than doubles. Video conferences gain background blur and virtual backgrounds (V1.4.23), computed locally in the browser with no CDN — model, WASM and images are served from the instance itself — and they also work in the Android WebView of the companion app (V1.4.24); iOS WKWebView and third-party in-app browsers stay blocked, and weaker phones are untested. The accounting side gets the client screens for open items, netting and debtor dunning that v0.21.0 had shipped server-only (V1.4.21/V1.4.22), and long-lived browser tabs are told when a newer client bundle is live (V1.4.20).

Keycloak can take over authentication as an optional, per-deployment replacement for the internal login (V1.7.1 to V1.7.3, Flyway V45 to V48): roles and membership status stay in the Lapis Cloud database, accounts are matched by email with an ADMIN screen for manual linking, and an emergency ADMIN login always remains. It ships off on every instance; switching an already-running instance over is only supported along the cutover runbook in deploy/example/README.adoc. Security: an app-wide widget-content forgery path was closed, where any server- or member-controlled string beginning with KVision’s i18n marker could render as arbitrary text — including a freely invented monetary amount. Infrastructure and documentation: the per-instance deploy/production*/ directories were replaced by a single generic deploy/example/ template with the real deployment data moved to the private Lapis-Cloud-Ops companion repo, the AsciiDoc documentation now renders its [kuml] blocks through the real kUML toolchain (which exposed invalid DSL in 10 diagrams), and the public API reference was verified statement by statement against the code. See CHANGELOG.md [0.23.0] for full detail.

v0.24.0 opens the server to MCP-capable AI agents of the member’s own choosing — an agent reads and acts on that one member’s own data, never anyone else’s. Authentication extends the OIDC provider this server has run since V0.8 rather than inventing a second, weaker identity model. PKCE did not have to be invented: the provider’s existing implementation was verified and reused unchanged, and the gap this wave actually closed was the missing resource-server validation path (oidc_issued_token.access_token_hash had no reader anywhere in the codebase before). A member flow now sits beside the existing guest flow (Flyway V49/V50). Five read-only tools ship first (contribution status, LTR balance, statute search, upcoming events, own ballots), then two write tools: registering for an event and drafting a post. Everything is off by default: the endpoint needs LAPIS_MCP_ENABLED, and the write tools additionally need LAPIS_MCP_WRITE_ENABLED. No agent gets access until the member authorizes that specific connection on a dedicated consent screen; on top of that an all-or-nothing per-member kill switch (mcp_member_block — no row means not blocked, the deliberate opposite polarity of ai_member_opt_in) always wins and revokes every live token the moment it is flipped. Its member-facing screen followed in V1.9.29 (the "KI-Zugang" card, tag v0.27.0); at the time of v0.24.0 only the RPC backend existed.

Two boundaries are worth naming because they were deliberate decisions, not oversights. An agent cannot spend money: registering for a paid event is rejected outright, so no checkout session is created through MCP and no payment link is ever handed to the agent’s model provider — paid registration stays in the web UI. An agent cannot publish: a drafted post lands in a separate draft store that is not a post at all, invisible to feeds, LTR weighting, moderation and the public pages, and only the member releasing it in the new "KI-Entwürfe" screen creates a real post — which then carries a label visible to every reader, in the app and on the public post pages alike. Drafts a member discards are removed on a retention schedule instead of being kept forever. No tool path reaches vote weighting, LTR minting or burning, auctions, crowdfunding distribution, election tallies, politician rating or timeline ordering.

This release also fixes "Cluster A", five long-standing client-test failures that turned out to be test-order pollution rather than a timing margin: one test class left a modal open, and in Karma’s single browser page it covered every class that ran after it. Four of the five are fixed; the remaining one is named in CHANGELOG.md and still fails. See CHANGELOG.md [0.24.0] and docs/architecture/mcp-server.adoc for full detail.

v0.25.0 covers two intense days of work across a dozen-plus numbered waves (V1.9.1 through V1.9.14, plus the remaining V1.4.32-V1.4.37 recurring-events/date-formatting/article-module follow-ups) — this section groups them by theme rather than listing each one; see CHANGELOG.md [0.25.0] for full wave-by-wave detail. Document/folder access control gains a per-folder DocumentAccessLevel (V1.9.1) enforced as the most restrictive level along a folder’s own ancestor chain, plus a folder-creation default that follows the creating role’s own most-restrictive visibility (V1.9.2). App-wide date/time display and i18n follow-ups (V1.4.32 W7, three review-fix rounds; V1.9.3) close a long tail of raw-ISO-date leaks and a German-plural-shown-for- singular bug on the SEPA batch screen. Video conferencing gets a round of real-pilot fixes from an ELB test session (no wave number of their own, dated 2026-09-27 in CHANGELOG.md) — persisted device choice, portrait-tile letterboxing, roster resync on reconnect, egress error logging, recording-start-gap elimination and two rounds of A/V-sync correction (see "Videokonferenzen" above) — plus private per-conference background images (V1.9.4). Recurring events ("Wiederkehrende Veranstaltungen") complete across three follow-up waves: an RRULE recurrence library, RPC wiring against the event/event-series tables, and finally the admin UI plus an RRULE-aware iCal feed (V1.4.35-V1.4.37). Content and publishing gain an editorial article/news module with a four-eyes approval gate (V1.4.34/V1.4.36, see "Article Module" below), an event cover-image upload, an events-list embed widget (V1.4.33), and two new public overview pages plus icon-only navigation across every public route (V1.9.11). The Vorstands-Karte (member map) ships end to end — privacy-preserving postal-code aggregate view, PMTiles basemap, then three fast-follow waves closing real board feedback: orientation labels and a hover tooltip, then border contrast, state-capital labels, small-locality labels and a place search (V1.9.5-V1.9.9, see "Member map" below). SuperMailer adds HTML-authored mailing content and a real asynchronous send path, replacing a newsletter send that previously never called any transport at all (V1.9.7). Board can now toggle the public member count on the homepage/transparency page off (V1.9.10, opt-out default). Members get a Mitfahrerzentrale (carpooling bulletin board, V1.9.12, see "Mitfahrerzentrale" below) and an optional Gliederungsverwaltung (regional chapters, V1.9.13/V1.9.14, see "Regional chapters" below). Finally, a 16-file untrusted-text sanitization sweep closes the remaining gaps in the app-wide "W6b security-härtung" XSS-class hardening effort, and a production build fix raises the client webpack step’s Node heap cap after the bundle grew past the old ceiling — see CHANGELOG.md [0.25.0] for both.

v0.26.0 spans nine numbered waves (V1.9.15 through V1.9.23) plus small fixes; see CHANGELOG.md [0.26.0] for the wave-by-wave detail. Democratic elections finally have a web client (V1.9.22, see "Democratic elections" below): the list and detail screen, opening an election from a scheduled motion, the election committee, candidacies, a voting booth with a one-time receipt for secret ballots, count approvals, the result and a receipt check. The same release hardens the server (V1.9.23): the four ways to decide a motion — election, meritocratic vote, systemic consensus and the quorum resolution — now exclude each other, a tally can no longer overwrite a decision, required majorities are exact fractions (two thirds, three quarters), and a secret ballot no longer carries a time. Active members can add a voluntary photo, and board members and listed politicians a short introduction, each with its own versioned consent that is withdrawn immediately and everywhere (V1.9.19/V1.9.20), and the public site gains /vorstand, /politiker and /landesverbaende with matching embed feeds (see "Member photo and public profiles" below). Regional-chapter crests may be JPEG, PNG or a strictly sanitized SVG (V1.9.21). SuperMailer gets its rich-text editor and opt-in click/open tracking with consent (V1.9.15). A membership tier administration screen replaces the missing UI for a backend that had been complete for a long time (V1.9.18), searchable person pickers and list filters replace plain dropdowns (V1.9.16), and V1.9.17 closes the table-cell text-spoofing gap and several small UI issues. Operators: this release edits V1__baseline.sql once more (run flywayRepair), adds migrations V60 to V64, and needs the LAPIS_MAILING_* variables forwarded in the compose file — they never were; see CHANGELOG.md [0.26.0] "Operator actions" and "Operator notes — V1.9.23".

v0.27.0 spans eight numbered waves (V1.9.24 through V1.9.31) plus a test-only stability fix; see CHANGELOG.md [0.27.0] for the wave-by-wave detail. Voting moves into the video conference (V1.9.24-V1.9.27, see "Voting in the conference room" below): an "Abstimmen" panel lists the open and recently decided elections and meritocratic votes of the room’s Sitzung and embeds the real voting booth, operators can open, close and count elections from the room, and a secret election locks the cast button until the streams of the Sitzung are paused. Systemic consensus gets its web client (V1.9.28, see "Systemic consensus" below). Members get the "KI-Zugang" card on "Meine Daten" to switch MCP agent access on or off and revoke connections (V1.9.29) — visible only where the operator has enabled MCP. Non-binding opinion polls weighted by LTR balance arrive with server and web UI (V1.9.30/V1.9.31, see "Opinion polls" below). None of the new conference, consensus or poll flows has been run end to end on Staging yet; the test plans are written. Operators: this release edits V1__baseline.sql once more (take a backup and run flywayRepair on every existing instance before deploying) and adds migration V65; MCP stays off unless LAPIS_MCP_ENABLED=true is added to the compose file by hand — see CHANGELOG.md [0.27.0].

v0.28.0 spans eighteen numbered waves (V1.9.32 through V1.9.49) plus a test-only stability fix; see CHANGELOG.md [0.28.0] for the wave-by-wave detail. Times follow the organization’s time zone (V1.9.38): a new ADMIN setting "Zeitzone der Organisation" (default Europe/Berlin), system timestamps shown in that zone, typed-in times shown as typed; this fixes the iCal feed, poll deadlines, event end times, letter dates and the 72-hour data-breach deadline, which were off by one or two hours. Systemic consensus follows the practice more closely (V1.9.32, V1.9.39, V1.9.41, V1.9.42): the consensus is part of the conference room panel, the status quo option is listed first, the real options carry numbers, a proposal can carry a rationale, the strong-objection hint follows the scale, and opinion polls gain two consensus modes (decision and priority ranking) with an optional explanation per option. An anonymous consensus with fewer than five ratings discloses no figures. Single ballots of secret votes are never delivered — neither the rating vectors of an anonymous consensus nor the anonymised ballots of a secret election (V1.9.44, V1.9.46; both are API behaviour changes, see the CHANGELOG); members therefore can no longer recount a secret election themselves, the result counts and the receipt check remain. Create forms are collapsed behind a button in the title row on all screens the R36B scan knows (V1.9.40, V1.9.45, V1.9.47-V1.9.49; exceptions such as the article editor are named in collapsible-forms.adoc), action buttons carry standard icons and toolbar buttons line up with their fields (V1.9.43). Members can maintain their address and GwG data, block a lost membership card and register for events themselves (V1.9.33), direct messages gain conversations and paging, and the board can mark event refunds as paid outside Lapis Cloud (V1.9.34-V1.9.36). A Postgres test lane runs the engine-dependent tests against a real PostgreSQL 17, also in CI (V1.9.37), and found three code paths that were only correct on H2. The staging test plans of these waves are written but were not executed. Operators: take a backup and apply migrations V66 to V69; V1__baseline.sql is unchanged, so no flywayRepair is needed. Open polls close and running events end up to two hours earlier than before (at the typed-in local time) — deploy while no vote is running — see CHANGELOG.md [0.28.0].

Auction opt-in (V0.6.2)

The legal classification of an LTR-for-goods marketplace (payment-services supervision, trade law, tax law, consumer protection, party-donation law, anti-money-laundering law) depends on jurisdiction and organization type — no single blanket legal review can resolve this for every future Lapis Cloud deployment. The auction ships disabled by default (OrganizationSettings.auctionEnabled = false); an ADMIN must call enableAuction with the SHA-256 hash of the currently displayed, versioned legal-risk disclaimer before it activates. Enabling it is an operational decision for each organization’s own operator, informed by their own legal counsel — not something this project can decide once for everyone. See CHANGELOG.md [0.6.0] and the disclaimer text in AuctionComplianceDisclaimer.kt for detail.

What doesn’t work yet

  • Real email delivery exists (V1.2.3) but is optional and off by default. Password-reset tokens are real (hash-only-persisted, single-use, short-TTL); SmtpPasswordResetMailer sends a real message via Jakarta Mail/Eclipse Angus whenever LAPIS_SMTP_* is configured (deploy/example/README.adoc "E-Mail-Versand (SMTP, optional)"), and falls back to the same honestly-disclosed NoOpMailTransport log-only stub MailingService.sendMailingMessage has had since V0.4/V0.1.5 when it is not. Bulk mailings are a separate switch: since V1.9.7 they are sent for real when LAPIS_MAILING_DELIVERY=smtp is set (see "SuperMailer" below), otherwise they stay in the log-only mode; this wave only wired up the two single-recipient transactional mailers. Verify outbound connectivity before relying on this in production: some hosting providers silently drop outbound connections on SMTP ports (25/465/587) regardless of local firewall configuration — a correct LAPIS_SMTP_* configuration and a genuinely reachable relay are two separate things to confirm.

  • FRIEND email verification now has a working delivery path AND a client landing screen (V1.2.3), but enforcement is still off. MemberStatus.FRIEND (V0.11.0) is a self-registerable status (/register-friend, no board approval, no identity check) scoped strictly to video-conference access on a per-room opt-in basis — it is not membership in the organization, carries no Beitragspflicht/governance/accounting/LTR rights, and no PUBLIC_MEMBERS document access. The friend_email_verification_token mechanics, the real mail (SmtpFriendVerificationMailer), and the #/verify-email?token=…​ client deep link (renderVerifyEmailScreen) all exist end to end now; LAPIS_FRIEND_REQUIRE_EMAIL_VERIFICATION (default false) stays off regardless — activating hard enforcement is a separate, later operational decision, not something this wave flips automatically.

  • UI coverage has grown substantially since v0.10.0, but is still not complete. Governance, Accounting, SEPA direct debit (V1.2.2), automated dunning (V1.2.7), member administration (V1.2.12/V1.2.13), the Stripe checkout (V1.2.8), and open items with netting and debtor dunning (V1.4.21) all now have real client screens. So do the Compliance domains this sentence used to list as missing — audit log (/audit-log), backup/restore (/backup), DSGVO compliance and member DSGVO rights (/dsgvo-compliance, /dsgvo-rights), §20 GwG Transparenzregister (/board-membership), §25 PartG donation duty (/donors) — as well as the postal-dispatch audit trail (/postal-mail) and all four original LTR economy domains (/crowdfunding, /auction, peer transfer on /ltr-ledger, /politicians). Democratic elections gained their client in V1.9.22 (/elections, see "Democratic elections" below), systemisches Konsensieren in V1.9.28 (/consensus), and the member-facing MCP access switch (IMcpAccessService) in V1.9.29 (the "KI-Zugang" card on "Meine Daten"). One domain is genuinely still reachable only via Kilua RPC / raw HTTP: federation/trust-anchor administration (IFederationService/ITrustAnchorService) — it has no route and no screen. It is of lower day-to-day usage frequency per the pilots' own prioritization and is not yet scheduled as a numbered wave.

  • No content type is actually federated yet. V0.8.1 built the ActivityPub-hybrid protocol layer; no existing content type (crowdfunding projects, politician profiles, governance resolutions, or the Soziales Netzwerk posts added in V1.1) is wired into outbound federation. A future wave’s own explicit product decision.

  • Social-network moderation has a legal-notification gap. DSA Art. 16 Abs. 4/5 (acknowledgment and decision notices to whoever filed a report) has no automated mail yet — the real SMTP transport added in V1.2.3 covers password-reset and FRIEND email verification only, not report notifications. There is also no NetzDG transparency-report mechanism and no DSA Art. 20 dispute procedure (small-business exception) — both deliberately out of scope so far. See CHANGELOG.md [0.15.0].

  • No guest participation in the LTR economy. A federated guest (V0.8.2) is authenticated and represented, but not wired into LTR staking/posting/earning — that mechanics is deliberately not built yet, a separate scope decision from identity itself. FRIEND (V0.11.0, tag v0.13.0) is a different, non-federated status and is not covered by this gap: since Soziales Netzwerk V1.1.4 a FRIEND can stake, post, comment, boost, and self-serve its own LTR balance within the social-network domain specifically, while every other LTR-gated domain (governance, crowdfunding, elections, peer-transfer sending, conferencing) remains ACTIVE-only. See CHANGELOG.md [0.14.0].

None of this is a secret gap. See the project vault’s Lapis Cloud roadmap for the up-to-date wave plan.

Videokonferenzen (V1.0 Wave 1)

A self-hosted video-conferencing module for small meetings (the concept note’s "Kleinsitzung" use case, first application: Vorstandssitzungen) — own infrastructure on LiveKit rather than an embedded third-party widget or a BigBlueButton integration, matching this project’s "own stack, own data" posture everywhere else. IConferenceService covers room creation/join/leave/end, a live participant roster, and moderator actions (remove a participant, end the room for everyone); the KVision client (ConferenceScreen.kt) adds a responsive video-tile grid, a persistent control bar (microphone/camera/screen-share/chat/leave), and a collapsible ephemeral chat panel riding LiveKit’s own data channel. Since V1.4.23 the call also offers background blur and six built-in background images, processed locally in the browser (no image is sent to the server; the segmentation WASM and model are served from this server, never from a CDN). On a browser or device without the required support — and, deliberately, inside the mobile app’s embedded WebView, where the combination has never been measured — the row stays visible but disabled with an explanation — see video-background-effects.adoc. Two-tier roles only in this wave: MODERATOR (the room’s creator) and PARTICIPANT (everyone else), with a global BOARD/ADMIN escalation on the two moderator-only actions — see IConferenceService KDoc for the full per-method authorization table.

Local development/testing infrastructure (deploy/local/ — this repository’s first Docker setup) and a full local-run recipe, including a two-browser manual verification walkthrough and an opt-in live integration test, are documented in deploy/local/README.adoc.

Postgres test lane (V1.9.37)

The normal test suite runs on H2. A second Gradle task, ./gradlew :lapis-server:postgresTest, runs the engine-dependent specs (Flyway chain, schema equivalence with H2, row locks, isolation, deadlocks, concurrency) against a real, disposable PostgreSQL; it is part of check and is skipped when LAPIS_TEST_POSTGRES_URL is not set. CI runs it against a postgres:17 service container. Start it locally with a fresh Docker container bound to 127.0.0.1 — see postgres-test-lane.adoc. Never set LAPIS_DB_URL for it and never point it at a real database (the lane refuses).

Note
GitHub’s built-in .adoc viewer does not load the kuml-asciidoc extension, so it cannot execute the [kuml] macro below and shows its raw source instead. The image immediately below is a pre-rendered copy of that exact model, committed purely so GitHub’s preview has something to show — the [kuml] block underneath stays the single source of truth (see "Documentation Convention" below) and is what actually renders in Antora/local Asciidoctor builds. If you edit the model, re-render docs/images/conference-room-lifecycle.svg from it (kuml render <extracted-source> --format svg) — nothing enforces that these two stay in sync automatically.
Conference Room Lifecycle state machine — Initial to Created to Active/Empty to Ended
import dev.kuml.sysml2.dsl.sysml2Model

sysml2Model(name = "ConferenceRoomLifecycle") {

    val initial = stateDef(name = "Initial", isInitial = true)
    val created = stateDef(
        name = "Created",
        entryAction = "LiveKit CreateRoom via server-internal admin token",
    )
    val active = stateDef(
        name = "Active",
        entryAction = "liveParticipantCount > 0",
    )
    val empty = stateDef(
        name = "Empty",
        entryAction = "liveParticipantCount == 0, endedAt still NULL",
        doAction = "waiting out room.empty_timeout grace period",
    )
    val ended = stateDef(name = "Ended", isFinal = true, entryAction = "endedAt stamped")

    transition(name = "init", source = initial, target = created)
    transition(name = "firstJoin", source = created, target = active, trigger = "joinRoom")
    transition(
        name = "moderatorEndsCreated", source = created, target = ended,
        trigger = "endRoom", guard = "moderator or global BOARD/ADMIN",
    )
    transition(
        name = "moderatorEndsActive", source = active, target = ended,
        trigger = "endRoom", guard = "moderator or global BOARD/ADMIN",
    )
    transition(
        name = "lastLeaves", source = active, target = empty,
        trigger = "leaveRoom", guard = "no live participants remain",
    )
    transition(name = "rejoin", source = empty, target = active, trigger = "joinRoom")
    transition(
        name = "moderatorEndsEmpty", source = empty, target = ended,
        trigger = "endRoom", guard = "moderator or global BOARD/ADMIN",
    )
    transition(
        name = "lazyReconcile", source = empty, target = ended,
        trigger = "listActiveRooms (any authenticated caller)",
        guard = "elapsed since empty > empty_timeout AND LiveKit ListRooms no longer knows the room",
        effect = "stamp endedAt server-side -- no webhook needed for this wave",
    )

    stmDiagram(name = "Conference Room Lifecycle (Wave 1)") {
        include(state = initial)
        include(state = created)
        include(state = active)
        include(state = empty)
        include(state = ended)
    }
}

The Empty → Ended "lazy reconcile" transition is deliberate: this wave has no LiveKit webhook consumer (every client-visible need is already covered by the LiveKit SDK’s own RoomEvent stream, and endRoom is a synchronous call), so a room whose participants all simply left — rather than having the moderator explicitly end it — only gets endedAt stamped the next time any authenticated member calls listActiveRooms and that reconciliation notices LiveKit no longer lists the room past its empty-timeout grace. Exact, event-accurate presence timestamps (and, downstream of that, a "Teilnehmerliste = Anwesenheitsliste" attendance integration) are deliberately deferred to a future wave that does add webhooks.

Explicitly out of scope for Wave 1 (see the wave’s own KDoc/design-review notes for the full list): recording/streaming (LiveKit Egress, RTMP), whiteboard/document sharing, breakout rooms, live subtitles/translation, hand-raise/reactions, a lobby/Warteraum, the full four-tier Moderator/Präsentator/Teilnehmer/Zuhörer role model, E2EE, "Termin → Konferenzraum" integration with the Sitzungen/Gremien module, voting-module integration, federated guest join, and chat persistence (chat is intentionally ephemeral — it travels only over the LiveKit data channel and is never written to the database, so it carries no GoBD/DSGVO retention obligation). The Wave 1 design review additionally flagged D1 (single-button room creation instead of today’s title-entry form), D2 (a first-class, non-technical camera/microphone permission preflight screen), D3 (a speaking-priority reflow above ~12 participants) and D10 (a fully named client-side connection state machine) as still-open polish items for the next Videokonferenzen step — not yet built, see `ConferenceScreen.kt’s own file KDoc.

Payment service providers (Stripe + PayPal)

Welle V1.2.8 (GitHub Issue #6) let members pay contributions and leave donations through a Stripe-hosted Checkout (card only, EUR). Welle V1.2.8b (GitHub Issue #6, follow-up) added PayPal as a full second provider — enablePaymentGateway(PAYPAL, …​) is accepted exactly like STRIPE now (only MANUAL is still rejected), and PayPal has a real checkout-creation/webhook/capture flow of its own, not a stub. PspCheckoutGateway is the provider-neutral abstraction every checkout call site depends on (createCheckout/maxCheckoutAmountEur/checkoutTtlMinutes/ sessionLifetimeCap); StripeCheckoutClient and PaypalOrdersClient are its two implementations. Design rationale, the capture-flow sequence, and two provider-specific gaps are documented in docs/architecture/psp-paypal.adoc — this section covers operator-facing setup and scope.

One PayPal-specific mechanical difference from Stripe worth calling out here: capture is webhook-triggered, in the CHECKOUT.ORDER.APPROVED handler, never in the return-flow request itself — robust against a payer who approves and then closes the tab, and it needs no new RPC method or client change (`PaymentReturnScreen’s webhook-authoritative polling works unmodified for both providers).

Three independent gates

MINOR fix (code review, Welle V1.2.8) — this list previously named the ledger-account mapping as a third checked gate; it is not one of requirePaymentGatewayUsable()’s checks (see `IPaymentGatewayService KDoc, "The three-part usability gate", the authoritative source this list now mirrors). Payment acceptance is only possible when all three hold — checked both at checkout creation and again when the webhook arrives, so a gate flipped off mid-flight stops the posting:

  1. organization_settings.payment_gateway_enabled — an ADMIN acknowledges a versioned legal-risk disclaimer (PaymentGatewayComplianceDisclaimer) via enablePaymentGateway/ disablePaymentGateway, same auditable acknowledgment mechanism as the auction opt-in above.

  2. The CURRENT disclaimer version was acknowledged — same "stale acknowledgment blocks writes" posture SepaService.requireSepaUsable already establishes for SEPA; a newly published disclaimer version blocks writes again until re-acknowledged.

  3. Deployment secrets present for the CONFIGURED provider — for STRIPE: LAPIS_STRIPE_SECRET_KEY/LAPIS_STRIPE_WEBHOOK_SIGNING_SECRET both set and well-formed (PspConfigState.Configured); for PAYPAL: LAPIS_PAYPAL_CLIENT_ID/ LAPIS_PAYPAL_CLIENT_SECRET/LAPIS_PAYPAL_WEBHOOK_ID all set and well-formed (PaypalConfigState.Configured) — AND organization_settings.payment_gateway_provider matches whichever provider is actually configured (MANUAL is the only literal enablePaymentGateway still rejects).

A complete ledger-account mapping (paymentBankAccountId/contributionIncomeAccountId/ donationIncomeAccountId/paymentFeeAccountId, the fee account only mattering once a provider fee is ever captured, which this wave does not do) is configured via the ordinary updateOrganizationSettings RPC, but is not one of the three gates above — an incomplete mapping instead degrades a webhook-confirmed payment to an UNPOSTED row in the treasurer’s Zahlungseingänge reconciliation queue rather than losing it silently, and rather than blocking checkout creation up front.

Environment variables — secrets are ENV-ONLY, never persisted

Same discipline as LAPIS_SMTP_* above: PspConfig.load()/PaypalConfig.load() are the ONLY places any of these are read, never persisted to the database, never SecretBox-sealed (that mechanism is reserved for credentials that live in a database row — a PSP API key is a single org-wide deployment credential, not per-row data). All-or-nothing per provider: either all of LAPIS_STRIPE_SECRET_KEY/LAPIS_STRIPE_WEBHOOK_SIGNING_SECRET are set, or neither; either all of LAPIS_PAYPAL_CLIENT_ID/LAPIS_PAYPAL_CLIENT_SECRET/LAPIS_PAYPAL_WEBHOOK_ID are set, or none — a half configuration for either provider fails the server’s startup with an explicit IllegalStateException naming the missing/invalid variable by NAME ONLY, never its value. Three numeric knobs (LAPIS_PSP_WEBHOOK_TOLERANCE_SECONDS/LAPIS_PSP_MAX_CHECKOUT_AMOUNT_EUR/ LAPIS_PSP_CHECKOUT_TTL_MINUTES) are shared across both providers — they are Stripe’s own opt-in signal and deliberately do NOT count as a PayPal opt-in signal, so a Stripe-only deployment that sets one of them is never mistaken for an incomplete PayPal configuration (see PaypalConfigState.Incomplete KDoc). LAPIS_PAYPAL_API_BASE_URL is optional (defaults to the live PayPal API; set it to PayPal’s sandbox base URL for testing). See deploy/example/.env.example for the full block.

There is no RPC that writes these secrets — getPspConfigStatus() (ADMIN-only) reports only whether each is present as a boolean, never a prefix/suffix/length/value.

Stripe dashboard webhook configuration

Register this URL in the Stripe dashboard for both the checkout.session.completed event and the checkout.session.expired event. Neither is optional: checkout.session.expired is the only hook that ever cleans up an abandoned anonymous-donation checkout (the placeholder external_donor row and the payment_checkout_session row it left behind) — see PspWebhookIngestion.ingestCheckoutExpired. Skipping it means every abandoned widget checkout leaves orphaned rows behind forever:

<LAPIS_PUBLIC_BASE_URL>/api/webhooks/stripe

Copy the signing secret Stripe displays there (whsec_…​) into LAPIS_STRIPE_WEBHOOK_SIGNING_SECRET.

PayPal dashboard webhook configuration

Register this URL in the PayPal Developer Dashboard (Live or Sandbox app, matching whichever LAPIS_PAYPAL_API_BASE_URL is configured) for the CHECKOUT.ORDER.APPROVED, PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.DENIED, and CHECKOUT.ORDER.VOIDED events. Unlike Stripe’s self-contained HMAC signature, PayPal signature verification is a live outbound call to PayPal’s own POST /v1/notifications/verify-webhook-signature API — see docs/architecture/psp-paypal.adoc for why that call, not local certificate-chain validation, was chosen, and for the 503-retry contract when that API itself is unreachable:

<LAPIS_PUBLIC_BASE_URL>/api/webhooks/paypal

Copy the Webhook ID the PayPal Developer Dashboard assigns to this webhook into LAPIS_PAYPAL_WEBHOOK_ID — every delivery is verified against it via the API call above, so the value must match exactly.

What doesn’t work yet (this wave)

Deliberately out of scope, see CHANGELOG.md for the full list: refunds/chargebacks, capturing the PSP’s own transaction fee (the full gross amount posts to the bank/income accounts), recurring/subscription billing, currencies other than EUR, and PayPal support for the anonymous embed-widget donation path (/api/embed/v1/donation/checkout stays Stripe-only — the widget itself offers an anonymous donor no provider choice).

Public REST API (V1.3.1)

A read-only REST API under /api/v1 — /members, /members/{id}, /committees, /committees/{id}, /meetings, /meetings/{id}, /resolutions, /resolutions/{id}, /motions, /motions/{id} — for external, machine-to-machine consumption, independent of the Kilua-RPC layer lapis-client uses. Authenticated via Authorization: Bearer lapis_<key>, a credential namespace kept structurally separate from session cookies/tokens. Board/Admin members issue and manage keys under Verwaltung → API-Schlüssel. Full endpoint/field/rate-limit reference: docs/api/public-api-v1.adoc.

What doesn’t work yet (this wave)

Deliberately out of scope, see CHANGELOG.md: any write endpoint, OAuth2/OIDC/scopes, multi-tenancy, distributed rate limiting, OpenAPI/Swagger, ETag/caching, CORS, field selection/sorting/full-text search/cursor pagination, bulk endpoints, automatic key rotation/expiry mail/cleanup.

Outbound Webhooks (V1.3.2)

Ten fixed events (committee.created/.updated, meeting.created/.held, resolution.adopted, motion.scheduled, member.created, contribution.paid, donation.received, event.registration.paid), plus a manually triggered webhook.test diagnostic event, fan out as signed HTTPS POSTs, one webhook endpoint per existing Authorization: Bearer lapis_<key> API key from the Public REST API above — there is no separate webhook-only credential namespace. Board/Admin members configure the endpoint URL, rotate the signing secret, send a synchronous test event, and inspect a 30-day delivery log, all under Verwaltung → API-Schlüssel. Full header grammar, event catalog, retry schedule and known limitations: docs/api/public-api-v1.adoc, section "Webhooks (outbound)".

Environment variables — OPTIONAL, off by default

WebhookConfig.load() is the only place these are read. LAPIS_WEBHOOKS_ENABLED=true gates delivery entirely: the outbox publisher, the retry poller, and every webhook-management write RPC (setWebhookUrl/rotateWebhookSecret/reactivateWebhookEndpoint/sendWebhookTestEvent) refuse to run while it is unset — reading and removing an existing endpoint stay available regardless, so cleanup never depends on the flag. Enabling it additionally REQUIRES LAPIS_SECRET_ENCRYPTION_KEY above (the same AES-256-GCM key Wave 3 Streaming’s destination secrets use) — missing, malformed, or wrong-length fails the server’s startup with an explicit error naming the variable, never its value.

Variable Purpose

LAPIS_WEBHOOKS_ENABLED

true to turn the whole subsystem on. Anything else (including unset) leaves it off — no failure, no partial state.

LAPIS_WEBHOOKS_ALLOW_INSECURE

Development only — accepts plain http:// endpoint URLs when set to true. Deliberately absent from both docker-compose.yml files rather than pinned to "false", so a stray copy-paste in .env cannot flip it on by accident. A WARN is logged at startup whenever it is set at all.

LAPIS_WEBHOOK_POLL_INTERVAL_SECONDS

Retry-queue poll cadence. Default 10, floored at 1 (a 0 or negative value would spin the poll loop with no pause).

LAPIS_WEBHOOK_RETENTION_DAYS

How long a DELIVERED/FAILED/ABANDONED delivery-log row survives. Default 30, floored at 1 (a 0 value would empty the log on the very next poller tick).

What doesn’t work yet (this wave)

Deliberately out of scope, see CHANGELOG.md: an "entity removed" event (reconciliation needs a periodic re-fetch of the relevant /api/v1 list), multi-instance-safe deactivation notification/retention cleanup (the poller assumes exactly one running server instance), and a grace-period dual-secret transition during signing-secret rotation.

Website Integration (V1.4.1a/V1.4.1b/V1.4.3.3)

A small (< 12 KB, no dependencies) embeddable script lets a partner website — a party’s or association’s own site, on a different origin — offer a member-login button, a "become a member" link, an anonymous donation form, and an event-registration form, without ever sharing a session cookie across that origin boundary. Login runs in a popup window on this server’s own origin; only a postMessage-delivered {ok, displayName} crosses back to the embedding page. Every widget host carries a working No-JS fallback link that the script only ever replaces, never removes first. Full contract (snippet, CSS custom properties, CORS/CSP, the popup+postMessage flow, known limitations): docs/api/embed-widgets.adoc.

Environment variables — OPTIONAL, off by default

EmbedConfig.load() is the only place these are read; the origin allowlist lives in the environment only, never in the database (a compromised ADMIN account cannot authorize a new embedding origin at runtime). An ADMIN-only, read-only screen (System → Website-Integration) shows what was loaded at startup.

Variable Purpose

LAPIS_EMBED_ENABLED

true to serve the /embed/v1///api/embed/v1/ routes at all. Anything else (including unset) leaves every one of those paths 404ing, as if this wave did not exist.

LAPIS_EMBED_ALLOWED_ORIGINS

Comma-separated exact scheme://host[:port] origins allowed to embed the widgets. Required and non-empty when LAPIS_EMBED_ENABLED=true — an invalid entry fails the server’s startup with an explicit error naming the entry, never a silent skip. www. and the bare domain are different origins.

LAPIS_EMBED_ALLOW_INSECURE

Development only — accepts plain http:// origins when set to true. Deliberately absent from both docker-compose.yml files rather than pinned to "false", same "O4" doctrine as LAPIS_WEBHOOKS_ALLOW_INSECURE above. A WARN is logged at startup whenever it is set at all.

Caution
The origins in LAPIS_EMBED_ALLOWED_ORIGINS are shipped inside the publicly downloadable lapis-widgets.js bundle — see docs/api/embed-widgets.adoc for why, and do not list an internal/staging origin expecting it to stay unlisted.

Anonymous donation widget (Welle V1.4.1b)

A third widget, data-lapis-widget="donate", on the same infrastructure: a one-field (amount only, 5–500 EUR) anonymous donation form, POST /api/embed/v1/donation/checkout (3 requests/hour/IP — the strictest limiter in this codebase), and two fixed Stripe return pages (GET /embed/v1/spende/danke//abgebrochen, never a session identifier in the URL). No new environment variable — it reuses LAPIS_EMBED_*/LAPIS_STRIPE_*/LAPIS_PSP_* verbatim. Full contract: docs/api/embed-widgets.adoc.

Event registration widget (Welle V1.4.3.3)

A fourth widget, data-lapis-widget="event", on the same infrastructure: POST /api/embed/v1/event/{slug}/registration reuses the exact same EventRegistrationSubmission/ EventPolicy logic as the pre-existing server-rendered /veranstaltung/{slug} form — the JSON route is a parallel path for the widget, not a replacement, and the form keeps working unchanged as the No-JS fallback. The response deliberately collapses the full registration-outcome type down to four visitor-facing outcomes (confirmed / waitlisted / payment required / not available) rather than mirroring the backend sum type one-to-one — a byte-identical response for "already registered" and a genuine new registration specifically avoids opening a membership-enumeration oracle over guest email addresses. Shares its rate-limit budget with the form route (no doubled ceiling). No new environment variable. Full contract: docs/api/embed-widgets.adoc.

What doesn’t work yet (this wave)

Deliberately still absent: an automatic "already signed in" indicator on page load without a click (deliberately absent — that would be a cross-site tracking channel), and donor recognition/aggregation across multiple anonymous donations (DSGVO data minimization — every widget donation is a fresh, unlinked external_donor row by design).

Article Module (V1.4.34/V1.4.36)

An editorial article/news workflow, distinct from the Soziales Netzwerk’s immediate-publish posts (V1.1): a Soziales Netzwerk post goes live instantly and is moderated only afterwards — inward- facing community communication. The article module is the opposite shape — outward-facing, editorial content management with an approval gate in front of publication, intended for a party’s/association’s own public "Aktuelles" (news) section. A member writes an article (title, excerpt, Markdown body, optional cover image) as a draft, submits it for review, and only a BOARD/ADMIN reviewer’s explicit approval makes it publicly visible at /aktuelles/{slug}.

Workflow

DRAFT/REJECTED → submit → SUBMITTED → board approves → PUBLISHED, or board rejects → REJECTED (with an optional reason). A published article can be corrected only by a BOARD/ADMIN reviewer unpublishing it first (mandatory reason) — never edited directly by its own author — which returns it to REJECTED so the author can revise and resubmit through the normal path; its slug and publication date are kept as an audit trail without restoring public visibility. The four-eyes principle applies to every board decision (approve/reject/unpublish) without exception — a reviewer can never act on their own article, not even an ADMIN. Full state machine, four-eyes enforcement, slug/cover-image handling, and the shared Markdown-rendering pipeline: docs/architecture/article.adoc.

An approved article’s cover image is served from a public, unauthenticated, versioned URL (/aktuelles/{slug}/bild); before approval (or after an unpublish), the same image is reachable only through an authenticated route restricted to the author and BOARD/ADMIN reviewers (/api/articles/{id}/cover). A read-only partner-website embed widget, GET /api/embed/v1/articles, lists the newest published articles (opt-in behind LAPIS_EMBED_ENABLED, same CORS origin allowlist as the other embed endpoints — see "Website Integration" above and docs/api/embed-widgets.adoc). No new environment variable is needed beyond the existing LAPIS_EMBED_* family.

What doesn’t work yet (this wave)

The public article detail page (/aktuelles/{slug}) carries no header/navigation chrome of its own — no link back to the site’s public navigation from there. No other gaps known at this time.

Interessenten-/Sympathisanten-CRM (V1.4.2)

A standalone contact entity (crm_contact/crm_interaction) for people who are not members — Interessenten, Sympathisanten, Förderer, ehemalige Mitglieder, press contacts — deliberately kept separate from ExternalDonor (stays §25-PartG-tax-focused) and MemberStatus.FRIEND (stays scoped to conference/LTR access). Optionally linkable to either. Every contact carries a mandatory Art. 6 DSGVO lawful basis (CONSENT/LEGITIMATE_INTEREST/CONTRACT); choosing CONSENT requires a recorded source and timestamp. An append-only interaction log (CALL/MEETING/EMAIL/LETTER/ EVENT/NOTE, free-text summary, author + timestamp) is the "closer to a real CRM" scope this wave was explicitly asked for, rather than a single free-text note field. Board/Admin members manage contacts under Verwaltung → Kontakte & Interessenten; only Admin can erase one (Art. 17, a real DELETE, name-typing confirmation instead of a single click — the only place in this codebase that truly deletes a row rather than anonymizing it).

mayReceiveEmail is computed server-side only (consent given, not withdrawn) — there is deliberately no CRM mailing path in this wave; this flag is the one place a future one must consult. A withdrawal (Art. 7(3)) is reversible by a genuinely later, new consent, but a mere correction of an existing timestamp (Art. 16) or a backdated older consent form can never silently undo a standing withdrawal — the chronology is checked, not just "did the value change".

DSGVO framework extended to non-member subjects

Every other PersonalDataContributor in this codebase keyed erasure/export by a member’s Uuid alone — ExternalDonor (V0.5.1) is a known, deliberately deferred gap because of that (see its own KDoc in 10-accounting.kuml.kts). Rather than repeat that gap for a wave whose whole point is carrying MORE personal data about non-members (an interaction history, not just a donor classification), the interface now takes a DataSubject (Member or CrmContact), and PersonalDataCoverageTest’s `information_schema walk covers both roots. external_donor remains outside it — the pre-existing gap is unchanged — but is now surfaced by a named, tested constant (PersonalDataRegistry.knownUncoveredSubjectRoots) instead of only a comment.

What doesn’t work yet (this wave)

Deliberately out of scope, see CHANGELOG.md: any CRM mailing/newsletter path (mayReceiveEmail is computed but nothing sends yet), self-service access/erasure for the contact themselves (both are admin-only), tagging/segmentation beyond the fixed CrmContactType enum, and `external_donor’s own long-deferred DSGVO gap (unchanged by this wave, now merely visible instead of silent).

Bank Statement Import (V1.4.5.1 / V1.4.5.1.1)

CSV (Sparkassen-CAMT export, plus a broad generic fallback) and MT940 bank-statement upload, matched against open contributions by four rules (a printed LC-XXXXXX reference in the transfer purpose, an IBAN match against an active SEPA mandate, a first+last name token match, or no match at all) — only the reference rule ever books automatically. A /bank-import operator screen (TREASURER/BOARD/ADMIN read, TREASURER/ADMIN upload/assign/ignore) lets a treasurer upload a statement, work through the resulting lines (filtered by status), assign a line to a contribution or a donation, or ignore it with a mandatory justification. See docs/architecture/bank-statement-import.adoc for the full design (data shape, the GF(2⁵)-checksum payment-reference grammar, the matching pipeline, the two-phase booking transaction that keeps `ContributionPostingBridge’s single-row-lock-per-transaction contract, the structured JSON rejection contract, and the screen’s own UI/UX decisions).

What doesn’t work yet (this wave)

Deliberately out of scope, see CHANGELOG.md: DATEV/lexoffice/sevDesk export (see the next section — the DATEV half is closed as of V1.4.5.2), camt.052/053/054 XML (FinTS/HBCI live retrieval, closed as of V1.4.14 Wave 2, is therefore limited to banks answering HKKAZ/MT940, not HKCAZ/camt-only banks — see docs/architecture/bank-account.adoc), splitting one bank line across multiple contributions, automatic donation posting at import time (§25 PartG: a donor category cannot be derived from a bank line alone — manual donation assignment through the screen IS possible, with the operator choosing the category), and header signatures for the VR_BANK/DKB/POSTBANK/COMDIRECT CSV dialects (no real export sample was available to verify one against). Also out of scope, and explicitly NOT part of this wave’s i18n coverage: the pre-existing catalog drift affecting other, unrelated screens (36 genuinely empty msgstr entries per language catalog, and roughly 433 code literals with no catalog entry at all, stand 2026-09-10) — see `BankStatementI18nCatalogTest.kt’s own KDoc.

Also NOT covered by this wave’s i18n, and NOT pre-existing drift — a genuine gap in the new screen itself, honestly named after a review finding (Round 2, 2026-09-10) rather than silently left in place: BankStatementLineDto.matchExplanation, the per-line reason a status was assigned (e.g. "IBAN-Abgleich: <member> (<tier>, <period>)"), stays untranslated German server prose, because it embeds per-line dynamic data (member names, amounts, dates, a payment reference code) computed in BankStatementMatcher and PERSISTED as a plain string column — unlike the fixed, parameterless strings BankStatementRejectionCode/BankStatementImportWarningCode translate, a structured replacement for matchExplanation needs its own DB migration (a code plus its parameters, not a plain string) and is deliberately left to a follow-up wave rather than rushed into a review-fix commit. BankStatementImportResultDto.warnings — the three fixed, parameterless warning sentences the import route can produce — IS fully code-driven now (BankStatementImportWarningCode, same round of fixes), and no longer exposes the internal LAPIS_SECRET_ENCRYPTION_KEY environment-variable name in the UI, which one of its three variants used to do.

DATEV Export (V1.4.5.2)

Read-only export of a period’s POSTED journal entries as a DATEV-EXTF-Buchungsstapel CSV file (Windows-1252, CRLF, the exact format DATEV’s own accounting products import directly) — GET /api/accounting/datev/buchungsstapel.csv?from=…​&to=…​ (TREASURER/ADMIN) plus a BOARD-readable dry-run preview (IAccountingService.previewDatevExport, TREASURER/BOARD/ADMIN) that reports the same row/blocker computation the file route uses, so the preview can never promise an export the file route would in fact refuse. See docs/architecture/datev-export.adoc for the full field layout, the Sammelbuchung row-derivation rules, the CP1252 character-sanitization policy, and every alles-oder-nichts export blocker.

What doesn’t work yet (this wave)

A live DATEV Connect API integration, Debitoren-/Kreditorenkonten in the DATEV file (accounts-receivable/-payable sub-ledgers), USt-Schlüssel (VAT codes), Kostenstellenübergabe (cost-center hand-off into DATEV’s own KOST1/KOST2 fields), an abweichendes Wirtschaftsjahr (fiscal year other than the calendar year), and re-export/duplicate-marking are all deliberately out of scope — see `docs/architecture/datev-export.adoc’s own "What doesn’t work yet" section for why each one specifically does not exist yet.

Contribution relief (deferral, exemption, reduction) (V1.4.10)

Members can request one of three contribution relief measures: deferral (a new due date for one specific contribution line), exemption (temporarily or permanently excluded from future contribution generation), or reduction (moved to a cheaper membership tier). Requests go through a full state machine (REQUESTED → APPROVED/REJECTED/EXECUTED/WITHDRAWN, ContributionReliefService/ContributionReliefExecution) with a BOARD/ADMIN decision step (Vier-Augen-Prinzip: no self-approval, a decision note is mandatory to approve) that decides and executes in one transaction — a failed under-lock recheck (e.g. the target contribution was paid between request and decision) leaves the request APPROVED with a recorded executionError instead of losing the approval, retryable via a dedicated RPC method. ContributionService.generateContributionsForPeriod skips a member exempt for the full period (ContributionExemptionRules.isExemptForPeriod). The (potentially Art. 9 DSGVO special-category) free-text reason is field-redacted for every viewer except BOARD/ADMIN/the subject, and automatically nulled 12 months after the request reaches a terminal state (ContributionReliefRedaction), independent of any DSGVO erasure request. See `docs/architecture/dsgvo.adoc’s own "Contribution relief" section for the full erasure/export contract.

Self-service UI and board queue (V1.4.10.1)

Members request a deferral directly from their own contribution table in the "Beitragsübersicht" screen (ContributionsScreen.kt) — a "Stundung beantragen" button appears next to every DEFERRABLE (OPEN/OVERDUE) row, hidden rather than merely disabled once an open DEFERRAL request already blocks a new one (activeBlockingDeferralRequest), with a single notice above the table and a badge on the specific blocked row instead of graying out every button on the page. The same screen carries a second self-service panel for exemption/reduction requests plus the member’s own request list (step tracker, effect description, withdraw while REQUESTED). BOARD/ADMIN review and decide requests in a dedicated queue at /contribution-relief — note the narrower gate: BOARD/ADMIN only, not TREASURER, unlike almost every other Finance-group route, mirroring IContributionReliefService.listReliefRequests’s own role check. The queue enforces the Vier-Augen-Prinzip client-side too (`reliefDecisionBlockedBySelf hides the decision panel on a board member’s own request before a click can ever reach the server’s ForbiddenException), and surfaces the APPROVED-with-executionError retry path as a distinct "Ausführung wiederholen"/"Ablehnen" pair — the only card state with two buttons instead of a plain approve/reject choice, since EXECUTED never carries an executionError (chk_crr_execution_error_state). A sidebar badge on the Finance group’s new entry shows the number of REQUESTED requests awaiting decision (capped display at "200+", mirroring the RPC’s own page-size cap), fetched only for BOARD/ADMIN and failing silently (no toast) if the call errors, so a TREASURER building the same sidebar never sees a stray 403.

What doesn’t work yet (this wave)

No deferral for IN_DUNNING/RETURNED contributions (would additionally need a dunning-level reset, deliberately deferred, not a design decision); no anteilige (partial-period) exemption; a granted exemption does not retroactively waive contribution lines already generated before the exemption took effect (the board’s existing waive path still applies to those, manually); exemption data is not exposed on MemberDto; no reminder/automation for review_due_on (Wiedervorlage) — it is stored and filterable (listReliefRequests(reviewDueOnly = true) returns requests whose review date has actually arrived, i.e. review_due_on ⇐ today, not merely requests that have one set), but nothing acts on it once it passes; reduction is not applicable to a member with no membership tier of their own (a family dependent billed through the family’s payer, see MemberFamilyService.addFamilyMember) and is rejected with 400 rather than billing them directly in addition to the family invoice; an unbounded (open-ended) exemption cannot be ended or shortened once executed — there is no RPC path that later sets or clears member.contribution_exempt_until on an already-EXECUTED request (a fresh EXEMPTION request can only move it forward, never back), so a board wanting to end someone’s permanent exemption has no self-service way to do so yet. Its potentially Art. 9 DSGVO free-text reason is still redacted 12 months after execution regardless (the redaction anchor falls back to executed_at when exemptionUntil is null), even though the exemption’s own effect keeps running.

Travel expense reimbursement (V1.4.11)

Any authenticated member can file a self-service travel expense report for a board/functionary trip — "board and functionaries" describes the real usage, not a technical restriction. A report carries three kinds of lines: mileage (kilometers × an operator-configured rate), per diem (days × an operator-configured daily rate), and receipted expenses (a fixed amount plus at least one uploaded receipt). A line’s kind is fixed once created — no dropdown, three separate "add line" actions instead. A draft can be edited freely; submitReport (DRAFT → REQUESTED) validates every line, requires a rate to be configured for every kind actually used, and freezes each mileage/per-diem line’s rate into rate_snapshot — a later rate change never retroactively changes an already-submitted report. BOARD/ADMIN decide in a queue at /travel-expense-approvals (not TREASURER, same narrower gate /contribution-relief already established) with a mandatory decision note on both approval and rejection, and a four-eyes rule that closes a gap the contribution-relief queue leaves open: neither the report’s subject nor whoever submitted it "on behalf of" them may decide it (decideReport’s own `ForbiddenException covers both subjectMemberId and requestedBy). Approval books an EXPENSE-side debit per line against an operator-configured travelExpenseAccountId and one CREDIT for the total against the payment bank account (TravelExpensePostingBridge) — the first "money out" bridge in this codebase — in the same transaction as the decision; a failed booking (unconfigured/inactive/ wrong-type account, or a transient cash-register shortfall) leaves the report APPROVED with a recorded executionError, retryable via a dedicated RPC method exactly like the contribution- relief precedent. Receipt files travel over dedicated HTTP routes (never Kilua RPC), with the declared Content-Type discarded entirely in favor of a server-side magic-byte sniff (application/pdf/image/jpeg/image/png only, deliberately no image/svg+xml), a 10 MiB streaming cap, and an IDOR gate on download (subject/requester/BOARD/ADMIN only). See docs/architecture/travel-expenses.adoc for the full state machine, booking shape, and security model.

What doesn’t work yet (this wave)

Mileage and per-diem rates are operator-configured values with no automatic adjustment to changes in the law — NULL means "not configured", and the affected line kind is disabled in the form until an ADMIN sets it; there is no rate history, only the currently configured value is editable, and a submitted report is immune to a later change because its rate is frozen. No Verpflegungsmehraufwand reduction per the full German travel-expense rules (arrival/departure-day special cases, cuts for meals provided by the organization) — a flat per-diem × days calculation is a nachweis aid for the board, not an automated compliance decision, the same framing the Mittelverwendungsrechnung caveat already uses elsewhere in this document. No payout — the end state reads "Zur Auszahlung gebucht" ("Posted for payout") and means exactly that: a journal entry. There is no payment run in this wave; the treasury arranges the transfer separately. No line-by-line partial approval; rejection with a mandatory reason plus "copy as draft" is the intended correction path (receipts are not copied — new line ids — and must be re-uploaded). The Übungsleiter-/Ehrenamtspauschale gap this section used to name here is closed by V1.4.12 as its own, separate feature and bridge — see "Volunteer/instructor allowance" below; no shared abstraction was forced between the two domains. The Gemeinnützigkeit sphere every posting uses is hardcoded to IDEELLER_BEREICH, not configurable (a board trip is practically always ideeller Bereich under Rams' "one account, one field" logic applied to the sphere; revisit if a Zweckbetrieb-funded trip ever needs this). Receipt files are not removed by a DSGVO erasure once a report has left DRAFT — they are the accounting voucher (§147 AO); unlike the contribution-relief wave’s Art. 9 free text, there is no automatic redaction sweep here, because there is no Art. 9 free text to redact and §147 AO already demands 10 years' retention regardless. Inline receipt preview was deliberately not built (attachment-only download, nosniff, no image/svg+xml in the allowlist).

Volunteer/instructor allowance (V1.4.12)

A board/functionary requests a tax-privileged payment for a member’s volunteer activity, in one of two independently-capped categories: INSTRUCTOR (§3 Nr. 26 EStG Übungsleiterpauschale) or HONORARY (§3 Nr. 26a EStG Ehrenamtspauschale) — fixed once a payment is created, no dropdown on an existing one. The annual cap per category/calendar-year is checked at decision time, not at submission time (a second payment for the same person could have been booked in between), and re-verified again immediately before the actual booking against a snapshot frozen at decision time (allowance_total_changed_since_decision if they no longer match). Booking requires two independent preconditions: a self-declaration from the RECEIVING person for that member/category/calendar-year ("Raskin’s Gate" — missing one leaves the payment APPROVED with a retryable self_declaration_missing, not a thrown exception), and, only when the cap is actually exceeded, a board acknowledgment of a versioned, hashed disclaimer (VolunteerAllowanceCapDisclaimer) that a client cannot send pre-emptively (sending one when the cap is not exceeded is itself rejected, so the gate cannot be neutralized). BOARD/ADMIN decide in a queue at /volunteer-allowance-approvals (not TREASURER), with the same four-eyes rule TravelExpenseService established applied to BOTH booking-producing methods (decidePayment AND retryPosting) — the V1.4.11 finding that a second booking-producing method needs the identical guard, not just the first one. Approval books a two-posting EXPENSE-side debit/bank-side credit against an operator-configured volunteerAllowanceAccountId (VolunteerAllowancePostingBridge) — the second "money out" bridge in this codebase, its own dedicated bridge rather than a shared abstraction with travel expenses. See docs/architecture/volunteer-allowance.adoc for the full state machine, cap arithmetic, and booking shape.

What doesn’t work yet (this wave)

The §3 Nr. 26/26a EStG cap amounts are hardcoded constants, not configurable per organization and not year-indexed — a future statutory change requires a code edit, same posture the travel-expense rates section above documents its own caveat with. No automated Lohnsteuer withholding for the portion exceeding the cap — the split survives only in the journal entry’s description and the payment row’s own snapshot columns; actually withholding and remitting Lohnsteuer remains the organization’s own responsibility, as the board acknowledgment text states explicitly. No cross-organization cap awareness — the §3 Nr. 26/26a EStG cap applies per person per calendar year across all organizations, but Lapis Cloud only ever knows the sum booked in the current one, so any "remaining" figure shown is a maximum, never a guarantee (and the UI is required to never call it a "Restbetrag" for exactly that reason). No payout — same framing as travel expenses: the end state means a journal entry exists, not that a bank transfer has run. A single EXPENSE account serves both categories — no per-category account split.

AI assistance (optional, default off) (V1.6.1)

Lapis Cloud can optionally answer a member’s question about the statutes ("Fragen zur Satzung"): a short summary plus one to three verified citations from documents the board released to a knowledge base. Off by default. Full architecture, boundary rules and decisions: docs/architecture/ai-assistant.adoc.

Switched on only when LAPIS_AI_ENABLED is exactly true and a complete provider profile is configured; anything else means "off" (the server still starts, the offending variable names are logged). When off, no provider client is constructed and the RPC service is not registered. The deploy compose files deliberately do not forward any LAPIS_AI_* variable — an operator must add them to the service’s environment: block on purpose, and needs a processing agreement with the provider first (deploy/example/README.adoc, section "AI assistance").

Environment variables — OPTIONAL, off by default

Variable Meaning

LAPIS_AI_ENABLED

Must be exactly true (any case) to switch the feature on.

LAPIS_AI_PROVIDER

anthropic or openai_compatible (OpenAI, Mistral, OpenRouter, Ollama’s compatible endpoint).

LAPIS_AI_MODEL

Model name, passed through to the provider.

LAPIS_AI_API_KEY

Provider API key. Never logged, never part of any exception message.

LAPIS_AI_BASE_URL

Optional for anthropic (default https://api.anthropic.com), required for openai_compatible. HTTPS only.

LAPIS_AI_ALLOW_PLAINTEXT_BASE_URL

true allows plain HTTP for loopback only (a local Ollama). Default off.

LAPIS_AI_TOP_K, LAPIS_AI_MAX_OUTPUT_TOKENS, LAPIS_AI_REQUEST_TIMEOUT_MS, LAPIS_AI_MAX_RESPONSE_BYTES

Tuning; invalid values fall back to the default and are named in the startup log.

LAPIS_AI_RATE_PER_MEMBER_HOUR, LAPIS_AI_RATE_PER_SERVER_DAY

Question limits (default 10 per member and hour, 200 per server and day).

LAPIS_AI_MEMBER_OPT_IN_DEFAULT

Removed (security audit): no longer read. Consent is always the member’s own, recorded action; there is no server-wide pre-consent.

Own minimal client, not Koog

The technology stack lists Koog, but this wave builds an own minimal LlmClient (two thin Ktor clients) instead: Koog 1.2.0 transitively pulls ktor-client-logging (against this repository’s "never a logging plugin on an API-key client" line), a second Ktor server engine, a Ktor 3.3.3 line against this repository’s 3.5.2, and Jackson as a second serialization stack — for one retrieval step and one model call. Rationale and the condition for re-evaluating it: docs/architecture/ai-assistant.adoc.

What doesn’t work yet (this wave)

One feature only (statute Q&A); no agentic tool use, no streaming, no conversation memory; only PUBLIC_MEMBERS documents can be released; PostgreSQL full-text retrieval (no embeddings); no OCR for scanned PDFs (reported as "format not readable"); the PostgreSQL full-text path has no CI coverage beyond its SQL shape and must be verified once manually against the real instance (PostgresFullTextRetrieverLiveTest).

Member map ("Vorstands-Karte", V1.9.5-V1.9.9)

A BOARD/ADMIN-only aggregate view of where the organization’s members live, at postal-code granularity — no member id, name, street, city or date of any kind crosses the wire, only postal-code-bucketed counts (five aggregate categories: mapped, unresolvable-but-German, missing postal code, foreign, and the raw total). V1.9.5 built the server-side aggregation (BoardMemberMapService.getMemberMap) and the PMTiles basemap HTTP route; V1.9.6 added the lapis-client screen ("Mitgliederkarte" in the Verwaltung/Administration sidebar group, BOARD/ADMIN route guard) — a MapLibre/PMTiles vector map with weighted, clustered postal-code markers next to an always-visible table of the exact same numbers. Full architecture, the privacy boundary, the country/postal-code classification rules and the recorded Q1-Q12 decisions: docs/architecture/member-map.adoc.

Three fast-follow waves closed real board feedback on the initial map. V1.9.8 "Orientierung" added DOM-based Bundesland/Nachbarland labels (shown/hidden by zoom band, since the bundled basemap style has no glyph-serving route for a real MapLibre text layer) and a hover tooltip on points/clusters, and fixed a cluster click that never zoomed in (a Promise-vs-callback API mismatch against the pinned maplibre-gl version). V1.9.9 "Details & Suche" made country/state borders actually visible (dedicated high-contrast map tokens, replacing UI-divider tokens that were nearly invisible against the map background), added the 16 Landeshauptstadt (state capital) markers that had been missing, rendered small-locality labels once zoomed in by querying the already-bundled vector tiles directly (map.querySourceFeatures, still no glyph-serving route stood up), and added an in-map Ortssuche (place search) that flies to a searched place and shows member counts within 5/10/25 km — computed entirely client-side from data already on the board member’s screen, no second server round-trip and no new member data transmitted. See `docs/architecture/member-map.adoc’s Q8-Q12 decision log for the full reasoning behind each of these.

The basemap file itself (~190 MB, extracted from a public Protomaps build) is an optional, operator-provisioned deployment artifact, never part of this repository — see deploy/example/README.adoc, section "Member map (optional)", for how to provision it. The postal-code centroid lookup (place names + coordinates) ships bundled with lapis-server already (GeoNames, CC-BY-4.0).

Third-party data

SuperMailer (V1.9.7/V1.9.15)

HTML-authored mailing content and a real asynchronous send path for the newsletter/mailing-list feature, plus the data-model foundation for open-/click-tracking (built this wave, not yet consumed by any code path). This wave also fixed a pre-existing correctness bug: MailingService.sendMailingMessage previously never called any real transport at all — it wrote a SENT delivery-log row per active subscriber inside the same transaction that flipped a message to SENT, with no recipient-eligibility filtering. sendMailingMessage is now a bounded DRAFT → QUEUED transition handed off to a dedicated MailingDeliveryWorker (its own bounded queue, separate from the transactional-mail dispatcher so a mailing run can never starve password-reset/FRIEND-verification mail); LAPIS_MAILING_DELIVERY (default log) controls whether it genuinely calls MailTransport.send or runs the full sanitize/render/queue pipeline without the transport call, the same honest-non-delivery posture the rest of this codebase’s mail paths follow. Only active, non-anonymized subscribers get queued at all — previously every non-unsubscribed subscription row was mailed regardless of the member’s current status.

The compose screen gained a second, HTML-authoring RPC (createDraftMessageHtml) alongside the existing plain-text one, backed by an allowlist-based sanitizer (MailingHtmlSanitizer, jsoup) and a shared render path (MailingMailRenderer) that both the async send and the preview RPCs go through, so a preview can never drift from what actually gets sent.

V1.9.15 added the parts the original plan left open. The compose screen’s textarea became a contenteditable rich-text editor (no new dependency: paste is plain text only and the server sanitizer stays the authority). Tracking is strictly opt-in, per member and per list, default off, with a consent timestamp and a snapshot taken for every delivery at send time: a click URL /m/c/{token} answers a 302 to a target that is read only from the database, and an open pixel /m/o/{token}.gif counts opens; both use HMAC-signed tokens (LAPIS_MAILING_TRACKING_KEY, required when LAPIS_MAILING_DELIVERY=smtp), re-check the consent on every hit, never count HEAD requests (link scanners) and are rate limited. Statistics (mailingMessageStats) are aggregates only with a minimum cohort of five; raw events are erased after 180 days, and withdrawing consent erases what was already collected. See docs/architecture/mailing-newsletter.adoc.

Operator note: LAPIS_MAILING_DELIVERY, LAPIS_MAILING_SEND_DELAY_MS and LAPIS_MAILING_TRACKING_KEY must be forwarded in your compose file (the example now does); until then an instance stays in log mode and mailings never leave the server. Real sending needs LAPIS_MAILING_DELIVERY=smtp, LAPIS_MAILING_TRACKING_KEY and a complete LAPIS_SMTP_* configuration — the server refuses to start in smtp mode otherwise.

What doesn’t work yet

Click and open counting has not yet been exercised end to end with a real delivered mail. Already saved drafts are not editable (no update RPC). The 200-link limit counts all links, not distinct ones. The public 404/429 page of the click route is German only, like every public server page. A privacy-statement and footer notice for the counting is left to each organization.

Public member count toggle (V1.9.10)

Board can now decide, per organization setting, whether the number of active members appears on the public homepage (/) and the transparency page (/transparenz). The new showPublicMemberCount field on organization_settings defaults to TRUE — the only Boolean on that table that is an opt-out rather than an opt-in, so no existing organization experiences a behaviour change on upgrade. Hiding it is structural, not cosmetic: PublicTransparencyReader .loadStats does not even query the count when the flag is off, and the homepage’s own empty-state guard deliberately stops counting a hidden member count towards "is there anything to show" — so the mere presence or absence of the stats block cannot itself leak whether the count is hidden. Setting it is ADMIN-only (the generic settings-update RPC replaces the whole settings object, including IBAN/DATEV fields, so a BOARD write right here would have given BOARD those fields too); TREASURER/BOARD see the current state read-only. The change takes effect within a few minutes, riding the existing 30-second in-memory cache on both public routes.

Public icon navigation + overview pages (V1.9.11)

The shared header chrome across all seven unauthenticated public pages (/, /s, /transparenz, /impressum, /datenschutz, and the two new ones below) switched from text links to icon-only links with a pure-CSS hover/focus tooltip (aria-label-driven, so every link stays fully accessible with no silent icon-only trap). Two new, server-rendered overview pages, GET /aktuelles (published articles) and GET /veranstaltungen (upcoming public events), link out to the existing detail pages — previously the only public way to browse them was the (opt-in, CORS-gated) embed feeds or a bookmarked direct link. Both new tabs appear in the navigation only when there is actually something to show (PublicNavAvailabilityProvider, a 30-second process-wide cache over two lightweight existence checks) and never appear on an error page, so a broken/rate-limited request never triggers an extra database lookup.

What doesn’t work yet (this wave)

Neither the article detail page (/aktuelles/{slug}) nor the event detail page carries its own header/navigation chrome — there is still no link back to the site’s public navigation from a detail page itself. Tracked as a known limitation, open for a follow-up wave.

Mitfahrerzentrale (V1.9.12)

A carpooling bulletin board for members: any ACTIVE member can offer a ride (OFFER, 1-8 free seats) or ask for one (REQUEST) between two free-text places on a given date, up to 180 days out, capped at 10 future postings per member. Contacting a posting’s author routes through the existing direct-message mechanism rather than a bespoke messaging system — CarpoolPostingDto deliberately carries no author id the client could address directly; the server alone resolves the recipient from the posting. Building this exposed two real gaps in that pre-existing mechanism, both closed as part of this wave: direct messages had never had a client UI at all before (the "Kommunikation" screen was upgraded from a bare unread-counter to a real message list with an inline reply form), and sendDirectMessage had never validated for an empty body or a message to oneself. A posting drops out of the public feed the day after its departure date but is only hard- deleted seven days later (CarpoolRetentionPoller), so an author can still see and duplicate a just-expired trip of their own. Full design, the RPC surface, and the direct-message-reuse decision: docs/architecture/carpool.adoc.

What doesn’t work yet (this wave)

No seat reservation/booking/acceptance workflow — a posting is a bulletin-board entry, not a matched ride. No structured place data (no postal code, no map, no distance calculation) and no notification when a new posting matching a member’s interest appears.

Regional chapters ("Gliederungsverwaltung", Landesverbände) (V1.9.13/V1.9.14)

An optional, flat (single-level) set of named regional chapters an ADMIN maintains — members can be assigned to one, and a plain MEMBER can be granted a "Landesvorstand" (regional-chapter officer) access that narrows the member roster to their own chapter’s active, non-anonymized members only (name/e-mail/join date; role, contributions and family links stay hidden). V1.9.13 built the server (data model, IRegionalChapterService, the visibility boundary, the DSGVO contributor); V1.9.14 added the client — an ADMIN-only chapter-management screen, the officer self-service roster, and an optional chapter picker on registration/direct member creation/the member editor. No account-role change: a "Landesvorstand" stays a plain MEMBER account, every existing role gate in this codebase is unaffected. The optional "assignment required once a chapter exists" activation rule ships default OFF — an operator who wants chapters purely informal, without requiring every active member to have one, leaves it off. Full architecture, the role- design decision (a dedicated "officer grant" issued by ADMIN, deliberately not a fifth AccountRole value and not tied to a regional-congress election result — see docs/architecture/regional-chapters.adoc’s "Role architecture" section for the full Design-Team debate behind that choice) and the visibility boundary: `docs/architecture/regional-chapters.adoc.

What doesn’t work yet (this wave)

Only one hierarchy level — no district/local sub-chapters. No client-side signal tells a member before they submit a registration whether the activation flag is currently on (today it is only learned reactively, from the error a submit attempt triggers). A freshly granted/revoked officer only sees the change after their next session refresh, not live in an already-open browser tab. No behavioral server-side test yet covers query-parameter manipulation by a chapter-scoped caller attempting to widen their own visibility — the existing allowlist-scan/visibility tests are the current coverage. See `docs/architecture/regional-chapters.adoc’s "Deferred" section for the full list.

Membership tiers (V1.9.18)

A TREASURER/ADMIN screen (/membership-tiers) to create and edit tiers — name, description, amount, interval, payment term and "selectable for new assignments" — with the number of active members per tier. The server validates (name unique case-insensitively, amount with at most two decimals) and every change leaves an audit entry, because a tier’s amount decides what members are invoiced. A tier is closed, never deleted: closing stops new assignments only and keeps invoicing the members already on it. Changing an amount never rewrites an existing contribution. No tier exists on a fresh instance; the treasurer creates them. See docs/architecture/membership-tiers.adoc.

Member photo and public profiles (V1.9.19-V1.9.21)

Every active member can upload a voluntary photo (JPEG/PNG, cropped square, scaled, all metadata removed); board members and appointed (listed) politicians can additionally write a short introduction. Both are private until the person gives a versioned consent for publication, and withdrawing it takes effect immediately (the public URL answers 404). The public site gains /vorstand, /politiker and /landesverbaende with matching embed feeds (/api/embed/v1/board|politicians|chapters, behind LAPIS_EMBED_ENABLED and the origin allowlist): only explicitly allow-listed fields, never ids or e-mail addresses. Regional chapters get a crest — JPEG, PNG or a strictly sanitized SVG (element and attribute allowlist, the stored file is a fresh serialization) — and a description. Photos and crests are not part of the organization backup and must be uploaded again after a restore. See docs/architecture/member-photo.adoc, docs/architecture/public-profiles.adoc and docs/architecture/regional-chapters.adoc.

Democratic elections (V1.9.22/V1.9.23)

The /elections screen drives the whole election lifecycle that IElectionService has offered since V0.2.4: opening an election from a scheduled motion (yes/no, single or multiple choice; secret or open; an exact required majority such as two thirds), the election committee (at least three members, never members of the target executive board), candidacies, the voting booth (review before casting, exactly one request however often the button is clicked), a one-time receipt for secret ballots, the four-eyes count approval (only real committee members, no admin bypass), the result and a receipt check. Since V1.9.46 the single ballots of a secret election are never delivered, in any status and to any role; open-ballot elections keep their named list. V1.9.23 hardened the server: the four ways to decide a motion exclude each other (MotionDecisionLock), a tally requires the motion to still be scheduled, and the ballot of a secret election carries no time and no order. See docs/architecture/elections-ui.adoc and docs/architecture/elections-integrity.adoc.

What doesn’t work yet

A real end-to-end election run with several accounts on Staging is still outstanding. The concurrency tests run on H2 and, since V1.9.37, also on PostgreSQL (the Postgres test lane). List and ranked-choice elections are rejected by the server. Who approved a tally is visible only as a count. castVoteBallot and castResistanceBallot have their own races against closing. Members cannot recount a secret election themselves (a deliberate consequence of V1.9.46). The vote counts of a small secret election are delivered without a minimum participation, so with one ballot or a unanimous result the votes are known; the receipt check tells the holder of a receipt code the chosen option (see docs/architecture/elections-integrity.adoc).

Voting in the conference room (V1.9.24-V1.9.27)

The video-conference room gains an "Abstimmen" panel. IConferenceService.getRoomVotingState(roomId) tells a participant which elections and meritocratic votes of the room’s Sitzung are open or were decided within the last 12 hours (at most 20), with only the caller’s own eligibility and status — never counters, choices, receipts, amounts or other members' ids. The Sitzung comes from the room on the server, never from the client. A payload-free data-channel signal (lapis-vote-nudge) makes the other clients ask the server again after open, close and tally; besides that the panel polls. The real voting booth is embedded in the panel; election board members and BOARD/ADMIN can open prepared elections, close and count from the room; approving the count stays with named election board members (no BOARD/ADMIN bypass). While a secret election is open, the cast button stays locked as long as a stream of the Sitzung is still being paused (the server checks the same). For a meritocratic vote, a first bid, opening a Yes/No vote and closing it work from the room; a changed bid, custom options and aborting stay on the motion page. See docs/architecture/conference-voting.adoc.

What doesn’t work yet

The staging test (docs/architecture/conference-voting-staging-test.adoc) was written but not run, so the nudge between real clients, the stream pause before the first ballot and a hanging pause are not verified in a real LiveKit room. Recordings are not paused for a secret ballot, and a room without a Sitzung never has its stream paused. Vote stakes stay bound after a vote closes — there is no release path yet. The committee leadership without an election-board seat cannot open, close or count (a server rule). Systemic consensus joined the room panel in V1.9.32 (see the next section).

Systemic consensus web client (V1.9.28)

The "Konsensieren" screen (/consensus, /consensus/:id) drives ISystemicConsensusService, which had no client before: a list with status filter, a detail view with the phase actions (freeze options, close rating, evaluate, revote, abort), adding and removing options (the status-quo option is permanent and, since V1.9.39, listed first with the plaque P; the real options carry the numbers 1..n and may carry a rationale), and opening a consensus from a scheduled motion — anonymous or open, advisory or binding. The resistance booth rates every option from 0 to 10, shows a review step and submits once; an anonymous consensus shows a receipt once, which can be checked later. The result ranks the options by mean resistance and states the group conflict in words. Four additive read RPCs back the screen; no migration. Since V1.9.32 the consensus is also part of the conference room panel (card, compact booth, operator steps). Since V1.9.42 an anonymous consensus with fewer than five ratings in the current round shows no figures, only the verdict in words, and since V1.9.44 its single ballots are never delivered. See docs/architecture/consensus-ui.adoc.

What doesn’t work yet

The staging test (docs/architecture/consensus-staging-test.adoc) is written, not run. A binding consensus has exactly one round; results of earlier rounds are not retrievable after a revote; an anonymous consensus discloses figures from five ratings on, with the residual risks named in consensus-ui.adoc ("Minimum participation"). The room shows no results distribution.

Opinion polls (V1.9.30/V1.9.31)

Non-binding opinion polls ("Umfragen", /polls): a question, an optional description, 2..10 options and an optional deadline. ADMIN/BOARD or a recording seat in an active committee may create one; every ACTIVE member may answer once. A poll is never a resolution, is bound to no motion or meeting and moves no LTR: the responder’s free balance is only read as a weight snapshot. Answers are anonymous by table separation (who answered and what was answered share no key, no time is stored). Results appear only after the close — by head count and LTR-weighted side by side, in whole percents. The head count needs at least 5 answers; the weighted result additionally needs at least 5 answers with weight above zero and at least 3 weighted answers for every option that has any, otherwise it is withheld with a reason. Migration V65 adds the four poll tables and the audit entity type POLL; because V1__baseline.sql was also widened in place, run flywayRepair (after a backup, from the v0.27.0 checkout after the code sync and before the build/restart) before deploying on an existing instance. See docs/architecture/polls.adoc.

What doesn’t work yet

The staging test (docs/architecture/polls-staging-test.adoc) was not executed. Whoever can read the database or its backups sees option and weight per answer and could try to match weights against the LTR ledger — this is a table separation, not cryptography. The concurrency tests run on H2 and, since V1.9.37, on PostgreSQL as well. There is no participation rate. Since V1.9.41 a poll can also be a consensus poll (SK_DECISION with the passive option "Keine Änderung", or SK_PRIORITY as a ranking), rated 0..10 per option without any LTR; below five answers only the response count is shown. Since V1.9.38 the deadline is a wall-clock in the organization’s time zone.

Documentation Convention

Consistent across all Lapis and kUML projects (see CLAUDE.md):

  • All documentation in Asciidoctor (.adoc) — no README.md

  • All diagrams in kUML, no PlantUML/Mermaid/draw.io/ASCII art

  • Diagrams embedded via the [kuml] macro of the kuml-asciidoc extension (Maven Central) — source inline in the .adoc document is the single source of truth, never replaced by a pre-rendered image. Exception: GitHub’s built-in .adoc viewer does not load the extension, so it cannot execute [kuml] blocks — for diagrams meant to be visible there (like this README’s own), commit a companion image::docs/images/<name>.svg[…​] reference immediately above the [kuml] block, purely as a GitHub-preview fallback, and re-render it by hand whenever the model changes (see the note above the first diagram below for the exact command). Nothing enforces that the two stay in sync — this is a deliberate, manual trade-off, not an automated build step.

[kuml, filename-without-extension, svg]

<kUML source>


Branch and Development Workflow

  • The default branch is always master, never main

  • Feature branches stay local and are never pushed

  • Releases happen via a local squash-merge onto master, then push

  • Never --force push without an explicit instruction

Details: CLAUDE.md in this repository.

License

Apache License 2.0 — see LICENSE.

About

Federated membership-management platform for associations and political parties (Kotlin/Ktor/KVision) — standard admin (members, accounting, mail-merge, compliance) plus a meritocratic governance layer, sovereign per instance

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages