Ctrl K
ComunidadGuías…

CHANGELOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Instance operators now have an instance-level audit log
  (`GET /api/v1/instance/moderation/audit`, operators only, cursor-paginated)
  recording every instance-wide ban and unban — including a ban of an
  address with no account, and every unban, which previously left no audit
  record anywhere (issue #276). Instance-operator actions have no single
  community to attribute them to, so an audit entry may now carry a null
  `community_id`; the per-community audit log is unchanged, and each
  account-ban still writes its member-facing per-community entry too.

- A "Reset link" control on your personal calendar subscription (issue
  #291). The iCal feed token rides in the subscription URL's path, so
  calendar apps and any fronting proxy log it on every poll; resetting
  mints a fresh token — dead-ending a link that has leaked — and shows the
  new URL to paste back into your calendar app. (A group's calendar link,
  a moderator action, is unchanged for now.)

### Changed

- Editing a legal page (privacy policy or imprint) is now guarded against
  concurrent operators (issue #276). Each page carries a version; if another
  operator has saved since you opened the editor, your save is refused with a
  409 rather than silently overwriting their text, and the editor offers to
  reload the current version so you can reapply your change. Previously two
  operators editing at once was last-write-wins with no warning.

- The realtime websocket now authenticates with a short-lived, single-purpose
  token instead of the long-lived device token (issue #175). The device token
  stayed in the socket URL's query string, where any fronting proxy or CDN
  logging request paths could capture a 365-day credential; the client now
  mints a ~60-second token over REST (device token in the `Authorization`
  header only) and connects with that. The short-lived token is bound to the
  device, so revoking a device also cuts its realtime access on the next
  connect. No user-visible change; existing connections are unaffected.

### Fixed

- Signing out of an instance now also clears any pending community invite left
  mid-flight, so on a shared device the next person to sign in can't inherit
  the previous user's invite token (issue #369) — the same shared-device
  privacy class as the offline-snapshot clearing (#186), for a different
  stored key. An audit of every `kammer:*` browser-storage key confirmed this
  was the only remaining instance-scoped residue; the theme and language
  preferences are device settings and intentionally persist.

- Three concurrent-moderation races that raised a 500 now answer a neutral 404
  instead (issue #276): lifting a community or instance ban that another
  moderator already lifted, and dismissing a report whose content another
  moderator concurrently removed. The two `unban` controllers gained an `else`
  clause for the already-folded not-found result (they previously raised a
  `WithClauseError`), and report `dismiss` now folds its own stale update the
  way the ban lifts already did.

- ICS calendar files now fold long content lines to at most 75 octets
  _including_ a continuation line's leading space (issue #383, RFC 5545
  §3.1); the folder re-measured the tail without the injected space and so
  emitted 76-octet continuation lines. Cosmetic — every calendar client
  tolerates it — but now spec-correct.

- Editing or cancelling an event now reliably updates calendars that already
  subscribed to it (issue #363). The ICS export never emitted `SEQUENCE`
  (RFC 5545 §3.8.7.4), so a calendar app that gates re-processing on the
  revision counter could silently ignore a later edit or cancellation. Events
  now carry a `SEQUENCE` that advances on each significant revision — an edit
  to an exported field, a cancellation, or a reinstatement (a capacity-only
  change, which the calendar never shows, doesn't bump it) — so both
  revision-gated and timestamp-gated clients pick the change up.

- A calendar app or RSS reader that sends a strict `Accept` header no
  longer gets a 406 from the webcal ICS feeds, the single-event ICS
  download, or a public group's RSS/Atom feed (issue #366) — content
  negotiation against `Accept: text/html` was silently failing the
  subscription for clients that don't send `*/*`. These media routes moved
  off the `:browser` pipeline onto a new `:browser_media` pipeline that
  keeps the session read and setup gating but drops `:accepts`, since each
  controller sets its own `Content-Type`. Mirrors the `:api_binary` split
  (#315) on the higher-traffic subscription surface; the one real HTML
  route (the newsletter-unsubscribe page) stays on `:browser`.

- A control character in a post or event title no longer makes a public
  group's RSS/Atom feed ill-formed (issue #364). XML 1.0 forbids the C0
  control range (except TAB/LF/CR) and the U+FFFE/U+FFFF noncharacters; a
  single one — which the post/event changesets don't strip — made a strict
  XML parser reject the _entire_ feed rather than the one offending item,
  so a single crafted title could take down `feed.rss`/`feed.atom` for
  every subscriber. `Feed.Syndication` now strips the illegal range at both
  output boundaries (the entity escaper and the CDATA description body),
  matching how the ICS side (#313) hardens at its boundary.

- An instance ban is now a full account lockout, closing the evasion where
  a banned account could re-authenticate and shed the ban by changing its
  email (issue #377). Before, `instance_banned?` was consulted only at the
  community-join choke-point: a banned account could still request a fresh
  sign-in link/code to its own address, exchange it for a new session, and
  then change to an un-banned address (the ban is keyed on the old email),
  rejoining every community. Now a banned address gets no new session —
  registration, sign-in-link requests, and code/magic-link exchange all
  refuse it neutrally (no ban oracle: a banned address is indistinguishable
  from a taken or unknown one), and every authenticated request re-checks
  the ban list as a backstop. Email change is refused when either the
  current or the target address is banned, so a ban can't be moved off. A
  ban also revokes the account's passkeys (a retained passkey would let it
  sign back in through the usernameless ceremony) alongside the device
  tokens revoked in #276. The notification channel now re-authorizes each
  push against the recipient's live group access — matching the feed
  channel — so a membership or visibility change that never broadcast a
  socket disconnect still cuts the stream.

- The legal-page editor's changeset no longer casts `updated_by_user_id`
  from the request body (issue #276); the "who last edited" attribution is
  set programmatically in the context via `put_change`, per the convention
  that programmatic fields are never cast. It was already safe in practice
  (the context overwrote any body-supplied value), so this closes the
  defense-in-depth gap where a future caller skipping that overwrite would
  let a crafted body spoof the editor. Also drops the now-unused
  `Legal.change_page/2` form-changeset helper, orphaned when the LiveView
  edit form was removed in #187.

- An instance-wide ban now severs the banned account's current live
  sessions, not just its community memberships (issue #276). Banning an
  email removed the account from every community but left its device tokens
  valid and its websockets open, so an active session kept working
  (continued `/me` access, historical notifications) until it happened to
  reconnect — the one adversarial account event that _didn't_ drop its live
  credentials, unlike account deletion, device revocation, and email
  change, which all do. `ban_instance` now revokes the account's device
  tokens inside the ban transaction and the API severs its open sockets.
  (This kills the live session; a banned account re-authenticating into its
  now-empty state, and the separate email-change ban-evasion hole, are
  tracked in #377.)

- Deleting a group now removes its uploaded file blobs from disk, not just
  the database rows (issue #276). A group delete hard-deletes and cascades
  every child row, but the on-disk bytes for the group's stored files and
  their thumbnails were never removed — and the daily purge worker sweeps
  only _soft_-deleted content and expired transient uploads, never a
  hard-deleted group's blobs, so those stayed orphaned forever, quietly
  breaking the privacy template's promise that deleted content is purged.
  `Groups.delete_group/2` now captures the group's storage keys inside the
  delete transaction and purges the blobs once it commits (a filesystem
  unlink can't roll back, so it runs post-commit).

- The test suite no longer leaks a recurring
  `update check failed: {:unexpected_status, 500}` warning (issue #280).
  The update-check failure-path test emitted it from its own stubbed 500
  (the check is never invoked unstubbed — Oban runs `:manual` in test);
  the test now captures and asserts the warning rather than letting it
  print, which also pins the previously-unasserted behavior that a failed
  check warns the operator.

- Control characters in an email no longer reach Postgres and 500 (issue
  #334). A NUL byte — or any other C0/DEL control — passed the old format
  regex `[^@,;\s]+@…` and then raised `Postgrex.Error` (text columns
  reject a NUL), so a crafted JSON body carrying a NUL (a JSON string may hold a
  `\u0000` escape) turned a malformed email into an unhandled 500. Two
  mechanisms are closed. The
  shared changeset validator now excludes the C0/DEL range and anchors
  with `\A…\z` (also closing a trailing-newline hole `$` allowed),
  covering every write that shares it (registration, guest identities,
  newsletter subscriptions, invites, event/post guest requests) — plus
  the profile `contact_email`, which had bypassed validation entirely
  (editable on `PUT /me`; a blank submission still clears the optional
  field), and the two moderation ban changesets. And the raw-email
  _lookup_ paths that never touched a changeset — anonymous sign-in
  (`request_link`), the login-code exchange, and the instance-ban address
  — now reject a control-char address before it reaches a
  `where email = ?` query, so the sign-in endpoints keep their neutral
  anti-enumeration response instead of 500-ing before rate limiting even
  runs. Unicode/IDN addresses stay accepted throughout. The sweep of
  genuinely free-text fields (`contact_phone`, `contact_note`, …) is left
  to the security-audit swarm's input-handling lens.

- Client resilience follow-ups (issue #316). Time-derived _state_ no
  longer fossilizes on a long-lived installed PWA the way relative
  timestamps once did: an open poll now locks the minute its deadline
  passes, a scheduled post flips to published when its time arrives,
  and the events agenda's "Today"/"Tomorrow" headings stay honest
  across midnight — each now reads the shared reactive minute clock,
  which also snaps fresh the instant the app foregrounds. The tokenless
  public shells (`/welcome`, `/sign-in`, `/setup`, public
  `/c/[community]/**`, and their siblings) gain a render-error boundary
  so a client-side _render_ crash degrades to a recoverable card instead
  of a white screen with SSR off. Two smaller hardening fixes: the shared
  `Button`'s link variant no longer drops `aria-*`/`data-*`/`target`
  props, and removing an instance clears the cross-instance offline
  snapshots (issue #186 — a shared device's next signer-in must not
  inherit them) before — and regardless of — the realtime-socket
  teardown, so a throw there can't skip the privacy step.

- Binary API downloads no longer reject their own documented media type
  (issue #315). Every byte-serving API route (the single-event ICS, the
  stored-file endpoints, the GDPR export zip) sat in the JSON-only `:api`
  pipeline, so a client sending the natural Accept header for the payload
  — `text/calendar` for the ICS, `image/*` for a file, `application/zip`
  for the export — got a 406 before the controller ran. They now serve
  through a binary pipeline that keeps the same authentication but drops
  JSON-only content negotiation (the PWA was unaffected — its `fetch`
  sends `*/*`). The authed byte responses (including the secret-token
  calendar feeds, deliberately) now carry a `private, no-store` cache
  directive — belt-and-braces over RFC 9111; the tokenless public post
  attachments keep the framework's default caching instead. And
  single-event `.ics` downloads take a
  filename derived from the event title instead of a static `kammer.ics`,
  so several saved files are distinguishable.

- Hardened ICS calendar output (issue #313, affecting every ICS
  surface — the single-event download, the group/user feeds, and the
  reminder and guest-confirmation email attachments). A lone carriage
  return in an event's title, description, or location used to survive
  escaping; RFC-strict parsers only break a line on CRLF, but several
  real calendar clients break on a bare CR too, which let an event's
  text inject whole property lines (ORGANIZER, ATTENDEE, VALARM) into
  subscribers' calendars — every line-break variant now collapses to
  one escaped `\n` (CR/LF plus the Unicode separators NEL/U+2028/U+2029
  that Unicode-aware clients also break on), and control characters are
  dropped (C0 and DEL, which RFC 5545 forbids outright, plus the C1
  range as defense-in-depth against lenient clients). Cancelled occurrences now
  export `STATUS:CANCELLED` instead of looking live, so a downloaded
  file marks the event cancelled rather than planting a normal-looking
  one.

- The newsletter unsubscribe link no longer deletes on GET (issue
  #239). GET is a safe method, and non-RFC-8058 mail scanners and
  corporate link-checkers prefetch every URL in an email with no human
  involved — so following the `List-Unsubscribe` URL in a browser now
  renders a small confirmation page (instance default locale, both
  themes) whose button fires the RFC 8058 POST, and only the POST
  unsubscribes. Mail clients' one-click POST keeps its semantics —
  same delete, same neutral 200 (the response body is now the same
  small HTML page instead of plain text); both endpoints still answer
  identically for valid and garbage tokens alike. The issue's second
  item — the email body's manage link carrying the full token in the
  URL path — had already been fixed in the #187 cut (the link rides
  the URL fragment), so
  this closes the issue. Alongside: the instance-locale wrapper the
  guest and newsletter notifiers each carried a private copy of moved
  to `KammerWeb.Gettext.with_instance_locale/1`, now shared by the
  notifiers, the decision-poll texts, and the new pages.

### Added

- Owner-facing notice email on account deletion and data export (issue
  #338). The two most consequential, irreversible account actions —
  permanent deletion and the full-data export — now email the account's
  own address after the fact, the way an email change already notifies
  the old address. It's the one signal a hijacked account's real owner
  still receives if a stolen device token is used to destroy the account
  or pull all its PII in one request: each notice names what happened
  and says to contact the instance's administrator if it wasn't them.
  Best-effort — a mail failure can't fail the action it reports (the
  deletion notice is sent from the in-memory record once the row is
  gone), but a failure is logged, since the notice is the security
  signal. EN + DA. Closes the residual gap ADR 0029's step-up work left
  (surfaced by #337's review).

- Test coverage for the community-file-space rehoming data migration
  (issue #308). The #187 migration that moves the now-unreachable,
  LiveView-only community file space onto a group carries real
  branching — it prefers the oldest group that can actually surface the
  files (not archived, not private, `:files` on), falls back to the
  community's oldest group otherwise, excludes transient uploads, and
  skips a group-less community — and it moves personal-data-bearing
  rows, yet had no test. It now runs its _real_ `up/0` (not a copy of
  its SQL) under the test sandbox through a new reusable
  `Kammer.MigrationHelper`, which drives `Ecto.Migration.Runner` so a
  data migration's `execute/1` commands run on the test's own
  sandboxed connection — a harness future migration tests can reuse.

- Step-up re-authentication before credential changes (issue #294, ADR
  0029). Adding or removing a passkey, revoking a device other than
  your own, and starting an email change now require the calling
  device to have recently re-asserted a root of trust — either a
  passkey confirmation or a single-use emailed link — within a short
  window (`STEP_UP_VALIDITY_MINUTES`, default 10). This closes the gap
  where a transiently stolen device token could mint persistence (add
  a passkey that survives revocation, lock the owner out, or repoint
  the account email); signing out, including revoking your own device,
  stays ungated. Gated endpoints answer 401 with the new
  `step_up_required` code; the PWA opens a confirmation dialog (passkey
  or "email me a link" with a check-your-inbox step), then retries the
  original action. The emailed link lands on a new `/step-up/{token}`
  page that works in any browser and elevates only the device that
  asked. This supersedes the #258-era decision to ship the API
  email-change flow without a re-auth gate, and resolves ADR 0018's
  open sudo-mode note.

- Per-community accent re-tint in the PWA (issue #321, SPEC §21).
  Community-owned surfaces — the authed community tree and the public
  community pages — now re-tint the interface with that community's
  configured `accent_color`, delivering §21's "branding is structural"
  promise: switching communities switches the accent. The accent and
  its foreground are derived per theme from the stored color with a
  WCAG AA floor (≥ 4.5:1 against paper/surface in the light _and_ dark
  palettes, and for text on the accent), darkening or lightening only
  as far as safety requires, so any admin-chosen color ships readable
  in both themes; an invalid stored color falls back to the default
  accent. Merged, multi-community surfaces (Home, cross-instance
  lists) and the app chrome keep the neutral default.

- Per-event capacity limit with an automatic ordered waitlist (issue
  #318). An event can now cap its attending RSVPs (`capacity`, empty =
  unlimited, set from the event form): once full, further "yes"
  answers — member and confirmed-guest alike, one cap over the whole
  RSVP set — land on a waitlist in arrival order, shown to the caller
  ("You're #3 on the waitlist") and to members on the event page as an
  ordered waitlist section (the public event read carries counts only,
  never queued identities). A freed seat (an attendee cancelling, the
  organizer raising or removing the cap) promotes from the front of the
  queue atomically — concurrent writes serialize on a row lock, so a
  seat is never double-filled — and the promoted member is notified
  through the notification machinery at their level (new
  `event_promoted` kind); a promoted guest gets an email, and a guest
  whose RSVP queued is told so in their confirmation email. Lowering
  the capacity never demotes anyone already attending. Deliberately
  decoupled from ticketing (#133): capacity is a small delta on the
  existing RSVP machinery.

- Newsletter subscription form on the PWA's public group page (issue
  #185, part of #187, SPEC §8). When a group opts guests into its
  newsletter (`guest_subscribe_allowed`), its public page now offers a
  tokenless subscribe form — name, email, and a per-post / daily digest
  / weekly digest cadence picker — matching what the LiveView group page
  offered. It posts to the existing
  `POST /api/v1/communities/{slug}/groups/{slug}/newsletter` endpoint,
  always answers a neutral "check your email to confirm" (no oracle on
  whether the address is already subscribed), and lets the guest change
  cadence or unsubscribe later from the management link the confirmation
  email carries. This closes the last unbuilt guest surface before the
  LiveView removal cut.

- Passkey enrollment in the PWA (issue #260 port 5b, part of #187, ADR
  0018). Four new authenticated endpoints under `/api/v1/me/passkeys` —
  `POST /challenge` (WebAuthn registration options), `POST` (verify the
  attestation and store the credential), `GET` (list), and
  `DELETE /{passkey_id}` (remove) — let a signed-in account register a
  passkey, the authenticated twin of the usernameless sign-in ceremony
  (#287). The ceremony runs statelessly: the challenge travels to the
  browser inside a signed, short-lived token (a salt distinct from the
  sign-in challenge's, so the two are never interchangeable) and comes
  back to verify against. Enrollment failures — a stale or tampered
  token, a bad attestation, a duplicate credential — all collapse into
  one neutral 422 that never reveals which step failed. The device page
  gains a "Passkeys" section listing the account's passkeys (name, when
  added, when last used) with remove, plus an "Add a passkey" control
  shown only when the browser supports registration and the instance is
  same-origin (WebAuthn binds the ceremony to this origin's host);
  otherwise a short note explains why. The serializer never emits the
  credential id or public key.

- Recurring event-series organizer view in the PWA (issue #260 port 4,
  part of #187, SPEC §6). A new authenticated endpoint —
  `GET /api/v1/communities/{slug}/events/series/{id}` — returns a
  series' rule, every occurrence (with RSVP counts and cancel state),
  and the attendance matrix (group members × upcoming occurrences, each
  cell the member's RSVP). It is organizer-only: the series' creator or
  a group moderator, gated exactly as the LiveView organizer page was.
  The PWA gains a series page reached from a "View series" action on any
  occurrence's detail, listing every occurrence with per-occurrence
  cancel/reinstate and rendering the attendance matrix. Attendance is
  the members' RSVPs (SPEC §6 — members only; guests count toward an
  occurrence's totals but are not matrix rows), computed, never a
  separate "mark present" step.

- Calendar subscription (iCal) links in the PWA (issue #260 port 3,
  part of #187, SPEC §6). New authenticated endpoints —
  `GET /api/v1/me/calendar-token` and a group sibling — hand the
  signed-in caller their subscription URL for the secret-token iCal feeds the
  browser routes already serve: a personal merged-events feed, and any
  group's whose events they can see. The PWA gains a "Subscribe" control
  on each group's page (when its events feature is on) and on the
  personal events page (one per added instance, since each feed lives on
  one server), revealing the feed URL with a copy action and a one-tap
  `webcal://` open link. The token is minted on first fetch and is the
  whole credential; the group endpoint gates exactly as the group's own
  feed does (viewable, events feature on), and an unviewable group is
  the same 403 every group-scoped endpoint gives.

- Group creation in the PWA, with a warm new-community cold-start
  (issue #278, part of #187). The PWA could edit a group's settings but
  had no way to _create_ one — a fresh community dead-ended with no
  first group (the LiveView was the only surface that could make a
  group). Adds a new-group form (name / URL / description / visibility /
  join, posting, and comment policy / approval queue / the create-only
  sealed flag) at parity with the LiveView's, and a `create_group`-gated
  "New group" affordance. A community with no groups now shows a warm
  empty state ("Create your first group") instead of a blank card — a
  new community starts empty by design (no auto-created default group,
  SPEC §3). The new-group form offers optional named suggestions
  (Everyone / Announcements / Public page) that pre-fill a sensible
  starting shape the operator renames and adjusts — suggestions, never a
  furnished room. SPEC §3 clarified: groups are the sole container (no
  community-wide file space), and cold-start is UX, not a data-model
  default.

- Passkey sign-in in the PWA (issue #260 port 5a, part of #187, ADR
  0018), driving the usernameless WebAuthn assertion the API already
  served (`/auth/passkey/challenge` → `/auth/passkey/verify`). The
  sign-in screen's email step gains a "Sign in with a passkey" option
  that runs the ceremony and lands on the same added-instance state as
  the magic link — no email typed, no password. The affordance is shown
  only where it can actually succeed — the browser can run WebAuthn and
  the instance is same-origin (a passkey is bound to its serving origin,
  so a cross-origin community couldn't sign in with one) — never a
  button that can only fail. Every failure (an unusable challenge, a
  dismissed prompt, a rejected assertion) collapses to one neutral
  message, giving no oracle for which passkeys or accounts exist.

- Custom profile-field management over the API and PWA (issue #259,
  part of #187, ADR 0020) — the last buildable #259 item, porting the
  LiveView community-settings surface and going past it. Managers get
  list, add, edit, and delete under
  `/api/v1/communities/{slug}/custom-fields`, all gated on
  `:manage_community` in the context. A manager can add a field (text
  or single-select with options), edit an existing field's label,
  visibility, or required flag, and delete one. A field's type and its
  options are frozen once it exists (changing them after members answer
  would orphan values); everything else stays editable — the LiveView
  only toggled required. Making a field required after the fact nudges
  already-joined members rather than locking them out (ADR 0020). The
  PWA community settings page gains a "Profile fields" section for all
  of it; a field id from another community answers a neutral 404
  through this community's path.

- Anonymous instance landing and community directory over the API and
  PWA (issue #260, part of #187), porting the signed-out
  `InstanceLive.Home`: a tokenless `GET /api/v1/public/communities`
  listing the communities that opted into the landing page
  (`listed_on_instance`, SPEC §3 — unlisted communities never appear,
  pinned by test), and a public `/welcome` page with the product-ethos
  blurb, a sign-in affordance, and the directory linking into the
  existing public community pages. Visitors without a signed-in
  instance now land there rather than straight on the sign-in form,
  taking over from the signed-out LiveView root.

- The four remaining admin-parity surfaces over the API and PWA
  (issue #259, part of #187), each mirroring its LiveView semantics.
  Group deletion:
  `DELETE /api/v1/communities/{slug}/groups/{slug}` — group owners
  and community admins (their sole power over sealed groups, ADR
  0005), audit-logged by the context — with a confirm-guarded delete
  button in the PWA group settings danger area, gated on a new
  `delete_group` entry in the group's `viewer_can`. Instance-wide
  email bans (the `InstanceLive.Moderation` twin): operator-only
  list/create/lift under `/api/v1/instance/moderation/bans`, keyed on
  the email itself so an address without an account can be blocked,
  and a PWA instance-moderation page under the account area with the
  ban form, active-ban list, and 422 field names mapped to our own
  copy (#253). Legal-page editing (`PUT /api/v1/legal/{key}`,
  operator-only, the `LegalLive.Edit` twin) with a PWA legal-pages
  overview — including the LiveView nag's equivalent, a
  "still showing the built-in template" hint per unpublished page —
  and a markdown editor linking the public page as preview reference.
  And a per-viewer `instance_operator` flag on `GET /api/v1/instance`
  (the `can_create_community` precedent), which the You page now
  reads from the capability-doc fetch it already made for the version
  line — replacing the per-instance settings-read 403 probe (#271's
  review note) and gating the new operator links.

- The PWA profile page now exposes the account's language, timezone,
  and email-digest frequency (issue #260, part of #187) — three
  settings `PUT /api/v1/me` already accepted and digest/notification
  emails already read, but which had no edit surface outside the
  LiveView settings screen. Language and digest mirror the LiveView
  option lists (English/Danish; off/daily/weekly Mondays); the
  timezone upgrades LiveView's free-text input to a select over the
  browser's IANA zone list, and the hints spell out that this
  language governs emails from the server while the app's own display
  language stays a device-local choice on the You tab. A 422 on save
  now maps the changeset's field names onto our own copy instead of
  rendering the server's message string (#253).
- Ban creation and an audit-log page in the PWA's moderation surface
  (issue #259, part of #187), both over API endpoints that already
  existed with no PWA caller. The moderation page gained a "ban a
  member" form (admins only): pick a plain member from the roster —
  the server refuses admins, owners, and self-bans, mirroring the
  LiveView semantics — with an optional reason the LiveView flow
  never exposed, and a 422's field names map to our own copy
  (already-banned email, over-long reason). A new audit-log page
  under `moderation/audit`, linked from the moderation page like the
  LiveView's inline audit section, lists the community's append-only
  admin actions (summary, action code, relative timestamp) for
  community admins.

- Community and instance-operator settings pages in the PWA (issue
  #259, part of #187), both over API surfaces that already existed —
  no new server endpoints. The community settings page gained the
  controls the update endpoint already accepted: web address (slug),
  default language, "list on the instance landing page", and the
  real-names request — previously only name, description, and accent
  were editable, and a 422 now maps the changeset's field names to
  our own copy (`name`/`slug`) rather than showing a generic error.
  A new operator-only instance settings page under the account area
  edits instance name, default language, who may create communities,
  the storage policy, and content-minimized email mode — reachable
  only where this account operates the instance (gated on the
  settings read itself, since there is no operator capability on the
  client). The LiveView instance settings screen only ever exposed
  the email toggle; the other four fields had no edit surface before
  now.
- Community creation over the API and PWA (issue #259, part of #187):
  a new `POST /api/v1/communities` endpoint, gated by the same
  instance community-creation policy (SPEC §3: operators only / any
  user) the LiveView flow honors — the creator becomes the
  community's owner — plus a `communities/new` PWA screen reachable
  from the groups tab. The instance capability doc
  (`GET /api/v1/instance`) now reports a per-viewer
  `can_create_community` boolean so the client gates its "new
  community" entry point instead of guessing the policy.
- The PWA group settings page now covers the full settings surface
  the API already accepted (issue #259, part of #187): visibility,
  join/posting/comment policy, web address (slug), approval queue,
  and file-version retention — previously only name, description,
  features, and archive were editable. The page also lists pending
  join requests with approve/deny, and links to a new group invites
  screen (mirroring the community invites page). The group serializer
  and its OpenAPI `Group` schema now expose `posting_policy`,
  `comment_policy`, `approval_queue`, and `version_retention` so the
  form can pre-fill them — emitted only to a viewer who can manage the
  group (`viewer_can` includes `manage_group`), so a public group's
  moderation posture and retention config never ride the tokenless
  public shape; no new server capability was added — the update
  endpoint already accepted every one of these fields.

### Changed

- Notifications no longer reimplements the comment-subject resolution
  (issue #124, architecture audit). `Feed.comment_context/1` — already
  the shared resolver used by Moderation — now returns the subject
  _struct_ (post/event/assignment), not just its id, so
  `Kammer.Notifications` derives a comment's host group, subject author,
  and notification references from it instead of independently
  branching and reaching `Repo.get!` into the Events and Assignments
  schemas across a context boundary. No behavior change; one copy of the
  branching, in one context. Also fills a coverage gap the audit
  surfaced (issue #95): the `@everyone` mention gate — a real
  authorization control, since it escalates a comment to a full-group
  broadcast — is now tested on event and assignment comments, not only
  post comments, pinning that those entry points route through the
  shared gate.

- Expelling a sealed **private** group from outside is now recorded as
  an operator-level action (console/DB), not an API power
  (owner-decided on issue #347, ADR 0005 amendment): the no-oracle
  gate (#224/#339) hides such a group from a community admin before
  the delete authorization could run, and restoring an admin-reachable
  delete would itself be an existence oracle. The group's own owner
  keeps the ordinary delete; sealed non-private groups keep the API
  path for admins too.
- The default community accent now matches the design-system accent
  (issue #328). A community that never customized its accent used to
  carry a green (`#3E6B48`) distinct from the app chrome's `#8a4b24`,
  so untouched communities rendered two-tone out of the box; the
  server default (schema and column) flips to `#8a4b24` and a
  migration moves existing rows still on the old default (matched
  case-insensitively — the untouched wizard default could be stored in
  either case), so a tint is always a deliberate admin choice. The
  data migration is one-way: rolling back restores the old column
  default but cannot tell a migrated row from one whose admin chose
  `#8a4b24` on purpose. The setup wizard, new-community form, and
  community-settings placeholder pre-fill the same value.

- Single-account display collapse in the PWA (issue #322, SPEC §21).
  With exactly one instance added — the overwhelmingly common case, and
  the entire pilot — the chrome now hides the multi-instance
  abstraction instead of presenting a one-item federation: Home,
  Groups, and Search drop the instance-name provenance labels, the You
  tab presents one plain account (no instance-named card header, no
  several-servers explainer, "Your account" instead of "Your
  communities"), the You sub-pages (profile, devices, push
  notifications, data & account) drop "on <instance>" from their
  descriptions, and the failed-instance banners say "your community"
  rather than naming the only server there is. Everything derives from
  a shared `instances.solo` flag, so adding a second account restores
  the current multi-account presentation unchanged — and URLs keep
  their `[instance]` segment either way (deep-link stability; this is
  presentation-only, the data model and routes are untouched).

- PWA API error handling collapsed into one shared `ApiError` (issue
  #270, part of #187). The ~10 near-identical per-module error classes —
  each carrying its own copy of the status→kind mapping, envelope
  parsing, and `fetch`-rejection guard — are gone; every client API
  surface (authenticated and tokenless alike) now throws the single
  `ApiError` from `$lib/api/errors`, and the two duck-typing bridges that
  existed only to reconcile the separate classes (`manage`'s
  `loadErrorKind`, `tools`' `toolsErrorKind`) collapse into one shared
  `errorKind` collapser. The duplicated status-to-kind mapping tests are
  retired in favor of one canonical spec. Net −600 lines. No user-visible
  behavior change beyond one first-run-setup network-error string now
  reading the shared "Could not reach this community." wording (converged
  with the other raw-`.message` render sites by the #253 field-error
  follow-up). A few internal mappings also unify — a swallowed `push`
  network string, `400` no longer aliased to the `validation` kind on the
  four tokenless surfaces that did so, and `too_large` now preserved
  through the collapsed bridges — but no current consumer distinguishes
  them. This is the shared mechanism the inline 422 field-error work
  (#253) builds on.

- Inline 422 field errors on the PWA's group and community create and
  update forms (issue #253, part of #270). A validation failure now
  highlights the specific input that caused it — a taken or reserved web
  address lands on the slug field, a blank-or-too-long name on the name
  field (and, on group settings, a bad version-retention value on that
  field) — with localized copy, instead of one generic banner. Two
  shared mappers, `groupParamsErrorKeys` and `communityParamsErrorKeys`,
  turn an `ApiError`'s 422 `details` (Ecto changeset field names, via
  `traverse_errors`) into per-field i18n keys, reusing the
  `registerErrorKeys` pattern and the shared `Input` `error` prop; any
  unmapped field or non-validation failure falls through to the shared
  `ErrorBanner`. Server-English message strings are still never rendered.

- Inline 422 field errors on the PWA's remaining forms — event
  create/edit and profile update (issue #253, part of #270). The event
  form now lands a non-http(s) location link on the location-link field
  (issue #247), an end-before-start on the end-time field, an
  over-length title/location on their fields, and a too-narrow
  repeat-until window on that date; the profile form lands a
  blank-or-too-long display name and an over-length pronouns value on
  their inputs. Two new mappers, `eventParamsErrorKeys` and
  `profileParamsErrorKeys`, turn the 422 `details` (Ecto changeset field
  names, verified against `Kammer.Events.Event`/`EventSeries` and
  `Kammer.Accounts.User.settings_changeset`) into per-field i18n keys;
  the event form's inputs moved onto the shared `Input` component to
  carry the `error` prop. `Select`-constrained fields and any unmapped
  field fall through to the shared `ErrorBanner`, and the event pages no
  longer render the raw server `ApiError.message`. First-run setup keeps
  its banner for now: its `with`-chained sub-changesets yield flat but
  cross-entity-ambiguous detail keys (`name`/`slug` for both community
  and group) split across two wizard steps, so field mapping is deferred
  — but its raw-`.message` render was replaced with localized copy too.
  The community/group invite and instance-settings forms need no change:
  their endpoints emit no field-level 422 (the invite changeset doesn't
  validate `invited_email`; `instance_name` is unvalidated), so they
  correctly stay on the banner.

### Removed

- The gettext catalogs dropped the 661 msgids whose only consumers
  were the LiveView templates deleted in the #187 cut (issue #325, the
  cleanup #294's build deliberately deferred): `mix gettext.extract
--merge` on a clean tree, verified lossless — every surviving msgid
  keeps its EN and DA translation, `errors.po` untouched.

- **LiveView is gone — the Svelte PWA is now the only web UI** (issue
  #187, closes #165; ADR 0024). The entire `lib/kammer_web/live/` tree,
  the LiveView-only web layer (core/feed/Kammer components, layouts, the
  CSP-nonce plug, the server `assets/` esbuild/Tailwind/daisyUI
  pipeline), and every LiveView-supporting controller whose capability
  had already moved to the JSON API (`GdprController` → `GET /me/export` +
  `DELETE /me`; `UserSessionController` → `/api/v1/auth/*`; the browser
  invite-accept and guest/newsletter confirm landings → their PWA + API
  twins; the browser file-download controller → the Bearer and public
  `/api/v1/files` routes) were deleted in one atomic cut, along with the
  `phoenix_live_view`, `phoenix_live_dashboard`, `phoenix_live_reload`,
  `heroicons`, `esbuild`, `tailwind`, and `lazy_html` dependencies, the
  dev LiveDashboard route (the Swoosh mailbox preview is retained for the
  e2e sign-in flows), and the LiveView smoke-test CI job.
  The `KammerWeb.UserAuth` plug shrank to a session _reader_ (browser
  sign-in was already an API device-token flow), and the browser
  pipeline dropped its LiveView flash / root-layout / CSP-nonce plugs.
- **The PWA moved from `/app` to the site root.** `:pwa_base_path`,
  the client's `paths.base`, the web manifest, and every email/redirect
  producer now target `/` — a magic link is `/sign-in/{token}`, a public
  group is `/c/{slug}/g/{slug}`, and the guest-manage link carries its
  token in the URL fragment. The router's PWA catch-all moved to the very
  end so it shadows neither the JSON API, `/healthz`, the ICS/RSS feeds,
  nor the newsletter-unsubscribe routes.
- The community-level file space (a LiveView-only surface with no API
  twin) was removed; a data migration rehomes any community-space
  folders, file entries, and stored files onto a group so nothing becomes
  unreachable. The target is the oldest group that can actually surface
  them — not archived, not `:private`, `:files` enabled — falling back to
  the oldest group overall (communities with no group are left
  untouched).

### Fixed

- Every tokenless public surface now draws one "public" line (issue
  #345, expanded by its review round): `publicly_readable?/1` gates
  the RSS/Atom feeds (an archived or sealed `public_listed` group
  used to keep serving a live feed whose every link landed on the SPA
  error state), the guest RSVP/comment gates (a sealed public group
  accepted whole guest flows whose confirmation links 404), the
  newsletter subscribe gate, and — the review round's sharpest catch —
  newsletter _delivery_: per-post and digest sends now re-check the
  gate, so a group flipped off the public presets stops emailing post
  excerpts to guest subscribers instead of leaking content across the
  visibility boundary indefinitely. The anonymous subscribe, guest
  comment, and guest RSVP requests all resolve through the shared
  public fetches now, folding sealed/archived public groups into the
  same 404 their pages give (the old answers confirmed existence to
  slug probers: 403 for archived, and for sealed a 202 acceptance of
  a flow whose confirm link then died). The single-post newsletter email links to the post itself
  (the digest's roundup link was correct and stays; the misnamed
  `post_url/1` helper is renamed, and all email links now build
  through `PublicLinks`, sharing the confirm links' base handling),
  and a confirmed guest comment redirects to the commented post — with
  the confirm pages growing the "go to the page" link that makes the
  redirect visible (the field previously had no client consumer).

- Leaving or being removed from a group no longer leaves an ex-member's
  RSVP sitting on that group's future events (issue #329, owner-decided
  option 1). Before #318 added capacity and a waitlist, a stale RSVP
  after removal was cosmetic; afterwards it was consequential — an
  ex-member could keep an attending seat or a promotable waitlist spot
  on an event they could no longer even view. `Kammer.Events` gains
  `drop_member_future_rsvps/2` (and the bulk
  `drop_member_future_rsvps_in_groups/2` for callers ending a
  membership across several groups at once): it deletes the user's RSVP
  — any status, including waitlisted — on the group's future events,
  then runs the same locked promotion pass a member's own cancellation
  triggers, so a freed seat or a removed waitlist row promotes the next
  waitlisted member in order. Past-event RSVPs are untouched, staying as
  attendance history. Wired into every path that ends a group
  membership: `Groups.leave_group/2`, `Groups.remove_member/3`,
  `Groups.remove_memberships_in_community/2` (the community-wide
  removal `Communities.remove_member/3` already delegates to),
  `Moderation`'s community and instance bans, and — added by the
  pre-merge review, which caught it as the sixth door — account
  deletion (`Gdpr.delete_account/1`), where the FK cascade already
  deleted the rows but nothing promoted the freed seats.

- Community audit log cursor pagination (issue #340, 2026-07-17
  dismissal audit). `Audit.list_events` hard-capped at 50 rows with no
  way to see anything older, the API took no pagination params, and
  the PWA audit page fetched once — an append-only accountability log
  that silently dropped history for any active community. Originally
  dismissed as "parity with the LiveView ceiling," which is exactly
  the limitation-carry-forward this file's process section bars.
  `GET /api/v1/communities/{slug}/audit-log` now takes the same
  `after`/`limit` cursor params as the notification center and group
  feed (`Kammer.Audit.list_events_page/4`, same `{events, next_cursor}`
  contract; default page size stays 50, `Pagination.limit/2` gained an
  optional default override for it); the PWA audit page grows a "Show
  older" button matching the notification center's, appending pages
  and hiding once `next_cursor` is `nil` — a failed page keeps the
  loaded log on screen and stays retryable, mirroring the group feed's
  load-more error handling.

- RSS/Atom group feed items now link to the post itself instead of the
  group page (issue #341). Feeds shipped in #54 before any per-post
  page existed, so every item's `<link>` (RSS) / `<link href>` (Atom)
  pointed at the group page — the best available target at the time.
  #246 later added the public post page at
  `/c/{community_slug}/g/{group_slug}/p/{post_id}`, but nothing
  circled back to point feed items at it, so a reader following an
  item from their feed reader landed on the group's whole feed rather
  than the post they clicked. Each item's link now resolves through a
  `post_link_fun` the controller passes to `Kammer.Feed.Syndication`
  (an `unverified_url/2` build of the post's public page, the same
  convention as the feed's other links); the feed-level `<link>`
  correctly stays the group page. The RSS `<guid isPermaLink="false">`
  is unaffected — it was never a URL.

- An explicit JSON `accent_color: null` on community create, community
  settings, or the setup wizard no longer escapes as a 500. The request
  bodies declare the field nullable, but the column is `NOT NULL`, so
  the cast nil died at Postgres. A null now means "no choice": the
  changeset drops it, keeping the stored value on update and the
  schema default on create — which is what `nullable: true` honestly
  means for a defaulted setting. Pinned by an API test driving null
  through both create and update.

- Three previously-dismissed review findings, remediated after the
  owner overruled the dismissals (2026-07-17). The invite landing page
  now re-tints with the invited community's accent once the token
  resolves — the community is known from that moment, so the page
  carries its branding like every other community-owned surface (the
  invite preview payload gains the community's `accent_color`; the
  guest confirm and manage pages stay untinted — their payloads carry
  no single community, and a guest's inventory can span several). The
  single-account failure banners no longer presume one community:
  "your community" copy is now count-agnostic in English and Danish
  (one instance can host several communities, so the old wording was
  wrong for multi-community accounts). Internally, waitlist-promotion
  notification jobs are enqueued with one `Oban.insert_all` instead of
  a per-promotion insert loop — same in-transaction atomicity, one
  statement per promotion burst.

- Invite creation now validates `invited_email` (issue #305). The
  changeset cast the address with no format check, so an admin could
  invite `jhon@` or `alice@example ` — the API answered 201 and the
  invitation email silently bounced. A malformed address now fails with
  the standard 422 envelope (`details.invited_email`) via the shared
  email rule every other email field uses
  (`Kammer.Validation.validate_email_format/3`); valid addresses are
  stored downcased like the guest and newsletter email writes (the
  column is citext, so comparisons were already case-insensitive).
  Validation also runs before the per-admin email-invite throttle, so a
  refused address no longer consumes email budget. The PWA invite forms
  (community and group) surface the 422 inline on the email field
  through the #253 field-error mapper, in English and Danish. This
  supersedes the #253-era note earlier in this release that the
  invite forms "correctly
  stay on the banner" — that held only while the server emitted no
  field-level 422 for them.

- "Add to calendar" on an event page no longer fails with a silent 404
  for members-only groups (issue #307). The signed-in event page linked
  the tokenless browser ICS route (`/c/{slug}/events/{id}/ics`), which a
  plain `<a href>` navigation reaches without the device token — so any
  event the anonymous public couldn't see (every `private`/`community`
  group, i.e. most of them) answered 404 to exactly the members it
  belongs to. A new Bearer-authenticated endpoint —
  `GET /api/v1/communities/{slug}/events/{id}/ics`, serving the same
  `text/calendar` attachment behind the events surface's no-oracle 404 —
  replaces the link with an authenticated download (the same
  object-URL anchor pattern as the account export), with pending state
  and the shared `ErrorBanner` on failure. The browser route stays for
  public events and calendar apps.

- Client resilience floor (part of #270). Relative timestamps ("2 min.
  ago") now keep aging while a screen stays open — a shared minute
  ticker (`createSubscriber`) re-renders every `RelativeTime` and the
  offline stale-data banner on the minute and runs only while one is
  mounted, so a long-lived installed PWA no longer shows fossilized
  times. The SPA also gains its error floor: a branded, localized root
  `+error.svelte` (distinct copy for 404 vs other failures, always a
  link home) replaces SvelteKit's unstyled English fallback on stale
  or mistyped URLs; a `hooks.client.ts` `handleError` logs unexpected
  client errors to the console (the only sink there is — expected 404s
  excluded) and keeps raw error detail out of the UI; and a
  `<svelte:boundary>` around the app shell's content column degrades
  one crashed screen render (a single bad post, say) to an inline
  retry card instead of white-screening the shell — the navigation
  stays outside the boundary, and the card resets the boundary on
  navigation, so leaving the broken screen always works. And removing
  an instance now tears down its realtime socket on every removal
  path — sign-out and re-sign-in-replacement alike: previously the
  manager lingered until a reconnect happened to 401, and re-signing
  in (a fresh instance id) orphaned the old manager entirely. `Button`
  gained the audit's `href` story along the way (renders a real link
  with the button look), replacing the error page's hand-rolled copy
  of that recipe.

- PWA action surfaces no longer render the raw server-English
  `ApiError.message` (issue #253, part of #270). A shared
  `ui/ErrorBanner` renders localized `errors.<kind>` copy instead, so a
  Danish user sees Danish rather than an English server string, and no
  server-internal message leaks to the UI. Converted the post composer,
  the join/leave and invite actions, device/passkey management, and the
  feed/event/series/files/roster stores' action-error surfaces (each
  store's action-error state now carries the `ApiErrorKind`, not a
  message string). Field-bearing forms — inline per-field 422 errors —
  follow in the next slice.

- `Moderation.list_open_reports/2` no longer authorizes the report
  queue one report at a time (issue #342, audit finding). For a
  non-community-admin caller, every open report ran its own
  `Authorization.can?(actor, :moderate_group, group)` check, each
  resolving the actor's community and group roles fresh — two queries
  per report, on top of the queue load itself. It now collects the
  distinct groups the loaded reports belong to and resolves all of
  them in one batched `Authorization.group_relationships/3` call
  (#206) — the same shape `CommunityController.groups/2` already
  uses — then filters in memory, dropping the per-report cost to a
  fixed few queries regardless of queue size. Community admins still
  see the whole queue; group moderators still see only reports whose
  subject lives in a group they moderate, and only that group's.

### Security

- The web client's transitive `cookie` dependency is force-resolved to
  0.7.2 via a pnpm override (GHSA-pxg6-pf52-xh8x, low): SvelteKit still
  declares `^0.6.0`, whose parser accepts out-of-bounds characters in
  cookie names/paths/domains. The vulnerable path only runs in
  dev/SSR — the shipped client is a static build — but the override
  clears the advisory at zero API cost (0.7 is compatible) rather than
  leaving the alert to explain forever.

- Account deletion and the data export now require step-up
  re-authentication (issue #323, updating ADR 0029). `DELETE /me` and
  `GET /me/export` answer 401 `step_up_required` until the calling
  device has recently re-asserted a root of trust, closing the two
  gaps the original step-up scope (issue #294) deliberately left open:
  a transiently stolen device token could irreversibly destroy the
  account in one request (destruction needs no persistence), or pull
  every stored byte of the account's data as one zip (one-shot bulk
  exfiltration). The deletion flow's typed-back-email confirmation
  stays, layered after the gate — accidental-click protection on top
  of, not instead of, the security control. The PWA's delete and
  export flows open the same step-up dialog and retry, like every
  other gated flow. The consent copy — the step-up email and the
  confirmation landing page — now names the widened stakes
  explicitly (deleting the account, downloading all its data)
  instead of the original "security settings" phrasing, so a user
  approving a step-up knows the worst the action-generic grant can
  do (adversarial review of the #323 widening).

- `Event.location_url` now rejects anything but `http`/`https` on
  write (issue #247, found by the adversarial review of #246):
  previously it was only length-checked, so an event host could set
  it to `javascript:...` and have the LiveView event page render a
  raw `<a href>` — `rel="noopener noreferrer"` does not neutralize
  `javascript:` URLs, and this executed same-origin, i.e. member-facing
  stored XSS. Defense in depth, outermost first: the changeset
  validation (`Kammer.Validation.validate_http_url/3`, a new shared
  helper — `InstanceBookmark`'s near-identical private URL check now
  delegates to it too) blocks new writes; a data migration nulls any
  pre-validation row (which also keeps the ICS export's `LOCATION`
  text clean); the API serializer never emits a non-http(s)
  `location_url`, covering every current and future client at one
  choke point; and the render sites — the LiveView anchor
  (`EventLive.Show`) and both PWA event pages (the anonymous public
  one already guarded in #246, plus the authenticated member page
  which had the same gap, both via a shared `safeHttpUrl` helper) —
  still guard independently. Both sides enforce the same anchored
  scheme-allowlist rule rather than full URL parsing: RFC-3986
  parsing over-rejected IDN hosts (`https://øl.dk`) and pasted maps
  URLs browsers accept, while diverging from the WHATWG parsing the
  client used. A repo-wide sweep for other user-entered URL fields
  rendered as a raw href found none unguarded: `InstanceBookmark.url`
  already validated the scheme (from its original commit), and the
  two other raw `href={...}` sites in the LiveView app (`file_href/1`
  in the file browser and search results, `latest_known_release_url`)
  are either server-generated paths or sourced from GitHub's release
  API, not user input.

- Slug-addressed group sub-endpoints no longer leak private/sealed
  group existence through a 403-vs-404 split (issue #339, found by the
  2026-07-17 dismissal audit). #224 folded a view-denied group into
  the same neutral 404 a missing one gets on the group management
  endpoint itself; eight private `with_group`/`with_feature_group`/
  `with_files_group` copies in the post, event, calendar, assignment,
  availability, decision, file-library, and group-member controllers
  didn't make the same fold, so an outsider probing a slug could tell
  a real hidden group from a typo by whether the group's feed,
  calendar token, assignment/availability/decision list-or-create,
  file library, or membership surface answered 403 or 404 — a live
  existence oracle the #224 fix was supposed to close everywhere.
  Extracted the fold into one shared `KammerWeb.Api.GroupGate.fetch/4`
  (optionally folding a disabled feature toggle too, ADR 0016) that
  all nine controllers — the original included — now call; only the
  `:view_group` resolution folds to 404, so a group a viewer can see
  but isn't allowed to write to, join, or manage still answers an
  honest 403. A review pass over the fix then caught and closed four
  more of the same oracle — uploads, group invites, and the anonymous
  newsletter-subscribe and guest-comment surfaces (the anonymous two
  the worst, being tokenless probes) — plus a latent 500 in the
  assignment/availability/decision create actions, where a denied
  write or invalid changeset in a _visible_ group escaped
  `with_feature_group` unhandled instead of answering the honest
  403/422. Flipped the tests that had pinned the wrong 403, tightened
  the `ResourcesTest` and `FeedWritesTest` parity properties (each
  previously accepted 403 _or_ 404 for an invisible group, which is
  exactly how this got past them), and pinned every gated surface with
  a deterministic invisible-group 404 test — added for create on the
  assignment/availability/decision suites, index and create on the
  feed and invite suites, and the single write each of the upload,
  newsletter, and guest-comment surfaces exposes — alongside new
  403/422 pins for the formerly-crashing denied and invalid creates.
  The independent pre-merge review then caught the final member of the
  class on the event-addressed twin: the anonymous guest-RSVP and
  guest-claim requests answered 403 for an event in a hidden group but
  404 for a missing one (disagreeing with the public read of the same
  event, which already folded). Folded those too, with a pin asserting
  the hidden-event and missing-event responses are byte-identical —
  status alone being what let every earlier oracle hide.

### Added

- The `GET /api/v1/instance` `features` object now carries
  `vapid_public_key` (issue #251, part of #186): the raw VAPID public
  key the PWA needs for `PushManager.subscribe`, `null` when push
  isn't configured server-side (and `web_push` now reflects real
  server config rather than a hardcoded `true`). The notifications
  settings page reads it and enables the push toggle — the last piece
  the #186 client work was blocked on.

- Account lifecycle over the API and PWA (issue #258, part of #187):
  the three flows that were LiveView/browser-only — email change,
  account deletion, and the GDPR export — now exist on the API, so
  SPEC §12's data rights survive the LiveView cut. `POST
/me/email-change` emails the new address a single-use confirmation
  link landing in the PWA (same notify-the-new-address-only semantics
  and per-user rate limit as the web flow); `POST
/me/email-change/confirm` consumes the token and — because device
  tokens are bound to the address they were issued under — answers
  with a rotated `device_token` for the confirming device while every
  other device signs out. `DELETE /me` deletes the account via
  `Kammer.Gdpr.delete_account` after the caller types their own email
  back (`confirm_email`, 422 on mismatch — possession of the device
  token is the re-auth, the typed address confirms intent); `GET
/me/export` streams the same export zip as the browser route. The
  PWA grows the matching surfaces: an email-change section on the
  profile page, the `/confirm-email/{token}` landing (which updates
  the stored instance's email and swaps in the rotated token), and a
  "Data & account" page per instance with export-to-download and the
  typed-email delete flow that removes the instance locally on
  success. EN+DA strings, OpenAPI operations + conformance taps, and
  API tests covering the full round-trip, deletion semantics, and the
  export payload.
- Report intake for event and assignment comments (issue #262, part
  of #187): `POST .../events/{event_id}/comments/{comment_id}/report`
  and `POST .../assignments/{assignment_id}/comments/{comment_id}/report`
  close the gap #263's contract trace found — the shared comment
  engine (ADR 0007) already let members report these comments in
  LiveView, but the API only exposed the post/post-comment intake, so
  the moderation queue would have lost event/assignment-comment
  reports at the LiveView cut. Same `Moderation.report_comment/3`
  behind both: neutral `201 {status: "reported"}`, a repeat report
  answers identically, an invisible subject 404s byte-identically
  (no-oracle, #156/#161), and filing draws from the same 20/h
  per-reporter budget as every other report (the shared acknowledgement
  now lives in `KammerWeb.Api.ReportIntake`, one definition for all
  four intake endpoints). PWA: the Report action now shows on event
  comments and on assignment-discussion comments, reusing the feed's
  inline reason form + confirmation idiom and existing EN/DA strings;
  `schema.d.ts` regenerated.
- PWA join flow (issue #255, part of #187): the invite link admins
  share (`/invite/{token}`) now lands on a real page in the Svelte
  client — until now the PWA had no invite landing and no registration
  form, so post-cut no new member could have joined. The landing
  previews what the invite opens (community/group, real-names note)
  via the until-now-unused public preview endpoint, with dead tokens
  collapsing into one calm "no longer valid" state; a visitor already
  signed in to this instance gets a one-tap accept, everyone else
  branches into sign-in or a new registration form (display name +
  email → magic link, `POST /auth/register`'s first client). The
  invite token rides sessionStorage through the email round-trip, so
  arriving back via the magic link accepts automatically and lands in
  the joined community/group; the sign-in screen also gains a
  create-account branch when the probed instance reports open
  registration. EN+DA strings, and a new e2e spec
  (`04-join.spec.ts`) drives the whole newcomer path — invite link →
  register → magic link → joined.
- Report creation over the API and the PWA report flow (issue #256,
  part of #187): `POST .../posts/{post_id}/report` and
  `POST .../posts/{post_id}/comments/{comment_id}/report` file a
  moderation report through the same `Kammer.Moderation` functions
  the LiveView flow uses — the group-feed slice of the gap where the
  moderation queue would have had no intake once LiveView goes
  (event- and assignment-comment reporting still needs its API
  sibling; tracked separately). The answer is a neutral
  `{status: "reported"}` (201) — a repeat report of the same subject
  answers identically, and an invisible post/comment 404s like every
  other feed write (no oracle, #156/#161). Filing is rate-limited to
  20 reports per reporter per hour — a fixed anti-abuse backstop (ADR
  0027 tier 3), enforced in the context so every surface shares it.
  The PWA's member group feed gains the matching UI: a Report action
  in the post menu and on every comment, an inline reason form, and a
  confirmation once it's sent (EN + DA).
- PWA notification center (issue #257, part of #187): the
  Notifications tab, previously a pure empty-state stub, is now the
  real in-app list — `NotificationLive.Index` parity over the #173
  API. Notifications merge newest-first across every added account
  (same multi-instance shape as Home/Events, per-instance #159
  failure kinds surfaced without blanking the rest), with unread
  emphasis, optimistic mark-read on tap, mark-all-read fanned out per
  instance, and tap-through into the right account's group feed or
  event page. EN + DA strings; store merge/mark-read logic covered by
  Vitest specs.
- Operator-configurable tier-2 deployment settings (issue #234, ADR
  0027): the throughput/policy rate limits, token lifetimes, and
  retention windows an operator legitimately tunes now read from
  `Kammer.Config`, each with a bounds-validated env override that
  fails the boot (naming the offending var) on an invalid value
  instead of silently clamping or being dropped — new vars
  `RATE_LIMIT_POSTS_PER_5MIN`, `RATE_LIMIT_COMMENTS_PER_5MIN`,
  `RATE_LIMIT_UPLOADS_PER_10MIN`, `RATE_LIMIT_EVERYONE_MENTIONS_PER_HOUR`,
  `SESSION_VALIDITY_DAYS`, `API_DEVICE_VALIDITY_DAYS`,
  `CHANGE_EMAIL_VALIDITY_DAYS`, `CONTENT_RETENTION_DAYS`,
  `TRANSIENT_UPLOAD_DAYS`, `GUEST_CONFIRM_LINK_HOURS`, and
  `GUEST_MANAGE_LINK_DAYS` (documented in `.env.example`). Every
  default is unchanged — the anti-abuse/security rate limits
  (magic-link, login-code, signup, setup, email-change, invite
  issuance) deliberately stay fixed constants, per ADR 0027.
- Account-less guest surfaces, newsletter, legal pages, and first-run
  setup over the JSON API (issue #185, the server half of the last
  #165 parity rung before the LiveView cut). These flows stay tokenless
  by design (ADR 0024): a guest holds no device token, so the signed
  link in the URL is the whole credential (ADR 0013) and the endpoints
  live in the public API, never behind Bearer auth. Guests may RSVP to
  a public event, claim a signup slot, and comment (each a request →
  emailed confirm link → record two-step flow), then reach one
  management page — behind their signed management link — that lists
  and changes every RSVP, claim, comment, and newsletter subscription
  they created, or erases all of it (SPEC §6/§8/§12). Newsletter
  subscribe/confirm mirror the same shape; the RFC 8058 one-click
  unsubscribe stays a plain-HTTP endpoint (mail clients POST the exact
  header URL) and is deliberately not duplicated. A public read
  endpoint serves the privacy and imprint legal pages (operator text or
  the built-in template). First-run setup is exposed over the API using
  the _existing_ operator-bootstrap credential — the setup token
  printed to the server logs — with no new secret: a status probe, a
  token check, and a one-shot complete that creates the operator,
  instance settings, first community and group, and invite link, then
  locks setup permanently. Every flow runs the same context function,
  authorization, and rate limit the LiveView flows use; an invalid,
  expired, or used token gets one neutral answer, never an oracle.
  OpenAPI operations, schemas, and conformance taps included; PWA
  screens follow as the client half.
- Tokenless public content-read endpoints over the JSON API (issue
  #185 slice B, the server half of the last #187 rung before the
  LiveView cut): `GET /api/v1/public/communities/:community_slug`
  (public face plus its `public_listed` groups), `.../groups/:slug`
  and `.../groups/:slug/posts` (cursor-paginated feed) and
  `.../posts/:post_id`, and `.../events/:event_id` — enough for the
  PWA to host the guest RSVP/signup-claim/comment request forms
  (above) on public content pages without an account. Every read is
  scoped through the new `Authorization.publicly_readable?/1` — the
  same `public_link`/`public_listed`-and-not-archived boundary the
  RSS/Atom feeds and the guest request endpoints already expose,
  additionally hardened against sealed groups since this is a
  newly-browsable JSON surface; a community/group/event/post that
  exists but isn't publicly readable answers the identical neutral
  404 a nonexistent one does — no oracle (#156/#161). The group feed
  and single-post read exclude soft-deleted posts entirely (not shown
  as tombstones, unlike the authenticated feed) since a guest has
  nothing to comment on there and the guest-comment request endpoint
  already refuses one. The `Group` serializer gained
  `guest_rsvp_allowed`/`guest_comment_allowed`/
  `guest_subscribe_allowed` so a client can decide whether to render
  those forms without guessing from `visibility`/`archived`/`sealed`
  or trying a request and handling the refusal. OpenAPI operations,
  schemas, and conformance taps included.
- Tokenless public reads of post attachments (issue #185 slice B):
  `GET /api/v1/public/files/:file_id` and its `/thumbnail`/`/download`
  twins serve the same bytes and hardening headers as the
  Bearer-authenticated `/api/v1/files/:file_id` (`KammerWeb.FileServing`
  shared between them), so an anonymous PWA visitor browsing a public
  post can load its image/file attachments — previously every
  attachment URL pointed at the Bearer-protected route, so a guest
  hit a 401 on every image in a public post's feed. Authorization is
  a new `Kammer.Files.fetch_public_file/1`, strictly narrower than the
  actor-based scope check `fetch_accessible_file/2` uses: a file is
  public only when it's an attachment on a post an anonymous visitor
  can already read through `PublicController.post/2` (published, not
  pending approval, not deleted) whose group additionally passes
  `Authorization.publicly_readable?/1` — never merely because its
  owning scope (a group/community file space) is publicly viewable,
  so library files not attached to any visible post stay 404. The
  `Serializer.post/4` shape gained a `public: true` option so posts
  read through the `/public` surface emit attachment URLs pointing at
  the new route instead, without changing the Bearer-authenticated
  shape any existing client already depends on. OpenAPI operations
  and conformance included.
- Guest, newsletter, legal, and first-run setup screens in the PWA
  (issue #185, the client half of the surfaces above): every route the
  guest/newsletter emails and the setup flow link to, all top-level and
  unauthenticated since these guests and operators hold no device
  token (ADR 0024). The RSVP, signup-claim, comment, and newsletter
  confirm links each POST their token once and show a neutral
  confirmed/error state (a full slot on claim confirm gets its own
  message rather than the generic "link invalid," since the API
  already distinguishes it). The guest management page lists RSVPs,
  claims, comments, and subscriptions behind a signed management link,
  lets a guest change an RSVP or subscription cadence, release a claim,
  unsubscribe, or erase everything — erasure requires an explicit
  in-page confirm step with focus moved to it and returned to the
  trigger on cancel, given how irreversible it is. Unlike the
  single-use confirm links, the management token is long-lived (issue
  #230, ADR 0026), so the emailed link carries it in the URL
  _fragment_ rather than a path segment — the page reads it from
  `window.location.hash` and sends it as an `Authorization: Bearer`
  header, never a URL, so it never lands in access logs, proxy logs,
  or `Referer`. Legal pages (privacy/imprint) render through the same
  sanitized Markdown component the feed uses. The first-run setup
  wizard mirrors `SetupLive.Wizard`'s field set (operator/instance →
  community/group → done) so the two stay interchangeable while
  LiveView setup still exists; there is no separate token-verification
  step (issue #230) — the setup token rides the operator step's form
  and is only actually checked on the final `POST /setup`, with a bad
  or expired one surfaced as a neutral error on that step rather than
  a pre-flight yes/no. EN + DA throughout; public event/group/post
  browsing and the initial guest RSVP/claim/comment _request_ forms
  are out of scope here — they need tokenless content-read endpoints
  for `public_listed` events/groups/posts that don't exist yet, left
  as remaining #185 scope rather than a new issue.
- Public community/group/post/event browse screens in the PWA, hosting
  the guest RSVP/signup-claim/comment request forms (issue #185 slice
  B, the client half of the last #187 rung before the LiveView cut —
  this is the guest-parity surface #187 was gated on). Four new
  top-level, tokenless routes consume the public content-read
  endpoints above: `/c/:community` (public face plus its
  `public_listed` groups), `/c/:community/g/:group` (description plus
  a cursor-paginated feed with load-more), a post page nested under
  the group (body, attachments, comments, and the guest comment form),
  and `/c/:community/events/:event` (details, RSVP counts, signup
  slots, and the guest RSVP/claim forms). Every guest form honors the
  `guest_rsvp_allowed`/`guest_comment_allowed` flags the group
  serializer now exposes — hidden entirely rather than shown and
  refused — and answers with the same neutral "check your email to
  confirm" state the request endpoints already guarantee (rate-limited,
  no oracle on whether an email is already known). The RSVP and
  signup-claim forms share `guest_rsvp_allowed` (one flag governs
  both, matching `Authorization.can_guest_rsvp?/1` server-side); each
  open signup slot reveals its own claim form rather than one shared
  one, since a claim is scoped to a specific slot. Post/event bodies
  render through the same sanitized Markdown component the
  authenticated feed uses; comments render read-only (no reactions,
  edit, or reply — none are guest capabilities). Attachments link
  straight to the serializer's plain URLs rather than the authenticated
  feed's Bearer-fetched object URLs (a guest holds no device token) —
  today that means an anonymous visitor's image request can 404 until
  the public-file-serving path lands in a parallel PR, so each image
  degrades to a neutral placeholder on load failure instead of a
  broken-image icon. `schema.d.ts` regenerated from the merged spec to
  pick up the five new `/api/v1/public/...` operations. EN + DA
  throughout.
- Collaborative tools and global search in the PWA (issue #184, the
  client half of the #165 parity rung): the Svelte screens over the
  API above. Each group surfaces the tools it has turned on (ADR 0016)
  — Availability polls (list, create, answer a candidate date, close or
  convert the winning date into an event), Assignments (the task list:
  create, claim/release, complete/reopen, delete, and per-task
  discussion), and the Decisions register (browse, raise a motion,
  record its outcome). A global search entry queries every community
  across all signed-in accounts and presents hits community-first (SPEC
  §10) over posts, comments, events, and files. Every control is gated
  on the resource's `viewer_can` so a button that would 403 is never
  shown; per-action failures surface inline without discarding the
  screen. EN + DA throughout.
- Collaborative tools and global search over the JSON API (issue #184,
  the #165 parity rung): the per-group tools the client previously had
  no way to reach, each behind its group feature toggle (ADR 0016) so a
  disabled tool is unreachable. Availability polls — list the open
  polls, create one, answer a candidate date, and close it plainly or
  by converting the winning date into an event. Assignments — the group
  task list, create/edit/delete, claim/release, complete/reopen, and
  the shared comment engine (ADR 0007). The decisions register — browse
  it, raise a motion (a feed post carrying the default For/Against/
  Abstain vote), and record the outcome. Global search — one
  community-scoped endpoint over posts, comments, events, and files,
  applying the exact listing-visibility and folder-permission narrowing
  the context already enforces, so a result never surfaces what the
  viewer couldn't already see. Every endpoint enforces authorization in
  the context, hides a resource the caller may not see behind a 404 (no
  oracle), carries a `viewer_can` capability list, and ships OpenAPI
  operations with inline conformance taps.
- Management and moderation over the JSON API (issue #183, the #165
  parity rung): the community-admin and instance-operator surfaces the
  client previously had no way to reach. Moderation — the open report
  queue, resolve (removes the content) or dismiss a report, community
  email bans (list/add/lift), and the append-only audit log. Community
  settings updates (name, slug, branding, locale, listing and
  real-name policy). Group management — create, edit settings, toggle
  features (ADR 0016), and archive/unarchive. Instance operator
  settings — read and update the singleton (community-creation policy,
  storage policy, default locale, instance name, content-minimized
  emails). Every endpoint enforces authorization in the context, hides
  rows the caller may not act on behind a 404 (no oracle), and ships
  OpenAPI operations with inline conformance taps.
- Management and moderation screens in the PWA (issue #183): the client
  side of the management rung — a moderation queue (resolve or dismiss
  reports, lift bans), community-settings and group-settings screens,
  and capability-gated entry links on the group page. Every control is
  shown only when `viewer_can` says it would succeed, so a stale
  capability degrades to a graceful "not allowed" rather than a broken
  button. EN + DA.
- Invite issuance is now rate-limited (issue #97): a community/group
  admin may send at most 20 email invites per hour, enforced in
  `Kammer.Invitations` before any row is written or mail delivered, so
  the invite endpoint can't be turned into an arbitrary-recipient
  email relay. A refused invite returns the `rate_limited` code (HTTP
  429). Link invites, which send nothing, are never limited.
- The last owner of a community can no longer demote themselves into
  an unrecoverable ownerless state (found by the #182 people-API
  review): `PUT .../members/{id}/role` refuses it with `422 last_owner`.

- Membership, profiles, and the member directory over the API (#182,
  part of the #165 parity ladder): invite issue/list/revoke and a
  public preview + accept flow that reports required custom profile
  fields still missing (ADR 0020); community and group membership
  lifecycle (leave, policy-aware group join, join-request queue, role
  changes, removals); the member directory with per-viewer field
  redaction and custom-field filters; own profile and per-community
  custom-field answers read/update; per-group notification levels; and
  device management — listing and revoking browser sessions and API
  device tokens by id, with revocation severing the device's live
  sockets (closes #174: API device tokens were invisible to their
  owner, and the devices page now lists and revokes them too). Group
  and community payloads gained `my_role`, `join_policy`, and
  join/request-to-join capabilities in `viewer_can`. In the PWA: the
  Groups tab became a per-community directory hub (visible groups with
  roles, plus Members/Invites links gated by `viewer_can`); a member
  roster screen with custom-field filters and admin role/removal
  controls; an admin invite screen (shareable links, email invites,
  revocation); the group page gained join/request-to-join, leave, and
  a per-group notification-level selector; and the You tab gained
  per-account Profile (base profile, contact visibilities, and each
  community's custom fields with the required-fields nag) and Devices
  (list + revoke) screens. EN+DA throughout.
- End-to-end Playwright coverage of the Svelte PWA (issue #235): the
  client-side counterpart to the LiveView smoke test
  (`scripts/screenshots.sh`), and the piece of test coverage the
  LiveView removal (#187) needs before it can proceed without losing
  browser-level assurance. `clients/web/e2e/global-setup.ts` builds
  and stages the client at `priv/static/app` (exactly where the
  Dockerfile's client stage puts it) and boots a real `mix phx.server`
  against a throwaway `kammer_dev` database — same-origin, so the
  suite exercises the deep-link sign-in flow's actual
  `window.location.origin` assumption rather than working around it
  with a second dev-server port. Three serial spec files cover what a
  first-time operator and a later anonymous visitor both do: the
  first-run setup wizard through to a magic-link sign-in
  (`01-onboarding.spec.ts`); posting with a file attachment, seeing it
  in the feed, creating an event, and RSVPing (`02-content.spec.ts`);
  and the tokenless public surface — anonymous browse of a public
  community/group/post/event, plus the guest comment and RSVP request
  forms landing on a neutral "sent" state (`03-guest.spec.ts`). A new
  `e2e` CI job runs the suite headless on every PR, structured like
  `smoke` (same Postgres service, Nix/Mix caching, and
  runner-provided Chrome) but skipping the LiveView asset-build step
  the Svelte client doesn't need. `scripts/e2e.sh` runs the same suite
  locally.
- Service worker, offline reading, and Web Push registration for the
  Svelte PWA (issue #186, the final parity-ladder rung before the
  LiveView removal cut, #187). `clients/web/src/service-worker.ts`
  uses SvelteKit's native `$service-worker` build support (no workbox
  dependency): it precaches the hashed build bundle, the static
  files, and the SPA shell (fetched explicitly — it isn't part of
  either, since `KammerWeb.PwaController` renders it per-request) into
  a version-named cache, serves precached assets cache-first and
  everything else network-first, and falls back to the cached shell
  only on a failed navigation (offline). New versions install silently
  and take over on reconnect/next-visible rather than mid-session
  (`$lib/pwa/register-service-worker.ts`), matching the LiveView
  side's "clients pick up new versions on reconnect" (SPEC §13).
  `endpoint.ex`'s `Plug.Static` `only:` allowlist gained
  `service-worker.js` (the one server-side touch this client work
  needed — the file wasn't in the allowlist, so it fell through to the
  SPA fallback and served HTML instead of the worker script) with a
  `no-cache` header so an HTTP cache can't mask a new build from
  `registration.update()`.
  Last-fetched-data offline reading (SPEC §14, full write queue stays
  #137) is a small `localStorage` snapshot per view
  (`$lib/offline/snapshot-cache.ts`) — the leanest option that
  satisfies "last data readable offline with a stale indicator"
  without an offline database or a service-worker-level HTTP response
  cache (which would also have to reason about auth headers and
  multi-instance CORS invalidation). Home, the Events tab, and a
  group's feed each fall back to their last snapshot when every added
  instance is unreachable, and show a calm `StaleBanner` while doing
  so.
  Web Push registration lives on a new per-instance You page
  (`you/[instance]/notifications`), wired to the existing
  `POST`/`DELETE /api/v1/push-subscriptions` endpoints (#173) via
  `$lib/push/api.ts`, and unregisters best-effort on sign-out
  (`revokeAndRemoveInstance`). Because a browser holds one push
  subscription per _origin_, and the PWA is instance-served (one
  origin per instance), push is only manageable from the instance
  actually serving the page — the settings page detects a mismatch and
  points elsewhere rather than offering a dead control.
  `service-worker.ts`'s `push`/`notificationclick` handlers and
  `$lib/push/notification-routing.ts` translate a notification's
  server-sent link (`Kammer.Notifications.push_payload/4`'s
  `{title, body, url}`) into the right in-PWA account/community route,
  landing on an already-open window via `postMessage` or a cold-opened
  one via a `notify` query param the root layout resolves (a service
  worker has no `localStorage` access, so it can't resolve instances
  itself). Actually subscribing is currently blocked on issue #251:
  `GET /api/v1/instance` exposes whether a server has push configured
  (`features.web_push`, unchanged) but not the VAPID public key
  `PushManager.subscribe()` needs — the settings page shows an honest
  "almost there" state instead of a non-functional button; the client
  plumbing (`subscribeToPush`/`unsubscribeFromPush`) already takes the
  key as a parameter and needs no further change once #251 lands.
  Manifest/icons (shipped earlier) were manually re-verified against
  an installability checklist: `start_url`/`scope` within the service
  worker's scope, 192/512/maskable icons, `standalone` display — no
  Lighthouse available in this container.

### Changed

- Test-suite prune (T2 of the #208 overhaul, per the #207 standard):
  ~30 ceremony/duplicate tests removed or merged across the LiveView,
  core-context, worker, and API suites — presentation trivia on the
  feature-frozen LiveView UI (empty states, link-visibility checks,
  "renders page" smokes, nav-click tests, `layouts_test.exs` and
  `error_html_test.exs` wholesale), phx.gen.auth Ecto boilerplate, the
  third/fourth duplicates of the #167 provenance rule, and the
  unfalsifiable `update_check_worker_test`. Defused the #187 coverage
  time bomb: tests for surviving HTTP endpoints (ICS calendar feeds,
  file download incl. anonymous denial, RFC 8058 one-click
  unsubscribe) moved out of death-row LiveView flow files into new
  `calendar_controller_test.exs`, `file_controller_test.exs`, and
  `newsletter_controller_test.exs`. The API conformance suite was
  restructured to the inline-tap idiom: `assert_operation_response`
  now taps the real interaction tests (posts/comments, events,
  notifications, push subscriptions, auth incl. the passkey ceremony —
  adding previously missing taps for `auth_register`,
  `auth_request_link`, and `auth_revoke`), and
  `schema_conformance_test.exs` shrank to the read-only operations
  with no other home. Several half-asserted tests were sharpened
  rather than deleted: the event-reminder test now proves the no-RSVP
  member got nothing, the reschedule test proves the new job carries
  the new start time and no stale email went out, the guest-RSVP token
  test now exercises a genuinely expired token, the recurrence
  cancel-authz test probes a fellow member instead of a stranger, and
  the User inspect test now actually sets the redacted `ics_token`.

### Fixed

- Configuration-layer hygiene, the no-decision "clear fixes" slice of
  the ADR 0027 audit (issue #234). `MIN_CLIENT_VERSION` now actually
  wires the advertised minimum-client-version knob to an env var
  (`config/runtime.exs`, documented in `.env.example`) — previously
  `Kammer.min_client_version/0` read a config key nothing ever set, so
  the advisory floor could never be raised without hand-editing
  source. The `"Kammer"` product-name default, previously re-typed at
  ten `Application.get_env(:kammer, :product_name, "Kammer")` call
  sites across the notifiers, digests, newsletters, and templates, now
  has one accessor (`Kammer.product_name/0`, mirroring
  `min_client_version/0`) that every call site uses instead. The
  endpoint's request-body length ceiling (previously a hardcoded
  `128_000_000`) now derives from `UPLOAD_MAX_MB` plus fixed multipart
  headroom via a small wrapper plug (`KammerWeb.Plugs.BodyParsers`),
  so raising `UPLOAD_MAX_MB` no longer requires the source edit
  `.env.example` used to ask operators for — `Plug.Parsers` bakes its
  options in at compile time, before `config/runtime.exs` runs, so the
  wrapper recomputes them per request instead. Several bare magic
  `limit:` literals (`Digests`, `Newsletters`, `Feed.list_home_feed/3`,
  `Events` past-events) are now named module attributes with a
  one-line rationale, and the duplicated 24-hour event-reminder lead
  time (`Kammer.Events` and `Kammer.Workers.EventReminderWorker` each
  hardcoded `-24, :hour`) is now a single `Kammer.Events.reminder_lead_hours/0`
  accessor both read, so they can't drift apart.

- Documentation drift between the spec/README/moduledocs and the
  shipped code (issue #93, round-2 quality audit). `SPEC.md` cited a
  nonexistent `RESTORE.md` (the restore walkthrough lives in
  `docs/backups.md`) and still called the cross-community **Home**
  feed "roadmap" though it shipped (`Kammer.Home`, ADR 0015); the
  README still listed passkeys as roadmap though they shipped (ADR
  0018). Four moduledocs cited the wrong SPEC section (Search →
  §10, digests → §9, not §16 "Architecture strategy"), `Notifications`
  implied digests were unbuilt, and `Kammer.Repo`/`Kammer.Mailer` had
  no `@moduledoc` despite CONVENTIONS requiring one on every module.

- Guest/setup public API hardening (issue #230, refining #185/#229 —
  the owner asked for a "do it properly" pass rather than matching the
  LiveView precedent). `POST /api/v1/setup/verify-token` is removed:
  it was a boolean oracle over the setup credential, and `complete`
  already validates the token itself before doing any work. `POST
/api/v1/setup` is now rate-limited per IP (10/hour, fixed — no
  config knob, same stance as every other security limit in
  `Kammer.RateLimit`), defense-in-depth for the one window in an
  instance's life with no operator around to notice abuse. The guest
  **management** link's token — long-lived, unlike the single-use
  confirm tokens — moved from a URL path segment
  (`/guest/manage/{token}`) to an `Authorization: Bearer` header
  (`/guest/manage`, read via `GuestController.fetch_manage_token/1`):
  a path segment for a credential that stays valid indefinitely was
  leaking into server/proxy access logs, browser history, and
  `Referer`. The emailed management link now carries the token in the
  URL fragment instead, which browsers never send to any server; a
  missing, malformed, or forged Authorization header answers the same
  neutral 404 an invalid token already did — checked before any
  request-shape validation, so the request body can never be probed
  without an authentic token. This covers the API surface; the
  recurring newsletter emails still carry a manage token in their URL
  and `List-Unsubscribe` header and are hardened separately. See ADR
  0026 for the full reasoning, including why the setup token and the
  single-use guest confirm links deliberately keep their current
  transport.
- Email-change confirmation is now rate-limited (issue #97,
  security-hardening pass): any signed-in user in sudo mode could loop
  the account-settings email form to send the branded confirmation to
  arbitrary addresses (an email-flooding relay) and pile up
  never-cleaned token rows. Issuance is now capped per acting user
  (five per hour) in the `Accounts` context, checked before any token
  is written or mail sent — a refused request does neither.
- The test suite no longer deadlocks intermittently on the
  instance-settings singleton (issue #215): the row is seeded once in
  the test bootstrap, so concurrent sandboxed transactions never race
  the lazy unique-index insert (production was already safe — the
  insert is an idempotent upsert).
- Deleting a folder that contained files crashed (a foreign-key
  cascade interplay deleted the folder's file entries out from under
  their stored files) instead of honoring the documented semantics —
  files fall back to the space root and outlive their folders. A
  migration releases folder contents on delete, and the same-name
  entry lookup now tolerates the sibling entries a release can create.
  Found by the new API folder-deletion success test (part of #208).

### Security

- Bumped `plug` (1.20.2 → 1.20.3) and `postgrex` (0.22.2 → 0.22.3,
  plus `db_connection` 2.10.1 → 2.10.2) to clear two newly-published
  advisories against the pinned versions: CVE-2026-56814 (plug's
  multipart `:length` limit not charged for part headers → unbounded
  temp-file creation, a DoS) and CVE-2026-58225 (Postgrex dollar-quote
  injection in the reconnect notification replay → notification DoS).
  Both had started failing `mix deps.audit` in CI on every branch.

### Added

- Trusted reverse-proxy support (issue #162, security-hardening pass):
  a new `TRUSTED_PROXIES` env var (comma-separated IPs/CIDRs) names
  the proxies allowed to speak for clients. When the TCP peer is on
  the list, the real client IP is recovered from `X-Forwarded-For`
  (rightmost address that isn't itself a trusted proxy) and rate
  limits key on it — signup, magic-link, sign-in-code, and guest
  budgets become per-client again instead of instance-wide behind a
  proxy. From anyone else the header is ignored entirely, so a client
  that reaches the port directly can't spoof its way past the limiter;
  unset (the default) ignores the header everywhere, and an invalid
  entry fails the boot. Applied on both the plug pipeline
  (`conn.remote_ip`) and the LiveView socket path (`peer_data` +
  `x_headers` connect info). Hand-rolled rather than the `remote_ip`
  hex package (SPEC §22's minimal-internal-version rule): that
  library honors forwarding headers without ever checking the peer
  and treats all private/loopback ranges as implicit proxies — the
  two defaults #162 exists to close.
- Product-version surface (issue #204, implementing the ratified
  versioning strategy #203): `mix.exs` is now the explicit single
  source of truth (`0.1.0-dev` until the first tagged release),
  exposed at runtime through `Kammer.version/0` so no code hardcodes
  it, and the public `GET /api/v1/instance` capability endpoint —
  already the RFC 0001 discovery surface carrying `version` and
  `api_versions` — additionally reports `min_client_version` (null
  until a release needs to fence out old clients), the foundation the
  future native-app handshake (#131) negotiates against. The PWA's
  You screen shows each signed-in instance's server version as a
  small about line (EN + DA).
- Client test additions from the test-suite audit (#208): XSS
  assertions for `renderInlineMarkdown` — the comment-body route into
  the PWA's single `{@html}` sink previously had zero sanitization
  tests (raw-HTML escape, `javascript:`/`data:` link refusal, image
  downgrade, no block elements); the untested rollback mirrors —
  `feed-store.reactComment`'s conditional rollback (clean and
  echo-intervened branches) and the event store's optimistic RSVP
  snapshot rollback (new `event-store.spec.ts`); the feed store's
  load-401 → `noteInstanceAuthFailure` socket bridge (mock was wired
  but never asserted); and the one no-observable-assertion test
  (`requestLink` "resolves on success") strengthened to assert the
  POSTed endpoint, method, and body.
- Per-viewer capabilities on the API (issue #199, groundwork for the
  #165 parity ladder): serialized posts, groups, and communities now
  carry a `viewer_can` array naming the action-oriented rights the
  calling viewer actually has — `edit`/`delete`/`pin`/`moderate` on a
  post; `post`/`moderate`/`manage_group`/`manage_members`/`create_event`/
  `upload_file` on a group; `manage_community`/`create_group`/
  `view_member_directory` on a community — so clients can hide controls
  a viewer would only be `403`ed on click, instead of offering them to
  everyone. Each capability is computed from the same pure
  `Kammer.Authorization` decision the controllers enforce (present IFF
  the action would succeed) and adds no queries: controllers thread the
  viewer's already-loaded relationship into the serializer. Consumed by
  the #179/#182/#183 client screens (client wiring is a follow-up; the
  API field is the enabler).
- Test-suite audit follow-up, security & correctness batch (part of
  #208, per the #207 standard): domain-level authorization negatives
  that were the only gate on their function (`Feed.approve_post/2`,
  `Files.delete_file/2`); an archived-groups property extension over
  the membership/admin actions (`join`, `request_to_join`,
  `create_group_invite`, `approve_group_members`) whose archived
  guards no test protected; a home-feed test pinning that pending and
  scheduled posts never leak into other members' home feeds; four
  promise-breaking tests made real (the decisions register-entry
  rollback is now actually induced, the `User` inspect test now sets
  the one redacted field, the ADR 0016 gate test now queries the
  unauthorized viewer and the nonexistent-event surface, the passkey
  stale-sign-count test is named for the bookkeeping rule it pins);
  GDPR coverage for the export controller route and for
  comments/uploaded files in the export zip plus comment anonymization
  on erasure; API coverage for folder-deletion success and
  `DELETE /events/{e}/slots/{slot_id}` with their conformance taps;
  pagination edge-contract assertions (garbage cursor, absurd limit);
  an events-write leg on the transport-parity property; and
  `file_versions_test.exs` no longer runs async while swapping the
  global uploads path.
- Files over the API and in the Svelte PWA (issue #181, part of the
  #165 parity ladder): a group's file library — browse folders and
  files with breadcrumb navigation, upload new files, re-upload the
  same name as a new version (ADR 0017), view version history and
  download any version, and — for managers — create/delete folders,
  set the read/write preset overrides (ADR 0009), and delete files and
  versions. New `/api/v1/communities/{c}/groups/{g}/files` and
  `/folders` endpoints (list/detail/upload/version-upload/delete plus
  folder create/override/delete), all through the existing
  `Kammer.Files` context so the folder-permission invariant is enforced
  in one place; genuinely no-oracle (a folder or file the caller can't
  see 404s to every verb, never 403), the per-file size/quota 413 is
  honored on this path too, and the group file space stays behind the
  files feature toggle (ADR 0016). A files screen inside each group in
  the PWA — folder browsing, upload, version history, download — with
  EN + DA, dark mode, reduced-motion, and AA contrast.
- Events over the API and in the Svelte PWA (issue #180, part of the
  #165 parity ladder). API: full write parity under `/api/v1` —
  create an event or a bounded recurring series
  (`POST .../groups/{group}/events`, ADR 0019; the response is the
  first occurrence carrying its `series_id`), edit this occurrence and
  delete (creator/moderator), per-occurrence cancel/reinstate
  (`.../cancellation`), signup slots (add/delete for managers, claim
  and release for members — a full slot refuses with `422 slot_full`,
  never overbooking), and the shared comment engine on events
  (create/edit/delete/react). The `Event` serializer now carries
  `series_id`, `cancelled`, `comments_locked`, and its `comments`. PWA:
  an Events tab (merged upcoming events across every account, bucketed
  by community with filter chips and a calm agenda view) and an
  addressable event detail screen (RSVP with counts, signup slots,
  comments, an "add to calendar" ICS link) with a create/edit form for
  authorized roles. EN + DA, dark mode, reduced-motion, AA. Every event
  write is genuinely no-oracle (issues #156/#161): an event the caller
  can't see answers `404` to every verb, never `403`, so a hidden event
  is indistinguishable from a nonexistent one.
- Reacting to a comment that hangs off an event or an assignment no
  longer crashes (`Kammer.Feed.toggle_reaction/3` resolved the host
  group only for post comments); it now resolves the group for every
  comment kind via `comment_context/1`, and only post reactions
  broadcast a feed update.
- Home and group feed screens in the Svelte PWA (issue #179, part of
  the #165 parity ladder): the merged, community-first Home (recent
  activity and upcoming events across every added account, bucketed by
  community with filter chips and per-instance failure banners) and the
  group feed — chronological/activity sort, pinned-first, cursor
  pagination, and live updates over the #173 realtime channels. Full
  write parity on the #178 endpoints: a composer with Markdown (safely
  rendered — `html: false`, link-validated, the one audited sink),
  poll builder, attachment upload, and acknowledgment-required toggle;
  reactions and poll votes are optimistic, post creation is
  post-then-insert (id-keyed against the channel echo); one-level
  comment threads with compose/reply/react/edit/delete. Per-instance
  realtime sockets with manager-owned exponential-backoff reconnect
  that surfaces auth failure for re-sign-in. EN + DA, dark mode,
  reduced-motion, AA contrast throughout.
- Feed write parity over the API (issue #178, part of the #165 parity
  ladder): reaction toggles on posts and comments; poll voting
  (`PUT .../poll/votes` sets the whole selection — single-choice keeps
  the first id, empty list unvotes) with the poll's `my_votes` in the
  response; post create takes a real, documented `poll` object
  (options in list order) and `stored_file_ids`; acknowledgments
  (`PUT .../acknowledgment`, idempotent) plus the author/admin
  acked-vs-pending view (`GET .../acknowledgments`); post edit
  (author), delete (author soft-deletes to a tombstone, moderators
  hard-delete via `?hard=true`), pin/unpin (moderators); comment edit
  (author, new `Feed.edit_comment/3` — comments now carry an
  `edited_at` marker) and delete; multipart feed-attachment uploads
  (`POST .../uploads`, same context path as the LiveView composer) and
  Bearer-authorized file serving (`GET /api/v1/files/{id}`,
  `/thumbnail`, `/download` — the browser file routes' logic extracted
  into a shared `FileServing` module). Serializer/schema growth: post
  `attachments`/`my_reactions`/`my_acknowledged`/`acknowledged_count`,
  comment `edited_at`/`reactions`/`my_reactions`, poll `my_votes`
  (issue #81). Every write goes through the same context functions and
  authorization as LiveView; writes address posts via
  `fetch_visible_post`, so an invisible post answers 404 to every verb
  (issue #156's no-oracle rule extended to writes per issue #161,
  covered by a write-parity property test). Context hardening the raw
  transport demanded: feed uploads now require posting rights in the
  context, `stored_file_ids` must be the author's own uploads into the
  group (422 otherwise), and locked-comment/closed-poll refusals carry
  stable `comments_locked`/`poll_closed` error codes (422, previously
  a generic 400).
- PWA-native sign-in, server side (issue #177, ADR 0024): API-initiated
  sign-in emails now deep-link into the instance-served PWA
  (`/app/sign-in/{token}`) and carry a short single-use sign-in code
  (8 characters, Crockford base32 without lookalikes, 15-minute
  lifetime, hashed at rest) for cross-device sign-in —
  `POST /api/v1/auth/exchange` accepts `email` + `code` as an
  alternative to `magic_token`, with per-email and per-IP attempt
  rate limits so the short code cannot be brute-forced. Passkey
  sign-in lands in the API too: `POST /api/v1/auth/passkey/challenge`
  (usernameless WebAuthn assertion options with a signed, short-lived
  challenge token — no account-enumeration surface) and
  `POST /api/v1/auth/passkey/verify` (assertion → device token, same
  response shape as exchange). The web-initiated LiveView flow is
  unchanged (bugfix-frozen per ADR 0024); passkey _registration_
  stays web-only until the You-tab arc (#182). All new operations are
  in the OpenAPI document with conformance tests; sign-in emails are
  localized EN + DA.
- Instance-served PWA (issue #176, ADR 0024): the release image now
  builds the Svelte client (new Dockerfile client stage — node/pnpm,
  `pnpm build`) and serves it at `/app` on the instance's own domain,
  from `priv/static/app`. Real files come off `Plug.Static`; everything
  else under `/app` falls back to the client's `index.html`, so
  client-side routes — most importantly the magic-link landing
  `/app/sign-in/{token}` — deep-link straight into the SPA. LiveView
  keeps `/` until the removal cut (#187); the mount point is a single
  config key (`:pwa_base_path`) matched by the client's `paths.base`.
  A dev server without a built bundle answers `/app` with a plain-text
  pointer to the client dev workflow instead of a 500, and the Docker
  workflow's boot check now asserts the shipped image serves the PWA
  (base path and deep link both).
- Svelte client foundation (issue #32, ADR 0024): the design system in
  SPEC §21's language (warm neutral palette, hairline borders, typed
  UI primitives) with first-class light/dark themes (system-following
  plus a persisted manual override, no flash on load) and global
  `prefers-reduced-motion` support; the app shell — bottom tab bar on
  mobile, sidebar on desktop, five tabs with calm empty states and a
  route guard back to sign-in; EN + DA i18n from day one (typed
  message catalogs — a missing Danish key is a compile error); the
  full sign-in flow (instance URL probe → request link → paste the
  link/token or follow a `/sign-in/{token}` deep link → exchange),
  per-account sign-out from the You tab, plus theme and language
  controls. Hardening from review findings: the instance store gets a
  versioned envelope with per-element validation and v0 migration
  (issue #158), merged-home failures carry an `auth`/`network`/
  `server` kind so screens can tell "sign in again" from "retry"
  (issue #159), and the vacuous client tests were replaced with real
  ones — including Authorization-header and store-migration coverage
  (issue #160). Installability lands too: a geometric "K" mark (any +
  maskable SVG) and a completed manifest close issue #145.

- Realtime and notifications for the JSON API (issue #30, ADR 0014):
  Phoenix Channels at `/api/socket` authenticated with the same device
  token as REST — `feed:group:<id>` re-exposes the live feed events
  LiveViews already react to (join gated by the same `:view_group`
  decision, every payload re-fetched per viewer so pending/scheduled
  posts never leak) and `notifications:user:<id>` streams the owner's
  notifications, both shaped by the one REST serializer. New endpoints:
  `GET /api/v1/notifications` (cursor-paginated, newest first),
  `PUT .../notifications/{id}/read` and `.../notifications/read-all`
  (foreign ids answer 404 — no existence oracle), and
  `POST`/`DELETE /api/v1/push-subscriptions` mirroring the browser's
  Web Push registration flow, upsert semantics included. All operations
  documented in the OpenAPI contract with response-conformance tests.
- OpenAPI schema-conformance tests (issue #151, audit-driven): every
  API operation's real response is now validated against its
  documented schema (`schema_conformance_test.exs`), closing the gap
  where the route-level drift test couldn't see field-level lies —
  which is exactly how #154 went unnoticed. Also extracts the shared
  `api_conn/1` test helper (`KammerWeb.ApiHelpers`).
- CORS on the JSON API (issue #150): `/api/v1` now answers cross-origin
  requests and preflights with `Access-Control-Allow-Origin: *` by
  default — required for the multi-instance Svelte client (any web
  client at any domain) and safe because API auth is a Bearer device
  token, never a cookie. Instances that want a tighter policy can set
  `API_ALLOWED_ORIGINS` to a comma-separated origin allow-list. The
  browser-facing LiveView app keeps the same-origin default.
- Svelte PWA client scaffold at `clients/web/` (issue #32, ADR 0001):
  SvelteKit + TypeScript, static adapter in SPA mode (`ssr = false` —
  the client is a pure session-holder talking to remote instances,
  nothing server-rendered), Tailwind CSS, Prettier/ESLint, Vitest, and
  a minimal PWA manifest. Own CI job (`web-client`, node/pnpm), not
  inside the Elixir gate; root Prettier excludes `clients/web/` since
  it owns its own config/plugins (Svelte, Tailwind class sorting).
  Screens (sign-in, merged home, community list, group feed, events)
  land in follow-up PRs — this is tooling-only, verified to
  lint/typecheck/test/build clean.

- Generated TypeScript API client for the Svelte PWA (issue #32):
  `openapi-typescript` types generated from `/api/v1/openapi.json`
  (`clients/web/src/lib/api/schema.d.ts`, regenerated via
  `pnpm run generate:api`, never hand-edited) plus a thin
  `openapi-fetch`-based wrapper (`client.ts`) creating one typed
  client per added instance, per ADR 0001's multi-instance model.

- Multi-instance session-holder core for the Svelte PWA (issue #32, ADR
  0001): `clients/web/src/lib/instances/` — `instanceStore` (localStorage-
  backed, dedupes by instance+account so re-authenticating replaces
  rather than duplicates), `probeInstance`/`requestLink`/
  `exchangeAndAddInstance`/`revokeAndRemoveInstance` (the sign-in and
  device-token lifecycle), and `fetchMergedHome` (client-side merging of
  `GET /home` across every added instance, timing out per-instance so one
  unresponsive server can't hang the whole merged view). No screens yet —
  this is the session/data layer they'll be built on.

- OpenAPI response schemas for `/api/v1/instance`, `/api/v1/auth/*`, and
  `/api/v1/home` (previously untyped `object()` placeholders generating
  `Record<string, never>` in the TypeScript client — silently defeating
  the "generated, not hand-written" client for exactly the endpoints a
  multi-instance client needs first). New `Schemas.Instance`,
  `Schemas.AuthUser`, `Schemas.AuthRegisterResponse`,
  `Schemas.AuthExchangeResponse`, `Schemas.StatusResponse`,
  `Schemas.HomeGroupSummary`, `Schemas.HomeResponse`.

- `POST /api/v1/auth/register` (issue #30): the API can now create
  accounts, not just authenticate existing ones — mirrors
  `UserLive.Registration` exactly (same changeset, same per-IP rate
  limit, same confirmation-email step). Registration decision
  resolved: open, not web-only — the Svelte PWA and native apps need
  full sign-up parity with the web UI. `/api/v1/instance`'s
  `features.registration` flag flipped from `"web_only"` to `"open"`
  accordingly.

- File search + text extraction (SPEC §10/§16): the last piece of
  global search — files now surface by filename and by extracted text
  (PDF via `pdftotext`, plaintext read directly), alongside posts,
  comments, and events. Text extraction runs off the request path in
  an Oban job after upload, with a graceful skip for unsupported
  content types. File search rides the folder-permission invariant
  (ADR 0009) exactly: a loose SQL/full-text candidate query is
  narrowed in Elixir with the same `Authorization.can_read_folder?/4`
  decision `Kammer.Files.list_files/3` uses, so a file in an
  `admins_only` folder never surfaces regardless of its group's
  visibility.
- Newsletter subscriptions (SPEC §8, ADR 0013 extended): anonymous
  visitors on a public group's page can subscribe by email — no
  account, double opt-in through a signed confirm link, choice of
  every-post or a daily/weekly digest. Delivery reuses
  `Kammer.Digests`' cadence math (own Oban worker, offset cron tick)
  and every email carries a one-click `List-Unsubscribe` header (RFC 8058) alongside a signed management link that reuses the existing
  guest-manage page — change cadence or unsubscribe, same as changing
  a guest RSVP.
- Report surfaces on event and assignment pages (SPEC §11): the
  "Report to the moderators" flow the group feed already had for
  posts and comments now covers comments on events and assignments
  too — `Kammer.Moderation.report_comment/3` already handled any
  comment regardless of subject, only the UI was missing. The modal
  and its handlers moved into a shared
  `KammerWeb.KammerComponents.report_modal/1` /
  `KammerWeb.ReportHandlers` pair, so all three pages use the
  identical code path instead of three copies. Closes out HANDOFF's
  Moderation gaps backlog.
- Instance-wide bans (SPEC §11): operators get a second, broader ban
  list at `/instance/moderation` — ban an email instance-wide and it
  can't rejoin _any_ community on the instance, not just one. Same
  choke-point as the existing community ban
  (`Communities.add_member/3`), same email-keyed design (blocks a
  future signup too, not just an existing account), same two-step
  "demote before you ban" rule for other operators. Banning an
  existing account strips its memberships everywhere at once; each
  affected community's own admins see the removal in their audit log.
- Activity-sort feed view (SPEC §5, ADR 0006): an opt-in "Activity"
  ordering next to the default "Newest" on every group feed and the
  Home feed — posts bump to the top on their newest comment,
  forum-style, while pins still sort first. Chronological stays the
  default everywhere; no ranking, no algorithm, exactly the one
  alternate ordering ADR 0006 always reserved. Persists per user,
  toggled from a small dropdown right on the feed.
- Custom profile fields and the member roster (SPEC §4, ADR 0020):
  community admins define fields (text or single-choice) from
  `/c/:slug/settings` — "Instrument", "Section", "Dietary needs" —
  each with members/admins-only visibility and an optional `required`
  flag. Required fields hard-block at invite acceptance (a small
  "before you continue" step collects them); making an existing field
  required only nags already-joined members with a banner and a link,
  never a lockout. The member directory shows each person's visible
  answers and gains filter dropdowns for single-choice fields — the
  roster. Also added personal profile fields (bio, pronouns, and
  phone/email/other contact info, each independently
  hidden/members/admins-only, hidden by default) under Account
  settings.
- Content-minimized email mode (SPEC §9, ADR 0011): an operator-only
  instance toggle at `/instance/settings` — new, linked from the
  instance home — that strips digest emails down to a per-group post
  count and a link, dropping author names and post excerpts.
  Per-event notification emails never carried post content in the
  first place, so they're unaffected; auth and RSVP emails stay
  exempt by construction.
- RSS and Atom feeds for public groups (SPEC §8): every `public_link`
  and `public_listed` group exposes `/feed.rss` and `/feed.atom` —
  no account, no secret token, gated by the same visibility check the
  group page itself already uses. A feed reader can follow a group's
  posts without ever creating a Kammer account. Linked from the group
  page for anyone who can see it.
- Admin update notice (HANDOFF §5.6): a daily, privacy-respecting
  check against this project's GitHub releases surfaces "a newer
  Kammer exists" to instance operators on the home page — no payload
  beyond the version fetch, and `DISABLE_UPDATE_CHECK` turns it off
  entirely.
- Recurring events (SPEC §6, ADR 0019): weekly, biweekly, or monthly
  series bounded by an end date, up to 52 occurrences. Each occurrence
  is a real, independent event — RSVP, comment, and add it to your
  calendar exactly like a one-off — so cancelling or moving a single
  date needs no special handling. A new series page lists every
  occurrence with cancel/restore and shows the organizer attendance
  matrix (members × upcoming instances).
- Passkeys (SPEC §16, ADR 0018): register a WebAuthn credential from
  the Devices page after your first magic-link login, then sign in
  usernameless — your device's fingerprint, face, or screen lock, no
  email hop. Built on Wax; registration and sign-in verify entirely
  server-side, with a hand-crafted-ceremony test suite exercising the
  real cryptographic path (no browser required). Losing every passkey
  still leaves magic-link sign-in as the fallback.
- Audit log (SPEC §11): every role change, ban/unban, content removal
  via moderation, settings update, and group deletion — plus
  community-admin overrides into groups, flagged as such — writes a
  plain-language entry to an append-only log, visible to community
  admins from the moderation page. Entries stay readable even after
  the row they describe is gone; the log is deliberately not itself
  audited and never blocks the action it records.
- GDPR data rights (SPEC §12): download everything the instance
  stores about you — profile, posts, comments, reactions, poll
  votes, RSVPs, signup and assignment claims, availability answers,
  and every file you uploaded — as one zip, and delete your account
  self-serve. Deletion removes your identity and personal records
  immediately; your posts and comments stay so group history isn't
  broken, shown as "Deleted user". Both live under Account settings.
- Moderation: a Report action on every post and comment (one open
  report per person per subject — signal, not spam), a queue for
  community admins and group moderators with dismiss /
  remove-content actions, and community bans — banning removes the
  member everywhere and blocks their email from rejoining through
  any invite until the ban is lifted.
- Email digests, strictly opt-in: choose daily or weekly (Mondays)
  under Account settings and get one calm summary — new posts across
  your groups and the coming week's events, in your language and
  timezone. Quiet periods send nothing: no email is better than a
  hollow one.
- Backups (SPEC §14): `mix kammer.backup` /
  `Kammer.Release.backup/1` write a restorable snapshot — pg_dump
  custom-format database dump plus an uploads tarball — with optional
  age encryption and per-kind retention pruning. Setting `BACKUP_DIR`
  turns on a nightly scheduled backup. Restore guide (both
  directions, verified steps) in docs/backups.md.
- Global search within a community: one box over posts, comments, and
  events (Postgres full-text, mixed-language friendly), filtered by
  the same listing visibility as everything else — property-tested so
  search can never surface content from a group you couldn't see
  listed. Anonymous visitors search exactly the public face. File
  search follows once it can ride the folder-permission invariant.
- Decisions register (per-group toggle, off by default): raise a
  motion — it lands in the feed as a post with a For / Against /
  Abstain vote — then record the outcome (adopted, rejected, noted,
  with a note for the record). The register lists every motion and
  outcome chronologically: minutes-grade institutional memory, built
  for board groups.
- Assignments (per-group toggle, off by default): a flat task list —
  open, claimed, done, nothing else. "Who takes this?" is one tap,
  several people can hold the same assignment, anyone can mark it
  done (the record shows who), and each assignment carries a
  discussion thread through the same comment engine as posts and
  events. No boards, no sprints — associations run on lists.
- Availability polls ("Date finding", per-group toggle, off by
  default): propose candidate dates, members answer yes / if needed /
  no on a shared grid, and closing the poll turns the winning date
  into a real event with one click. Open polls appear on the events
  page under "Finding a date".
- Signup slots on events: "bring cake ×2, drive ×4" — event managers
  add capacity-bounded slots, members claim with one tap (never
  overbooked, enforced in the database), and on public events guests
  sign up through the same email-confirm flow as guest RSVPs. The
  guest management page lists signups next to RSVPs and comments; the
  API event detail now includes slots.
- Guest comments on posts in public groups that opt in
  (`members_and_guests` comment policy): name + email + comment, a
  signed confirm link (nothing stored until followed — the comment
  travels inside the link), then a moderator approval queue inline in
  the feed. Pending comments are invisible to everyone but moderators.
  The guest management page now lists everything a guest created —
  RSVPs (changeable) and comments — with one-click full erasure, and
  signing in with the guest's email claims comments too.
- OpenAPI document at `GET /api/v1/openapi.json` — the machine-readable
  API contract clients generate from, drift-guarded against the router
  by a test.
- Versioned files: uploading a file with the same name into the same
  folder now creates a new version instead of a duplicate. Every file
  has a browsable version history (uploader, time, size, download);
  single versions can be deleted (never the last); deleting the file
  removes all versions. Admins can cap history per space ("versions to
  keep"); default is unlimited.
- JSON API v1 (`/api/v1`): passwordless device-token auth (request
  link → exchange → revoke, tokens hashed at rest and revocable from
  the devices page), instance capability discovery, communities and
  groups, cursor-paginated group feeds with post and comment creation,
  events with RSVP, and the merged Home — every route through the same
  authorization module as the UI, with a property test guaranteeing
  API/UI visibility parity.
- Home: the instance start page now merges upcoming events and recent
  activity across all communities and groups you belong to — strictly
  chronological, read-only, with a per-group "Show in Home" switch on
  every group page (on by default, sealed groups included).
- Per-group feature toggles: group admins choose which tools the group
  shows (events, files; the feed is always on). Turning a feature off
  hides it everywhere — navigation, listings, ICS feeds, guest RSVP —
  without deleting anything; turning it back on restores everything.
- Guest RSVP on public events (no account needed): name + email, a signed
  confirm link that records nothing until followed, a confirmation email
  with calendar file, and a management link to change the answer or erase
  all guest data. Signing in with the same email claims guest history
  automatically. Event pages in public groups are now viewable without an
  account.
- Passwordless authentication: magic-link sign-in, revocable sessions with
  device overview, rate limiting on link requests.
- Communities and groups: multi-tenant instance, community switcher, four
  group visibility presets, join/posting/comment policies, roles, invite
  links, post-as-group, irreversible sealed groups, archiving.
- Central authorization module (`Kammer.Authorization`) with property-based
  test suites for the permission matrix, sealed-group semantics, and the
  file-visibility invariant.
- Feed: Markdown posts, image uploads (re-encoded, metadata stripped,
  thumbnailed), polls, attachments, emoji reactions, single-level comments,
  mentions (@everyone/@admins/display names), pinned + scheduled +
  acknowledgment-required posts, edit history, soft-delete with 30-day
  purge, live updates.
- Events: timezone-aware single/all-day/multi-day events, RSVP, comments,
  email reminders with ICS attachments, secret-token ICS feeds per group
  and per user.
- Files: community and group spaces, shallow folder trees, permission
  presets under a centrally enforced visibility invariant, auto-collections,
  local-disk and S3-compatible storage, optional quotas.
- Notifications: in-app center, email, and Web Push behind a pure §9
  channel matrix ("highlights" defaults; broadcast groups default to
  everything); per-group levels.
- Hybrid first-run setup: env-always-wins boot initialization, token-gated
  /setup wizard (operator, instance settings, first community/group,
  invite link, SMTP-testing magic link), permanent lock, optional
  one-click-removable demo community.
- Legal pages (privacy policy, imprint) with built-in templates, public
  routes, operator editing, and an operator nag until published.
- `/healthz` endpoint; Docker/compose deployment (app + Postgres +
  optional MinIO) with migrations on boot.
- English and Danish translations throughout, including emails.
- Repository automation: Dependabot with gated auto-merge, CodeQL,
  dependency review, Gitleaks, Docker image CI publishing to GHCR, an
  importable branch-protection ruleset, CODEOWNERS.
- Reproducible dev environment: Nix flake (canonical), direnv `.envrc`,
  `devbox.json` — one toolset, three entry paths, reused by CI.
- Phoenix 1.8 application scaffold (LiveView, Tailwind, UUID primary keys).
- Engineering-standards toolchain: mix format, Credo strict (incl. custom
  single-letter-variable ban), Dialyzer, Sobelow, ExCoveralls with coverage
  floor, mix_audit/hex.audit, lefthook hooks, commitlint, GitHub Actions CI,
  warnings-as-errors.

### Changed

- Strategic pivot recorded (ADR 0024): the Svelte PWA —
  instance-served by the Phoenix release at each instance's own
  domain — is the product UI, and LiveView is a first-build stepping
  stone to be removed from the repo entirely, not a permanent
  companion (LiveView can't do offline mode or multi-instance
  session-holding/merging, the two capabilities the product depends
  on). LiveView is feature-frozen as of now (bugfixes only) while
  the PWA climbs a surface-by-surface parity ladder — each surface
  ships with full write parity, including its missing API
  endpoints — with removal in one cut at full member+admin+guest
  coverage. SPEC.md §1/§16/§21 corrected to stop describing LiveView
  as the product UI (and §21 gains the community-first IA
  principle); AGENTS.md now carries the freeze policy so future
  sessions don't add LiveView features. Transition umbrella: #165.
- SPEC.md §16's "explicit non-goals" list corrected: native apps,
  offline support (beyond v1 app-shell caching), chat/DMs, E2EE, event
  ticketing/capacity/waitlists, collaborative document editing, video
  upload, storage billing, and group type templates were listed as
  permanently excluded when they're actually owner-confirmed future
  roadmap — several (native apps, offline support, group type
  templates) were oversold exclusions even by the ADRs that originally
  scoped them narrower. Each now has a tracking issue (#131–#138, #22)
  and the list itself is corrected so it stops reading as settled
  product philosophy. ActivityPub was considered directly and stays
  excluded, now with documented reasoning (ADR 0023) instead of a bare
  mention. Native apps' and offline support's re-scoping is recorded
  in ADR 0022, amending ADR 0012. See AGENTS.md's new "Product scope
  changes" section for the process fix this prompted.

### Removed

- 10 unused "valid values" accessor functions with zero call sites
  anywhere in the codebase (`Folder.overrides/0`,
  `Notification.kinds/0`, `NotificationPreference.levels/0`,
  `EventSeries.frequencies/0`, `CustomField.field_types/0`,
  `CustomField.visibilities/0`,
  `InstanceSettings.community_creation_policies/0`,
  `Decision.outcomes/0`, `AvailabilityResponse.answers/0`,
  `User.visibilities/0`), and trimmed two `phx.new`/`phx.gen.auth`
  generator boilerplate example comments (`user_auth.ex`,
  `error_json.ex`) down to their essential guidance. Found by the
  full-codebase audit (#78).

### Fixed

- Config and env-var errors now fail the boot loudly instead of being
  silently absorbed (issue #98, security-hardening pass): `PHX_HOST`
  is enforced in prod like `SECRET_KEY_BASE` (a forgotten value used
  to silently ship `example.com` sign-in links in real emails);
  env-provided instance settings go through the same
  `InstanceSettings.changeset/2` validation as the setup wizard, so a
  `DEFAULT_LOCALE` typo can no longer be persisted unvalidated and
  fed to Gettext; unrecognized `COMMUNITY_CREATION_POLICY` /
  `STORAGE_POLICY` values raise exactly like `MAILER_ADAPTER` /
  `STORAGE_ADAPTER` typos always have (they used to be dropped with
  no log at all); an `OPERATOR_EMAIL` that can't become an account
  raises instead of vanishing; and the boot-time settings application
  now runs synchronously, so those raises actually stop the boot
  rather than crashing a background task the supervisor shrugs off.
  Also from #98: `OPERATOR_EMAIL` (with its auto-promote side effect
  called out) and `STORAGE_POLICY` are documented in `.env.example`
  at last, and `Kammer.Backups` asks `Kammer.Storage.adapter/0`
  instead of reading the raw config key — an unset adapter means
  local storage there too, so `mix kammer.backup` outside a prod
  release archives the uploads tarball instead of skipping it with a
  misleading "S3" message.
- Group-authored posts no longer leak the human author's identity on
  the remaining web surfaces (issue #167, found by independent review
  of #166) — completing #153's closure across all transports: search
  results and the signed-in home's recent activity now dispatch on
  `author_type` like the feed does, and notifications for as-group
  posts render the group as actor everywhere the fanout's
  `actor_user_id` used to surface — the in-app notification center
  (name and avatar), the JSON API's notification serializer, and the
  email/push summary line ("Board posted", not "Alice posted").
  Rendering-side dispatch keeps `post.author_type` the single source
  of truth (matching digests and newsletters) and retroactively fixes
  already-stored notification rows. Regression tests per surface.
- Three residual holes in the ban enforcement family #129 hardened
  (issues #170, #171, #172, found by independent review of #169):
  `Communities.add_member/3` re-checks the community and instance ban
  lists inside its own transaction against the row-locked
  (`FOR UPDATE`) user — the same lock the ban paths take first — so a
  concurrent ban and invite redemption serialize instead of leaving a
  banned email holding a live membership; `Moderation.ban_member/4`
  re-reads the target's email from that locked user row, so the ban
  records the current address rather than a stale struct snapshot the
  target could sidestep by having changed their email; and
  `Communities.create_community/2` now consults the instance-ban list
  (under the same lock) and refuses with `:unauthorized`, closing the
  path where a banned account's surviving session could create — and
  own, unpurgeably — a fresh community. All membership-writing
  transactions now share one lock order (user row → community
  membership → group memberships), so none can deadlock another. The
  races themselves aren't reproducible under the test sandbox; new
  context tests pin the observable contract (current-email bans,
  banned-email refusals against stale structs, banned-creator
  refusal).
- The moderation ban guards checked the target's protected status
  (admin/owner role for `Moderation.ban_member/4`, operator flag and
  community ownership for `Moderation.ban_instance/3`) before their
  transactions rather than inside them (issue #129, found by
  independent review of #128) — a concurrent promotion or ownership
  transfer landing in that window could let a ban proceed against a
  freshly protected target, in the instance-ban case reintroducing
  the owner-purge bug #122 fixed, via a race instead of a stale
  guard. Both guards now run inside the ban transaction against
  row-locked (`FOR UPDATE`) user/membership rows, making check and
  act atomic, and `Communities.remove_member/3` now deletes the
  community-membership row before the group rows so every
  membership-removing transaction acquires locks in the same order
  (the reverse order could deadlock a concurrent ban + remove of the
  same member). The race window itself isn't reproducible under the
  test sandbox; the new context tests cover the observable contract
  around it (current-role reads, no-partial-purge refusal, rollback
  of the membership removal when the ban insert fails).
- Group-authored posts no longer leak the human author's identity
  through the API (issue #153): the feed queries never preloaded
  `:group`, so the serializer fell through to the user clause and
  exposed the real name/id that posting "as the group" exists to
  hide. `/home` and the group feed now serialize the same author.
- The OpenAPI document no longer misdescribes single-object responses
  as arrays (issue #154): `posts_create`, `comments_create`, and
  `events_show` now use a single-object envelope, the RSVP response
  documents its actual `{event_id, status}` shape, and
  `acknowledgment_required` is a boolean, not a string. Error
  documentation is also truthful now: every operation documents
  401/403/404, and writes additionally 422/429 — previously only
  401/404 appeared despite 403s being designed behavior. The generated
  TypeScript client (`clients/web/src/lib/api/schema.d.ts`) is
  regenerated — its types were actively wrong for these operations.
- A malformed post id in API comment creation is a 404, not a 500
  (issue #155): the id is UUID-cast before querying instead of raising
  `Ecto.Query.CastError`.
- Comments can no longer target unpublished posts (issue #156):
  `Feed.create_comment` now enforces the same visibility rule as the
  feed queries — a pending-approval post takes comments only from its
  author or a moderator, a scheduled post only from its author, and
  the refusal is a 404 (an invisible post answers exactly like a
  nonexistent one, no existence oracle). Previously any member who
  learned a post UUID could comment on a moderation-queued post
  through the API before it was visible. The created comment also
  returns its author populated instead of `"author": null`.
- Posts now serialize `pending_approval` (issue #157), so a member's
  own moderation-queued post is distinguishable from a published one
  in API clients — the field existed on comments but not posts.
- `.prettierignore` excluded `SPEC.md`, `BUILDLOG.md`, and
  `CHANGELOG.md` from formatting with no real reason — they're
  ordinary markdown, not legal text like `LICENSE` (the one entry
  that's actually justified). Reformatted all three; only `LICENSE`
  stays ignored now.
- `priv/gettext/da/LC_MESSAGES/default.po` had an empty `msgstr` for
  the setup wizard's SMTP-failure message (issue #101) — the only
  untranslated string in an otherwise fully-translated file. A
  Danish-locale operator hitting an SMTP failure during first-run
  setup saw a blank error instead of the guidance to check SMTP
  settings and request a new sign-in link. Filled in the translation.
- The OpenAPI document's `operation/4` helper hardcoded every
  operation's success response to `200`, wrongly documenting
  `posts_create`, `comments_create`, and the new `auth_register`
  (all real `201 Created` responses) — caught by independent review
  on #141. Added a `status` option, defaulting to `200`, set to `201`
  on the three creation endpoints.

- Closed the last 3 of 7 Repo-bypass gaps found by the architecture
  audit (#121, closes #123): `Files.list_feed_collection/2` queried
  `Kammer.Feed.PostAttachment` directly instead of asking `Feed` for
  it (new composable `Feed.attached_stored_file_ids_query/0`);
  `DecisionLive.Index`'s "record outcome" handler bypassed
  `Decisions.fetch_viewable_decision/2` for a raw `Repo.get/2` (also
  picks up its UUID-cast guard for free); `AssignmentEventHandlers`'
  unclaim handler queried `AssignmentClaim` directly instead of
  through `Assignments` (new `Assignments.get_claim/2`, matching the
  existing bare-accessor convention).
- `Feed.comment_context/1`'s assignment branch raised `MatchError`
  (generic 500) instead of `Ecto.NoResultsError` (proper 404) for a
  comment on a since-deleted assignment — reachable via
  `Moderation.report_comment/3`. Introduced by the #123 fix above,
  caught by independent review before merge. Renamed the existing
  private `Assignments.get_assignment!/1` (heavy preloads, one
  internal caller) to `get_assignment_with_details!/1` and added a
  proper bare `get_assignment!/1` (matching `Events.get_event!/1`'s
  pattern) so `comment_context/1`'s assignment and event branches now
  raise identically. Added regression tests for both branches.

- `Moderation.ban_instance/3` (instance-wide bans) could silently strip
  a community owner's ownership membership, with none of the
  protections the normal removal path enforces — no ownership
  transfer, not even a block. Found by the architecture audit (#121,
  closes #122). `ban_member/4` (single-community bans) already refuses
  to ban an admin/owner, and `Communities.remove_member/3` already
  refuses to remove an owner's membership at all; `ban_instance/3` now
  gets the same guard, refusing the ban outright when the target owns
  any community (a bulk instance-wide purge has no single community to
  ask "who's the new owner?" of, unlike the single-community path).
- `test/kammer_web/live/decision_flows_test.exs`, `legal_live_test.exs`,
  and `search_flows_test.exs` asserted exclusively via raw HTML
  substring matching (`html =~ "..."`), contrary to
  CONVENTIONS.md/CLAUDE.md's guidance to use `element/2`/`has_element?/2`.
  Found by the full-codebase audit (#79), closing it. Added DOM ids to
  the elements these tests needed to target that didn't have one yet
  (`LegalLive.Show`'s content article and edit link,
  `InstanceLive.Home`'s imprint-nag link and demo-purge button,
  `SearchLive.Index`'s per-result links), then rewrote every assertion
  to `has_element?/2,3` scoped to those ids. No behavior changed.
- `Moderation.ban_instance/3` ran two separate `CommunityMembership`
  queries against the same target user (an ownership check, then a
  second query for the communities to audit-log) — self-review on
  #122 flagged this and it was waved off as not worth fixing; it was.
  Folded into one `community_memberships_for/1` query that serves
  both the guard and the audit list.
- `Kammer.Media.process_image/1` — the libvips pipeline SPEC §11
  credits with stripping EXIF/GPS metadata via re-encoding — had no
  test. Found by the full-codebase audit (#79). Added
  `test/kammer/media_test.exs`, verifying the metadata-stripping claim
  directly (embeds real EXIF data via `Vix.Vips.MutableImage.set/4`,
  confirms it survives an unstripped save and is gone after
  `process_image/1`), plus dimension math (unscaled below the display
  max, downscaled and aspect-preserved above it), the WebP thumbnail's
  format and width, and the error path for an unreadable source file.
- `Kammer.Storage.S3` (the S3-compatible storage adapter — MinIO,
  Hetzner Object Storage, or any S3 API, SPEC §1's alternative to
  local disk) had zero test coverage of any kind. Found by the
  full-codebase audit (#79). Added tests for `put/2`, `put_binary/2`,
  `path_for/1`, and `delete/1` against a fake S3 endpoint
  (`Req.Test`), covering the round trip, the local-cache short
  circuit, `:not_found`, and unexpected-status errors. Each function
  gained an optional third `opts` argument (default `[]`, merged into
  the underlying `Req.new/1` call) purely as a test seam for injecting
  `plug: {Req.Test, name}` — behavior is unchanged for every existing
  caller, which never passes it.
- Six Oban workers with real branching logic — no-op vs. configured,
  scheduled vs. approval-held, empty vs. due recipients, vanished vs.
  live records — had zero `perform_job/2` coverage, unlike their
  tested siblings. Found by the full-codebase audit (#79). Added
  worker-level tests for `DigestWorker`, `BackupWorker`,
  `PublishScheduledPostWorker`, `PurgeDeletedContentWorker`,
  `NewsletterDigestWorker`, and `UpdateCheckWorker` that exercise each
  worker's own branches via `perform_job/2`; the underlying context
  functions each delegates to remain covered in depth by their own
  context test suites. No production code changed.
- The guest-confirm controllers resolved a post/event/group's
  community by walking `Kammer.Repo.get(Community, ...)` chains
  themselves instead of asking the owning context for it. Found by
  the round-2 audit (#91), closing it. `Feed.confirm_guest_comment/2`,
  `Events.confirm_guest_rsvp/2`, `Events.confirm_guest_claim/2`, and
  `Newsletters.confirm_subscription/2` now return the post/event/group
  with `:group`/`:community` preloaded, so `GuestCommentController`,
  `GuestPaths.event_path/1`, and `NewsletterController` build their
  redirect paths from the preloaded association instead of a second
  `Repo` lookup. Added `Communities.get_community/1` for
  `InstanceLive.Home`'s demo-community lookup, the one remaining
  direct `Repo`/schema reference in the web layer this issue covered.
- The web layer fetched Assignment/AvailabilityOption/EventSlot/
  SlotClaim/Report/CommunityBan/InstanceBan entities directly via
  `Kammer.Repo` and their schema modules instead of through the owning
  context. Found by the round-2 audit (#91). Added bare-id accessors
  (`Assignments.get_assignment/1`, `Availability.get_option/1`,
  `Events.get_slot/1`, `Events.get_slot_claim/2`,
  `Moderation.get_report/1`, `Moderation.get_community_ban/1`,
  `Moderation.get_instance_ban/1`); the affected LiveViews call them
  instead of naming `Kammer.Repo` or a schema module directly.
- The web layer fetched `Feed` entities (posts, comments, polls)
  directly via `Kammer.Repo` and the schema modules instead of through
  `Kammer.Feed`, and hand-rolled the poll-vote-toggle selection logic
  (`FeedEventHandlers.toggle_option/3`) — Feed domain logic living in
  `KammerWeb`. Found by the round-2 audit (#91). `Kammer.Feed` now
  exposes `get_post/1`, `get_comment/1`, `get_poll/1`, and
  `toggle_poll_option/3`; `FeedEventHandlers`, `ReportHandlers`,
  `GroupLive.Show`, `AssignmentLive.Show`, and `EventLive.Show` call
  them instead of naming `Kammer.Repo`/`Kammer.Feed.Post`/
  `Kammer.Feed.Comment` directly (one exception, documented inline:
  `FeedEventHandlers`'s `create_comment` handler keeps a direct
  `Repo.get/2` — narrowing that one call site's type triggers an
  unrelated Dialyzer false positive on `Feed.create_comment/3`'s
  `:comments_locked` branch, confirmed real via `feed_test.exs`).
  Remaining bypasses in Assignments/Availability/Events/Moderation and
  the guest-controller redirect-path chains are tracked as follow-up
  work under the same issue.
- The passkey sign-in and passkey-registration colocated JS hooks
  (`user_live/login.ex`, `user_live/devices.ex`) each defined
  identical `b64urlToBytes`/`bytesToB64url` WebAuthn base64url
  helpers. Found by the full-codebase audit (#77), which is now fully
  addressed. Extracted `assets/js/webauthn.js`; both hooks import from
  it instead of maintaining their own copy.
- `GuestClaimController` and `GuestRsvpController` each had a
  byte-for-byte identical private path helper (`event_path/1` and
  `confirmed_path/1`) resolving a guest-confirmed event's
  community-scoped URL. Found by the full-codebase audit (#77). Now
  both call the shared `KammerWeb.GuestPaths.event_path/1`.
- Community and instance moderation each hand-rolled their own
  ban-row list markup and `unban` `handle_event` body. Found by the
  full-codebase audit (#77). Now both call the shared
  `KammerComponents.ban_row/1` and `KammerWeb.BanEventHandlers`,
  parameterized on the DOM id prefix, confirmation text, and the
  scope-specific unban function, so the two can no longer drift.
- The group assignment list and the single-assignment page each
  hand-rolled their own `claim`/`unclaim`/`complete`/`reopen`
  `handle_event` bodies, including a byte-for-byte identical manual
  `AssignmentClaim` lookup before calling `Assignments.unclaim/2` in
  both places. Found by the full-codebase audit (#77). Now both call
  the shared `KammerWeb.AssignmentEventHandlers`, so the two can no
  longer drift. Added a regression test for the single-assignment
  page's full claim/unclaim/complete/reopen cycle, which had no prior
  test coverage.
- The community home feed hardcoded a post's `edit`, `pin`,
  `lock_comments`, and `approve` permissions to `false`, unlike the
  group feed's `post_permissions` which computed all of them via
  `Kammer.Authorization` from the exact same inputs (post, group,
  relationship, current user). A group admin or post author browsing
  their Home screen instead of the group page couldn't edit, pin,
  lock, or approve a post they were fully entitled to act on — the
  moderation menu simply didn't show those options. Found by the
  full-codebase audit (#77). Both feeds now share
  `KammerWeb.PostPermissions.for_post/4`, so the two can no longer
  drift.
- The group feed and the aggregated community home feed each
  hand-rolled their own feed-sort (Newest/Activity) control markup and
  `set_feed_sort` `handle_event` body, near-verbatim. Found by the
  full-codebase audit (#77). Now both share
  `FeedComponents.feed_sort_form/1` and route `set_feed_sort` through
  the existing `FeedEventHandlers` (already used for the rest of their
  feed interactions), so the two can no longer drift.
- Group and community settings each hand-rolled their own invite-link
  list markup and create/revoke `handle_event` bodies, near-verbatim.
  The community copy also displayed a bare `invite.use_count` instead
  of the group copy's `used/max_uses` label — so a single-use email
  invite (`max_uses: 1`) showed as a plain "0" or "1" in the list
  instead of "0/1", losing the cap information. Found by the
  full-codebase audit (#77). Now both call a shared
  `KammerComponents.invite_list/1` component and
  `KammerWeb.InviteEventHandlers`, so the two scopes can no longer
  drift.
- `Kammer.Moderation.report_comment/3` and `report_group/1` (via a
  private `comment_group/1` helper) re-derived a comment's owning
  group by branching on which of `post_id`/`event_id`/`assignment_id`
  was set — the exact same resolution `Kammer.Feed.comment_context/1`
  already did, reaching directly into `Events`/`Assignments` schemas
  instead of asking `Feed`. Found by the full-codebase audit (#76).
  Now `Moderation` calls the newly-public `Feed.comment_context/1`
  instead of re-deriving the branch itself.
- `Layouts.community_shell/1`, `CommunityLive.Home`, and
  `GroupLive.Show` each re-derived community membership/admin status
  with a private `member?/1`/`admin?/1`/`member_of_community?/1`
  helper that pattern-matched `community_relationship.community_role`
  directly, duplicating logic that already lives in
  `Kammer.Authorization` — the codebase's sole authorization
  choke-point. Harmless today since the helpers matched
  `Authorization.can?/4`'s `:view_community`/`:manage_community`
  clauses exactly, but a future change to either without noticing the
  other would have silently diverged. Found by the full-codebase
  audit (#75). Now all three call `Authorization.can?/4` directly.
- `Kammer.Files.fetch_accessible_file/2` only checked coarse
  group/community visibility — it never loaded the file's folder
  chain or checked an `admins_only` read override, unlike
  `list_files/3` which correctly does. A file placed in an
  `admins_only` folder was correctly hidden from folder listings for
  non-admins, but still directly downloadable via `/files/:id`,
  `/files/:id/thumbnail`, and `/files/:id/download` by anyone who
  could see the owning group or community, if the file's ID was known
  or guessed — the folder-level restriction was silently bypassed on
  the direct-fetch path. Now applies the same folder-permission
  invariant (ADR 0009) as `list_files/3`.

- `Kammer.Files.fetch_accessible_file/2` skipped the `Ecto.UUID.cast`
  guard its sibling `fetch_viewable_*` functions use, so a malformed
  file ID raised `Ecto.Query.CastError` instead of returning
  `{:error, :not_found}`. This broke `FileController.serve/4`'s
  deliberate 404 branch (the exception aborted the `with` before it
  could run) and crashed the `FileLive.Index` delete handler's
  LiveView process on a tampered `id` param. Found by the round-2
  audit. Now guards the same way its siblings do.
- `Kammer.Feed.create_engine_comment/5`'s reply-flattening
  (`normalize_parent/1`, now `/2`) accepted any `parent_comment_id`
  the client sent and looked it up globally, with no check that the
  candidate parent belonged to the same post/event/assignment as the
  comment being created. Since replies render via a
  `has_many :replies` preload keyed purely on `parent_comment_id`,
  this let any member with comment access anywhere on the instance
  inject a comment that would render nested inside an unrelated
  thread they had no visibility into at all — including a
  comment-locked or archived thread, or one in a sealed group. Found
  by a second, more thorough full-codebase audit (round 2). Now
  verifies the candidate parent shares the same subject before
  adopting it; a cross-subject `parent_comment_id` is silently
  treated the same as a missing one (becomes a root comment),
  matching the existing not-found fallback.
- Extracted `Kammer.Validation.validate_email_format/3` and
  `validate_display_name_length/3`, replacing six duplicated copies of
  the same email-format/length and display-name-length rules across
  `Kammer.Newsletters`, `Kammer.Events` (two guest-request
  changesets), `Kammer.Feed`, `Kammer.Guests.GuestIdentity`, and
  `Kammer.Accounts.User` — the last of the DRY violations from the
  full-codebase audit (#74). Deliberately narrow: normalization (e.g.
  downcasing email) stays with each caller since not everyone wants it
  — `User`'s email notably never has. Behavior-preserving at every
  site, including `User`'s 100-character display-name bound (the
  other five use 120) via the shared helper's `max` parameter.
- Added direct test coverage for two previously-untested
  security-relevant boundaries flagged by the full-codebase audit:
  `Kammer.Storage.Local`'s path-traversal guard (every key reaching
  disk goes through it — uploads, thumbnails, extracted text) and
  `Kammer.Markdown.to_html/1` (the sanitization boundary for every
  post, comment, and event body in the product). No production code
  changed; both were already correct, just unverified by the suite.
- Consolidated comment creation for posts, events, and assignments
  into one `Kammer.Feed.create_engine_comment/5` (ADR 0007's "one
  comment engine," which had drifted into three near-identical
  copies). Fixes two real bugs the drift had caused: event and
  assignment comments skipped the `@everyone`-mention rate limit that
  post comments already enforced, and — activated for the first time
  by this same fix, since event/assignment comments were never
  previously enqueued for fan-out — `Kammer.Notifications.fanout_comment/1`
  assumed every comment belonged to a post and would raise
  `Ecto.NoResultsError` for any event or assignment comment. Fan-out
  now resolves the comment's host (post, event, or assignment)
  polymorphically, with regression tests covering all three subjects.
- Consolidated the instance-operator check (`user.instance_operator`)
  into `Kammer.Authorization.instance_operator?/1`, replacing raw
  struct-field checks scattered across three context modules
  (`Kammer.Legal`, `Kammer.Communities`, `Kammer.Moderation`,
  `Kammer.Setup.DemoData`) and four LiveViews (legal page edit/show,
  instance settings, instance moderation, instance home) — the third
  and final authorization-consolidation fix from the same audit.
  Behavior-preserving; existing tests for all eight sites pass
  unmodified.
- `KammerWeb.CommunityScope`'s `:require_member` `on_mount` hook — the
  route-level gate for most of the app's community-scoped LiveViews
  (groups, files, members, moderation, settings, events, assignments,
  decisions, availability) — checked community membership with an
  inline `if socket.assigns.community_relationship.community_role`
  instead of going through `Kammer.Authorization`, another violation
  found by the same audit. Now calls `Authorization.can?/4` with
  `:view_community` (the same predicate, `community_role != nil`),
  with a dedicated test for the gate itself rather than only the
  indirect coverage every other community-scoped test already gave it.
- Consolidated the "creator or group moderator" access-control rule
  (who may edit/cancel/close/record-an-outcome-for a resource) into
  `Kammer.Authorization.can_manage_own_resource?/3,4`, replacing five
  independent copies of the identical logic across `Kammer.Events`,
  `Kammer.Availability`, `Kammer.Assignments`, and `Kammer.Decisions`
  — a direct violation of CONVENTIONS.md's "one authorization module"
  rule found by a full-codebase audit. Now property-tested alongside
  the rest of the authorization decision core instead of five separate
  unit tests that never covered the rule as a shared invariant.

### Security

- Full rate-limit coverage (SPEC §11): every item on the spec's
  rate-limit list now has a real `Kammer.RateLimit` bucket — account
  creation (per IP), posting, commenting, and file uploads (per
  author/uploader), on top of the magic-link and guest-endpoint limits
  that already existed. While auditing coverage, `@everyone` inside a
  **comment** turned out to bypass the broadcast-rights gate
  entirely — `Notifications.fanout_comment/1` already escalated it to
  a full-group broadcast for any commenter, since only post creation
  ran the gate. Comments now get the same gate and rate limit posts
  already had.
- Nonce-based CSP (SPEC §11, ADR 0021): `script-src` drops
  `'unsafe-inline'` for a fresh per-request nonce, closing the one
  remaining gap that would have let a successful XSS run arbitrary
  inline script. The app's one inline script (the light/dark theme
  bootstrap) carries the nonce; colocated LiveView hooks were never
  affected — they compile into the external `app.js` bundle.
- Newsletter one-click unsubscribe no longer embeds the full-power,
  60-day guest manage token in the RFC 8058 `List-Unsubscribe` header
  (issue #233) — a URL mail gateways auto-fetch with no human in the
  loop, so that token was a live, wide credential sitting in every
  delivered email. `Kammer.Guests.Token` gained a third,
  single-purpose salt (`sign_unsubscribe/1`/`verify_unsubscribe/1`,
  180-day max-age): the payload carries only the one
  `subscription_id` it authorizes, never an identity, so a leaked or
  auto-fetched copy can unsubscribe exactly that one subscription and
  nothing else — it fails `Guests.manage_token_valid?/1` outright
  (different salt), so it's powerless against every manage endpoint.
  The `/newsletter/unsubscribe/:token` route (GET and the RFC
  8058 POST) dropped its separate `:subscription_id` segment — the
  token names its own subscription, so there's no longer a
  caller-supplied id to tamper with. The body "manage or unsubscribe
  anytime" link is deliberately left carrying the full manage token
  for now (it's a link a human must click, not an auto-fetched
  header) pending the #185/#187 PWA transition.