Standard membership management, libertarian-extended.
Full association and party administration as the foundation, meritocratic governance as the differentiator.
- About Lapis Cloud
- Related Projects
- Technology Stack
- Status
- Videokonferenzen (V1.0 Wave 1)
- Payment service providers (Stripe + PayPal)
- Public REST API (V1.3.1)
- Outbound Webhooks (V1.3.2)
- Website Integration (V1.4.1a/V1.4.1b/V1.4.3.3)
- Article Module (V1.4.34/V1.4.36)
- Interessenten-/Sympathisanten-CRM (V1.4.2)
- Bank Statement Import (V1.4.5.1 / V1.4.5.1.1)
- DATEV Export (V1.4.5.2)
- Contribution relief (deferral, exemption, reduction) (V1.4.10)
- Travel expense reimbursement (V1.4.11)
- Volunteer/instructor allowance (V1.4.12)
- AI assistance (optional, default off) (V1.6.1)
- Member map ("Vorstands-Karte", V1.9.5-V1.9.9)
- SuperMailer (V1.9.7/V1.9.15)
- Public member count toggle (V1.9.10)
- Public icon navigation + overview pages (V1.9.11)
- Mitfahrerzentrale (V1.9.12)
- Regional chapters ("Gliederungsverwaltung", Landesverbände) (V1.9.13/V1.9.14)
- Membership tiers (V1.9.18)
- Member photo and public profiles (V1.9.19-V1.9.21)
- Democratic elections (V1.9.22/V1.9.23)
- Voting in the conference room (V1.9.24-V1.9.27)
- Systemic consensus web client (V1.9.28)
- Opinion polls (V1.9.30/V1.9.31)
- Documentation Convention
- Branch and Development Workflow
- License
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:
-
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.
-
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 owndeploy/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 indeploy/example/README.adocfor the split.
-
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)
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=Strictcookies), replacing theX-Member-Idheader 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 namespacedlapis: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 realMember(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].
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.
-
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);
SmtpPasswordResetMailersends a real message via Jakarta Mail/Eclipse Angus wheneverLAPIS_SMTP_*is configured (deploy/example/README.adoc"E-Mail-Versand (SMTP, optional)"), and falls back to the same honestly-disclosedNoOpMailTransportlog-only stubMailingService.sendMailingMessagehas 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 whenLAPIS_MAILING_DELIVERY=smtpis 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 correctLAPIS_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 noPUBLIC_MEMBERSdocument access. Thefriend_email_verification_tokenmechanics, the real mail (SmtpFriendVerificationMailer), and the#/verify-email?token=…client deep link (renderVerifyEmailScreen) all exist end to end now;LAPIS_FRIEND_REQUIRE_EMAIL_VERIFICATION(defaultfalse) 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, tagv0.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) remainsACTIVE-only. SeeCHANGELOG.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.
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.
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.
|
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.
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).
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:
-
organization_settings.payment_gateway_enabled— an ADMIN acknowledges a versioned legal-risk disclaimer (PaymentGatewayComplianceDisclaimer) viaenablePaymentGateway/disablePaymentGateway, same auditable acknowledgment mechanism as the auction opt-in above. -
The CURRENT disclaimer version was acknowledged — same "stale acknowledgment blocks writes" posture
SepaService.requireSepaUsablealready establishes for SEPA; a newly published disclaimer version blocks writes again until re-acknowledged. -
Deployment secrets present for the CONFIGURED provider — for
STRIPE:LAPIS_STRIPE_SECRET_KEY/LAPIS_STRIPE_WEBHOOK_SIGNING_SECRETboth set and well-formed (PspConfigState.Configured); forPAYPAL:LAPIS_PAYPAL_CLIENT_ID/LAPIS_PAYPAL_CLIENT_SECRET/LAPIS_PAYPAL_WEBHOOK_IDall set and well-formed (PaypalConfigState.Configured) — ANDorganization_settings.payment_gateway_providermatches whichever provider is actually configured (MANUALis the only literalenablePaymentGatewaystill 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.
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.
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.
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.
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).
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.
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.
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)".
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 |
|---|---|
|
|
|
Development only — accepts plain |
|
Retry-queue poll cadence. Default 10, floored at 1 (a |
|
How long a |
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.
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.
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 |
|---|---|
|
|
|
Comma-separated exact |
|
Development only — accepts plain |
|
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.
|
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.
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.
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).
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}.
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.
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".
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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").
| Variable | Meaning |
|---|---|
|
Must be exactly |
|
|
|
Model name, passed through to the provider. |
|
Provider API key. Never logged, never part of any exception message. |
|
Optional for |
|
|
|
Tuning; invalid values fall back to the default and are named in the startup log. |
|
Question limits (default 10 per member and hour, 200 per server and day). |
|
Removed (security audit): no longer read. Consent is always the member’s own, recorded action; there is no server-wide pre-consent. |
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.
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).
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).
-
Postal code data (c) GeoNames.org, CC-BY-4.0 — see
lapis-server/src/main/resources/geodata/LICENSE-GeoNames.txt. -
Basemap tiles (c) OpenStreetMap contributors, ODbL (via a Protomaps build, BSD-3-Clause build tooling).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
Consistent across all Lapis and kUML projects (see CLAUDE.md):
-
All documentation in Asciidoctor (
.adoc) — noREADME.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.adocdocument is the single source of truth, never replaced by a pre-rendered image. Exception: GitHub’s built-in.adocviewer 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 companionimage::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>
-
The default branch is always
master, nevermain -
Feature branches stay local and are never pushed
-
Releases happen via a local squash-merge onto
master, then push -
Never
--forcepush without an explicit instruction
Details: CLAUDE.md in this repository.
