Skip to content

Unreleased

Changes not yet published in a release.

Added

Backend

  • REQ-001 Master data management: Botanical families, species, cultivars, lifecycles (ArangoDB); field propagation_methods (13 propagation method values, multi-select) added to the species profile — all 143 crop species seed records populated
  • REQ-002 Site management: Sites, locations (recursive hierarchy), slots, location types
  • REQ-003 Phase control: Phase state machine (germination → harvest), GDD/VPD/photoperiod calculation
  • REQ-004 Fertilization logic: Fertilizers, nutrient plans, dosages, mixing safety, flushing, runoff, EC budget, water source/CalMag correction
  • REQ-006 Task planning: Workflow templates, tasks, queue, dependencies, HST validator
  • REQ-007 Harvest management: Harvest indicators, observations, batches, quality assessment, pre-harvest interval gate
  • REQ-010 IPM system: Pests, diseases, treatments, inspections, resistance manager
  • REQ-011 External master data enrichment: GBIF + Perenual adapters, enrichment engine, Celery tasks
  • REQ-012 Master data import: CSV upload, validation, preview, confirmation
  • REQ-013 Planting runs: PlantingRun, batch operations, state machine
  • REQ-014 Tank management: Tanks, tank states, fills, maintenance, sensors
  • REQ-015 Calendar view: iCal feeds, aggregation, token-based access
  • REQ-019 Substrate management: Extended substrate types, lifecycle manager, reuse
  • REQ-020 Onboarding wizard: 5-step assistant, 9 starter kits, experience levels
  • REQ-022 Care reminders: 9 care profiles, FAMILY_CARE_MAP, adaptive intervals, Celery task
  • REQ-023 Authentication: Local accounts (bcrypt), JWT (authlib), refresh token rotation
  • REQ-024 Multi-tenancy: Tenant isolation, memberships, invitations, RBAC
  • REQ-028 Companion planting: Graph-based compatibility
  • REQ-031 AI assistant: RAG-based knowledge base, LLM adapters (Anthropic/Ollama/OpenAI-compatible), hybrid search
  • Exporting and importing a tenant (issue #2138, REQ-056 §8): a garden can now be saved as an archive and brought back as a new garden — into the same or another installation. The lead with management may export (POST /api/v1/tenants/{slug}/exports, also during the deletion grace period), or a platform admin (POST /api/v1/admin/platform/tenants/{key}/exports), each with a password confirmation; at most one export runs at a time (409 EXPORT_ALREADY_RUNNING) and at most TENANT_EXPORTS_PER_DAY (default 3) per 24 hours. Every member with management gets an e-mail. The archive holds all domain data, the links between them, the photos and the members without e-mail addresses — no credentials, API keys, invitations, integration secrets or logs — and can be downloaded for 72 hours. Only a platform admin may import (POST /api/v1/admin/platform/tenant-imports): every record gets a new id, members are mapped by the e-mail addresses of existing accounts, and the admin sets their role, not the archive. An archive with foreign records, missing catalogue entries or a damaged file is refused with 422 without writing anything. The new garden stays unreachable until its last record is written (being imported), a failure part-way removes everything again, and an import that died is cleaned up hourly. Archives carry the installation's signature; verified tells whether an archive comes unchanged from here. Integration keys stay out, and every download is in the security log. New settings: TENANT_EXPORTS_PER_DAY, TENANT_IMPORT_MAX_UPLOAD_MB and RATE_LIMIT_TENANT_IMPORT. How large an archive may unpack is bounded by fixed limits (1 GiB, at most 128 MiB of it records, plus a compression ratio and a record count), independently of TENANT_IMPORT_MAX_UPLOAD_MB. The new garden's owner gets exactly the permissions the admin confirms, and deactivated accounts cannot be mapped. A single huge line or millions of small values are refused before they cost memory, and the target plan's member limit applies to the imported members too. If a failed import cannot be fully rolled back, the half garden stays unreachable, the import reports cleanup_pending, and the hourly job deletes it. Refused and failed imports are in the security log too, with the archive's SHA-256. An archive the new garden's plan cannot hold — more members, more storage or more photos per plant — is refused when the import is requested, with the quota's code (MEMBER_LIMIT_REACHED, STORAGE_QUOTA_EXCEEDED, PHOTO_QUOTA_EXCEEDED), without writing anything. With TimescaleDB the archive now carries the garden's sensor readings too (timeseries/readings.csv: raw values and hourly/daily values, only the garden's own, counted against the export's size limit and covered by the signature); the archive format stays kamerplanter-tenant-export/1, the import skips the file and reports its row count. New is TENANT_EXPORT_SIGNING_SECRET: a secret of its own for the archive signature (at least 32 characters; empty = derived from JWT_SECRET_KEY as before; optional in the Helm chart via tenantExport.signingSecret). The import now has its own upload limit TENANT_IMPORT_MAX_UPLOAD_MB instead of the general 26 MiB — this route only, at most up to UPLOAD_SPOOL_BUDGET_MB (about 51 MiB with the defaults); for exactly this path the Helm chart sets the limit of the frontend nginx and of an Ingress of its own, tenant-import (tenantExport.importMaxUploadMb). The runbook explains how to size /tmp for larger archives. Still open: loading the readings on import. The user interface exists: the Export tab in the garden settings and the Imports page in platform mode (you enter the member keys by hand). See Exporting and importing a tenant.
  • Plans and quotas per tenant (issue #2142, REQ-056 §7): every tenant now has a plan (plan_key) that sets its quotas — members, service accounts, storage, photos per plant and the three daily AI budgets. Every tenant starts on the plan standard, where each quota follows the existing server setting; without further plans no limit changes, including one adjusted by environment variable. Platform admins create, change and delete plans through /api/v1/admin/platform/plans and assign them with PATCH /api/v1/admin/platform/tenants/{key} (plan_key); every change is written to the security audit. A lower quota removes nothing, it refuses only the next creation. Every member reads plan and usage through GET /api/v1/tenants/{slug}/entitlements. The refusal codes stay the same. New is TENANT_MAX_ORGANIZATIONS_PER_USER (default 10, 0 = only the operator creates organizations): the hard-coded limit of ten organizations per account is now configurable, the refusal now carries the code ORGANIZATION_LIMIT_REACHED instead of VALIDATION_ERROR (status still 422), and an organization being erased (deleted) no longer counts. Note: in a plan, 0 means "unlimited" for storage and photos and "budget off" for the AI budgets; the plan response names such quotas in the field unlimited. Every quota has an upper bound, a corrupt plan falls back to the server settings, and the platform tenant always follows the server settings. Kamerplanter creates the plan standard at startup, and migration v0091 records it on every existing tenant.
  • Plans and quotas in the interface (REQ-056 §15, AK-45): platform admins maintain plans on the new Plans page (Settings > Platform Mode > Manage plans): a list with the default mark and the number of tenants, a form to create and change, deleting for unused plans only. An empty quota reads "same as instance setting", a 0 "unlimited" or "budget off". A tenant's page shows plan, usage per quota and the owner and assigns another plan (not for the platform tenant). Every member sees their garden's plan and usage in the new Quotas tab of the garden settings. The refusals ORGANIZATION_LIMIT_REACHED, MEMBER_LIMIT_REACHED, SERVICE_ACCOUNT_LIMIT_REACHED, STORAGE_QUOTA_EXCEEDED and PHOTO_QUOTA_EXCEEDED now read as an understandable text in German and English instead of the English server message.
  • Privacy: a consent of its own for the AI knowledge question (REQ-031 v2.6, REQ-025 v1.35). New optional consent purpose ai_knowledge_question ("AI knowledge question to the knowledge base"): it allows your freely formulated question in the AI assistant to be sent to the knowledge base and its language model. Plant values are only included if you add them to the question yourself — that additionally requires ai_tenant_data_access. Revocable at any time, effective from the next question. It is listed under GET /api/v1/privacy/consents and granted via POST /api/v1/privacy/consents.
  • Privacy: a consent of its own for the pest recognition index (REQ-025 v1.36, REQ-044 v1.4, REQ-010 v1.7). New optional consent purpose pest_reference_contribution ("Photo contribution to pest recognition"): a pest photo you contributed yourself that an administrator makes visible to everyone is then added to the shared pest recognition index as a numerical fingerprint (embedding) plus provenance; the photo does not leave the installation. Revocable at any time, effective from the next promotion. It is listed under GET /api/v1/privacy/consents and in the Consents tab; it is granted via POST /api/v1/privacy/consents (there is no dedicated switch in the interface yet).
  • API: CONSENT_REQUIRED names the missing purpose machine-readably in details[0].purpose (additive, field stays consent) — one route can refuse for either of two purposes, and the client no longer has to parse the message.
  • API: cloud label on the knowledge question. The response of POST /api/v1/t/{slug}/ai/knowledge/ask additionally carries provider_type and uses_cloud_provider, classified like the glossary from the platform's system default provider (the Knowledge Service answers with its own model).
  • Invitation link for several people (issue #2182, REQ-024 v1.34 AK-07): For an invitation link you set under “People per link” how many people can join with it (1–100, default 1 as before). Every use is counted atomically, the invitation list shows “Used” (e.g. “3 of 20”, as a card on a phone), and every use counts against the member limit. API: POST /api/v1/tenants/{slug}/invitations/link accepts max_uses, the invitation list carries max_uses and use_count. If a join fails, the use is handed back — an invitation revoked in the meantime stays revoked. An invitation into a suspended, orphaned or scheduled-for-deletion garden cannot be accepted (422); after a failed join a use is handed back only when the membership provably did not come about.
  • OIDC auto-join (issue #2182, REQ-024 v1.34 AK-09, REQ-023 v1.49): If a sign-in provider has a default tenant (default_tenant_key), an account newly created on its first sign-in through that provider joins the tenant as Viewer. Until now the field was stored but had no effect. Never into the platform tenant or a personal garden; an unknown, inactive, deleted or full tenant is skipped quietly and the sign-in succeeds. The join is recorded in the security audit log (via: oidc_auto_join). Only for a generic OIDC provider (on Google, GitHub and Apple anyone can create an account) and only for an account whose email address the provider reports as verified. Kamerplanter checks the default tenant when it is saved (the tenant exists, is not personal, is not platform; otherwise 422), the provider response shows it, and null or "" removes it. If the join fails unexpectedly, the sign-in is not affected.
  • Documentation (issue #2179): The user guide described a platform role viewer with read access to the admin area. That role does not exist; it is now marked “Not yet implemented” (REQ-024 §1a.4).
  • Upgrade from light to full mode (Issue #1855, REQ-027 v1.9 §7a): Every start records the deployment mode (collection system_meta). When a light installation starts with KAMERPLANTER_MODE=full for the first time, Kamerplanter locks the light-mode system account (is_active=false), revokes its API keys, ends its sessions and deletes its calendar feeds. A kp_ key minted without sign-in in light mode then gets 401 (AK-35); Home Assistant and MCP clients need a new key. The light-mode garden stays untouched. New: GET /api/v1/system/takeover-status and POST /api/v1/system/takeover (full mode only): the first registered account — or an existing platform admin — takes the garden over (accept: true; it becomes lead with both management scopes, the data stay where they are) or declines; on an instance without a platform admin, either decision makes it the first one. When the instance starts in light mode again, Kamerplanter unlocks the system account and gives it its garden back. Every change is in the security audit (via: mode_switch / light_mode_takeover, new action tenant_taken_over). After the security review: taking over and declining require the account's step-up (new step-up act light_mode_takeover); without a platform admin only the first active account registered after the switch with a confirmed address and its own garden decides; the take-over is claimed and removed only after success, a failure midway is undone; full mode never signs the system account in, whatever the database says (MCP included); new audit actions takeover_accepted, takeover_declined, takeover_reverted, system_account_sealed; a light start with other accounts writes a critical warning. After the second review round: if the take-over fails midway (also while logging or granting the platform admin role), Kamerplanter takes back everything already written; if that fails too, it keeps what is left on the open take-over (needs_attention, audit takeover_undo_failed) and takes it back before anybody decides again. Refused attempts are in the security audit as takeover_refused; only an account that may decide gets a confirmation code. After the third round: the stored remainder no longer contains the deciding account, the undo checks before every step that the decision still holds, a garden deleted meanwhile no longer blocks the take-over for good, an account erasure does not take the light-mode garden along while a remainder exists, and both take-over endpoints are limited to 10 calls per minute and account (RATE_LIMIT_TAKEOVER).

Frontend

  • Take-over dialog after the upgrade to full (Issue #1855, REQ-027 scenarios ⅚): After the switch from light to full mode, a dialog asks the first account whether to take the previous garden over and shows what it holds (plants, sites, runs, nutrient plans, tasks). "Yes, take it over" switches straight into the garden, "No, start fresh" leaves it for the admin area, "Decide later" asks again at the next sign-in. The decision is confirmed with your password (or a fresh sign-in at your provider, or a code).
  • All REQ-001 through REQ-024 frontend pages implemented
  • REQ-020 Onboarding wizard (5-step MUI Stepper)
  • REQ-021 UI experience levels: Field configuration, navigation tiering, ExperienceLevelSwitcher
  • REQ-022 Care dashboard with urgency grouping
  • Light/dark theme with localStorage persistence
  • i18n German/English (react-i18next)

Infrastructure

  • CI (development): mypy ratchet for the backend (issue #2169). strict = true in src/backend/pyproject.toml ran in no check until now — measured 3452 findings in src/backend/app, among them since July the [call-arg] error behind #2150. task typecheck:backend (step "mypy ratchet" in the Backend CI job lint-test, locally also a pre-commit hook) compares the findings per file and error code with src/backend/mypy-ratchet-baseline.json: a new finding is red, a new file has to be clean. For call-arg, name-defined, attr-defined and call-overload the count has to match exactly — when you fix such a finding, lower its entry in the same change with task typecheck:backend -- --tighten. The run is advisory as long as lint-test is not a required check.
  • Metrics (Prometheus): The backend serves http_request_duration_seconds, http_requests_total, http_requests_in_progress and process metrics on a port of its own (METRICS_PORT, default 0 = off; issue #2129, MT-033, NFR-007 v1.1). In the Helm chart, monitoring.enabled: true switches on port 9464, a ServiceMonitor (Prometheus Operator) and the NetworkPolicy backend-metrics, which admits only the configured Prometheus pods to that port. /metrics is not a route of the API and cannot be reached through the ingress. Labels: method, handler (route pattern, never the path), status — no tenant, no person. New dependency prometheus-client (Apache-2.0, bundles decorator under BSD-2-Clause; licence allow-list extended).

  • MkDocs documentation infrastructure with Material Theme and DE/EN i18n (NFR-005)

  • ADR-001 through ADR-006: Architecture decisions documented
  • Skaffold-based development workflow with Kubernetes/Helm
  • pgvector + embedding service for RAG pipeline
  • Knowledge container for YAML-based knowledge base
  • GitHub Actions CI/CD (Docker lint/build, Skaffold verify)

Changed

Documentation

  • Light Mode and AI: the specification promised that the backend refuses to start in Light Mode with a cloud language model. That check does not exist, and in the described form it would have had no effect — the Knowledge Service chooses the language model through LLM_PROVIDER. The Light Mode page now says that for Light Mode you have to configure a local model on the Knowledge Service (issue #2176).
  • Roles and permissions, platform admin: the pages now say what applies — a location assignment does not restrict editing, the first registered account does not become platform administrator automatically (it is set up on the server), a platform administrator sees no plants or harvests of other gardens, and the guided switch from light mode is marked as not built yet.
  • Tenants & Gardens: the role comparison names the current roles Lead, Grower and Viewer instead of the retired "Admin" role; inviting, changing roles and settings belong to the Management scope. An invitation grants the Viewer role (issue #2181).
  • Specification, choice values: 20 value lists in REQ-001, -002, -004, -006, -007, -014 and -019 named different values than the application stores — for example site type without windowsill, balcony and grow tent, harvest quality A+ instead of the stored a_plus (the label still shows "A+"), frost sensitivity tender instead of sensitive, nutrient phase harvest instead of ripening. The specification now names the application's values; the application itself does not change. An automated check now compares these value lists with the code (issue #2183).
  • Specification, tenant lifecycle and quotas (issue #2143): the new requirement REQ-056 brings a tenant's states, suspension, registration, quotas, tenant export, ownership and contact person together in one place. Planned and not yet built are plans with their own quotas (default plan standard with today's limits), an export of a whole tenant with import into another installation, handing over responsibility for a garden and a contact person for the operator; prices and billing are explicitly out of scope. Measuring turned up: a suspension can be bypassed when a platform admin deletes a suspended tenant and its management cancels the deletion (the tenant is active again afterwards), and six automatic jobs — tank alerts, Home Assistant readings, weather data, InvenTree sync (two) and the notification digest — keep running for a suspended tenant. The admin guide claimed a tenant's quotas and limits could be viewed — that is now marked "Not yet implemented". NFR-012 has a quota table (§10.3) that NFR-013 pointed at without it existing.

Frontend

  • Garden settings: cancel a scheduled deletion (issue #2166). A garden whose deletion is scheduled was invisible to its members — it was missing from the tenant selector, and only platform admins could cancel. The garden settings — now reachable from the tenant selector through Garden settings — show a section Gardens scheduled for deletion with state and deletion date. If you hold the lead role and the management right in such a garden, you cancel the deletion there with Cancel deletion (confirmed with your own password, an e-mailed code or a fresh sign-in); the garden is active again and back in the tenant selector. An orphaned garden is shown but cannot be brought back.
  • Calendar, iCal feeds (issue #2171): Kamerplanter shows a feed's subscription URL only once, right after creating the feed or regenerating its token, in a dialog of its own with Copy URL and a note that it cannot be retrieved later. The feed list no longer shows a URL or a copy button, but a hint that you regenerate the token to get a new URL. Regenerate Token now asks first, because the old URL stops working in every calendar app afterwards. A click beside the URL dialog no longer closes it; if the browser does not allow copying, Kamerplanter tells you to copy the URL from the field yourself. If creating a feed fails, the dialog stays open with your name, and Create is locked while the request runs — a double click no longer creates a second feed.
  • Privacy (behaviour change): The frontend reports errors only after consent (issue #2159, UI-NFR-013 CI-001/CW-003). Until now the browser started the error-tracking SDK as soon as a SENTRY_DSN was set, and the consent banner was mounted nowhere. Now, with a DSN set, the banner appears on the first visit and the SDK is loaded only after consent to error analysis. Under Privacy → Consents, "Allow error analysis" turns reporting off or on without a reload, in other tabs too. In Light mode no banner appears and the frontend reports no errors. Without a DSN nothing changes. For operators: frontend errors now arrive only from people who consented.
  • AI Assistant (behaviour change): In full mode the AI Assistant page asks knowledge questions through your garden (/t/{slug}/ai/knowledge/ask) instead of the public path, which no longer exists there; Light mode keeps /public/ai/ask (issue #2175). If the consent "AI knowledge question to the knowledge base" is missing, the page offers it in place and sends the question again once granted. A garden with AI switched off and a used-up daily quota are explained clearly. If the knowledge base answers with a cloud model, the answer carries the Cloud processing label like the other AI answers. The page reads which consent is missing from details[0].purpose; only for older servers without that field does it fall back to the message text.
  • Plant recognition (behaviour change): The Use as recognition reference switch is now the consent "Photo contribution to plant recognition" (issue #2174). Switched on, it explains what is shared; on saving, the consent is granted before the photo is contributed. If that fails, the plant is not created and an error is shown instead of silently skipping the contribution. The switch is hidden in Light mode.
  • Privacy: In the Consents tab, every granted optional consent can be withdrawn with Revoke; until now that only worked through the API. The "privacy settings" link in the plant-recognition consent dialog now leads to the privacy page instead of the account settings.
  • Glossary: The Generate detailed explanation button disappears once AI features are switched off for the garden or the installation; the reason takes its place instead of a retry button that would be refused the same way.
  • Settings: Only platform admins see the "Platform Mode" tab; for everyone else the page no longer requests admin data (issue #2144).
  • Admin, invitations, the AI assistant, a plant's tasks and storage observations load their lists completely, page by page, now that the API returns at most one page there (issue #2131) — you still see every item.
  • A garden's members and location assignments, the member list in tenant administration, the runs of a workflow template and the InvenTree links also load completely, page by page (issue #2131) — you still see every item.
  • Privacy: the account deletion dialog names, before you confirm, the organizations whose management passes to the longest-serving lead or that become orphaned (issue #2134)
  • Admin: tenant management shows a tenant's state (active, suspended, deletion scheduled, orphaned, being deleted) with the deletion date and offers Cancel deletion with password confirmation while a deletion is scheduled; the active switch and delete are locked meanwhile (issue #2123)
  • Logging out — and being signed out because the session was ended elsewhere — now clears everything the browser held for the account: every loaded list, the active garden (kp_active_tenant_slug) and this device's browser push registration (issue #2117, MT-020). On a shared device the next person sees none of the previous account's data and receives none of its push messages. A logout whose request fails (offline) still signs the tab out. Light-mode leftovers in the browser (local favorites, module visibility, dashboard layout) are carried into a profile only by the first account that adopts them on this browser.
  • A request that answers only after you logged out (or were signed out) no longer puts its result into the tab (issue #2253): a slow list does not bring back the previous account's entries, a late garden list does not restore its active garden, and a late profile does not sign the tab back in as that account. Requests started after the logout are unaffected.
  • Admin: a tenant's page has a Home Assistant entities section — the instance's inventory with search, a switch per entity and release by ID for notification services (issue #2112). The sensor and weather-source dialogs request the Home Assistant list only with the Technical scope and otherwise explain why it is missing; without releases the sensor dialog points to the platform admin.
  • Login and registration follow the registration mode (issue #2132): when registration is closed, the register link is gone and the registration page explains why; when it needs an invitation, the page asks for the invitation code (/register?invitation=<code> prefills it). A refused sign-in through an OIDC provider explains the reason.
  • Invitations: when a garden is full, the invitation acceptance page explains it in your language — and that the invitation stays valid until a seat is free (issue #2133)
  • Notifications: Saved Apprise URLs appear masked (tgram://****#1); a hint below the field explains how to keep, remove or add them, and Enter now starts a new line (issue #2113).
  • Admin: After deleting an organization the interface reports "Deletion accepted" instead of "Organization deleted" — the deletion runs in the background (issue #1792)
  • Plant instances are displayed everywhere with a speaking name (e.g. BASIL-001 (Basil – Genovese)) instead of only the technical instance ID; the instance ID is preserved as secondary information
  • Tasks: the forms in the Edit and Complete tabs now render validation errors as helper text directly on the affected field instead of a short-lived native browser bubble (noValidate)
  • Account: the email verification page now offers a Log in button in the error case as well (invalid or expired link) — previously that page was a dead end

Backend

  • Faster task queue with many gardens (issue #1534): the task queue, the overdue list and the status-filtered task list of a garden now read only that garden's tasks. Until now each of them read every open task of the whole installation — measured with about one million tasks in 3 400 gardens, roughly 1.4 s per query for a small garden, now about 2 ms. Kamerplanter builds the new database index on the next start (migration v0092); nothing changes for you.
  • Fixed (security): the upload limit applies before the body is read, attachments no longer come out of the browser cache unasked (issue #2254, NFR-013 v1.13). FastAPI reads an upload completely — files above 1 MiB into /tmp — before sign-in or the size check run. Measured: a 50 MiB upload against a 1 MiB limit was read to the last byte, without sign-in too. Now the backend checks the size first: every upload is limited to STORAGE_MAX_FILE_SIZE_MB + 1 MiB (the same limit as the frontend nginx). With a larger Content-Length it answers 413 (REQUEST_BODY_TOO_LARGE) without reading a byte; a body without a declared length is cut off at the limit. Concurrent uploads share a budget for /tmp (UPLOAD_SPOOL_BUDGET_MB, default 52, counted in bytes that arrived); one that gets no room within UPLOAD_SPOOL_WAIT_SECONDS (30) receives 503 with Retry-After. Downloads via token link (/api/v1/attachments/token/…) carry Cache-Control: no-store instead of private, max-age=86400: the link is valid for 15 minutes, the browser's copy would have lasted a day, even after the attachment was deleted. Attachments and thumbnails in the app carry private, no-cache with an ETag: the browser asks before every display and gets 304 when nothing changed and you may still see the attachment. Signing out, a removed membership and a deletion thus take effect at once. The CSV import, pest detection and plant identification read at most one byte past their limit. Every other request (JSON, forms) may be at most 1 MiB. Promoted pest images also carry private, no-cache instead of public, max-age=86400, and the ETag of all image downloads no longer reveals the hash of the uploaded original file. An oversized photo in pest detection answers 413 instead of 422, so the app shows its "image too large" message again. A Content-Length such as ² used to cause a 500.
  • Fixed (security): Home Assistant, InvenTree and Web Push can no longer be redirected to internal addresses (issue #2249). The SSRF check resolved the host name and judged the addresses, but the HTTP client resolved the name again when it connected. A name answering a public address to the check and an internal one to the connection (DNS rebinding) thereby reached 127.0.0.1 or in-cluster services — and each tenant's technical admin sets its own InvenTree URL, http included. The connection now judges the addresses it opens to by the same rule and connects only to a judged address; the Host header, TLS SNI and certificate verification stay on the host name. This covers every Home Assistant call (including "test connection") and every InvenTree call. Web Push followed a redirect (301–308) from the push service to any target, unchecked; no redirect is followed any more and the delivery counts as failed. A Web Push call also has a 10 s timeout now (previously none). A malformed URL such as http://[::1 is now refused by the SSRF checks with 422 instead of a server error. Legitimate connections are unaffected; the HTTP(S)_PROXY environment variables no longer apply to Home Assistant and InvenTree, because a proxy would resolve the name itself. Note: for Home Assistant and InvenTree, CGNAT addresses (100.64.0.0/10, e.g. Tailscale) and IPv6 site-local (fec0::/10) now count as private too and need HA_ALLOW_PRIVATE_ENDPOINT or INVENTREE_ALLOW_PRIVATE_ENDPOINT; an IPv4 address embedded in IPv6 (6to4, NAT64, IPv4-mapped) is judged as that IPv4 address, and the metadata addresses 100.100.100.200 and fd00:ec2::/32 are always refused. Web Push: remaining risk. For Web Push, #2249 closes redirects only. The push endpoint is checked before sending but resolved again when connecting; a rebound name ends at the TLS certificate, but Kamerplanter does open a TCP connection to the internal address. The error text of a failed delivery therefore says only delivery failed instead of the exception type.
  • Fixed (security): cancelling a deletion no longer lifts a suspension (REQ-056 §4.3, REQ-024 v1.36 AK-65). When a platform admin scheduled the deletion of a suspended tenant, the tenant's own management could cancel it through POST /api/v1/tenants/{slug}/erasure/cancel — and the tenant was active afterwards, the suspension bypassed. Scheduling a deletion now remembers the previous state (status_before_deletion), and the cancellation restores it: a suspended tenant stays suspended, while its management may still cancel the deletion and so prevent the data loss. A deletion scheduled before this change cannot tell whether it froze a suspension: only a platform admin can cancel it (to active); the management gets 403 (no migration). Every state change (suspend, reactivate, schedule a deletion, cancel it) is now a conditional write: if the state changed between the read and the write, the request answers 409 and changes nothing. A repeated deletion request no longer overwrites the remembered state and no longer makes an orphaned organisation cancellable. Only a platform admin can schedule the deletion of a suspended tenant (403 on every other path, same message). Found along the way: after a cancellation the deletion date (deletion_scheduled_at) stayed stored on the reopened tenant, because the tenant repository did not write a field set to null; an explicit null now removes the field — only for deletion_scheduled_at, status_before_deletion and description; the repository refuses it for any other field.
  • Fixed (security): background jobs skip suspended and pending-deletion tenants (REQ-056 §5.2). 13 periodic tasks did not ask the tenant's state and kept working for a suspended or pending-deletion tenant: the tank-alert check, the e-mail digest (which also delivered notifications about the tenant from before the suspension), Home Assistant readings, weather, climate normals, hardiness zones, irrigation demand, both season beats, both InvenTree syncs, the actuator control loop and the actuator state poll. All of them now ask ActiveTenants. Every beat_schedule entry is classified in TENANT_WRITING_BEATS or TENANT_AGNOSTIC_BEATS (app/tasks/tenant_gate.py, with the reason); the guard tests/unit/guards/test_beats_skip_inactive_tenants.py fails on a new beat that is unclassified or ungated. Retention, erasure, storage reconciliation and global catalogue jobs keep running. A closed tenant's actuators are not left latched: for a suspended, pending-deletion or orphaned tenant no schedule, override or sensor rule runs any more; the control loop drives its devices to a safe state instead. Safety rules keep firing; otherwise every device an emergency scenario switches off (valves, pumps, heaters, lights, CO₂ …) is switched off once — the exhaust fan keeps running, and the device's fail_safe_state takes precedence. Manual actuators are left alone, and no fallback task is written into the closed tenant.
  • Fixed (API): the member list reports the admin scopes (issue #2166). GET /api/v1/tenants/{slug}/members answered admin_scopes: [] for every member, including members with management or technical rights — the database query did not read the field. The stored scopes are now in the answer. New (API): GET /api/v1/tenants?include_scheduled_deletion=true also lists the tenants in pending_deletion and orphaned; every entry now carries status and deletion_scheduled_at. Without the parameter the list is unchanged (active tenants only).
  • Fixed: Testing a not-yet-saved weather source (POST /api/v1/t/{slug}/sites/{key}/weather-sources/test) answered 500 when kind did not match the source (say dwd sent as home_assistant). It now answers reachable: false with a message naming the mismatch (issue #2169).
  • Fixed (privacy, issue #2166): When the last person with the management right in an organization requests their account's deletion, the organization is now settled at the request — the longest-serving lead takes over, or the organization is orphaned and deleted after the grace period — and the members get an email right away. Before, this happened only at the final deletion up to 90 days later; until then nobody could administer the organization, because the account is closed from the request on. The grace period of an orphaned organization starts with the request. An account erasure request cannot be withdrawn, so the hand-over is not taken back either (REQ-025 v1.37 §3.1.3 rule 4).
  • Fixed (tenants, issue #2166): The daily reminder and task beats (care reminders, care notifications, daily summary, escalation, tank maintenance, runoff flush, frost warning, watering and feeding tasks, Home Assistant tank sync) leave out a tenant that is suspended, scheduled for deletion or orphaned. Before, they kept creating tasks there, read its Home Assistant sensors and notified the members about a garden they cannot open (REQ-024 v1.32 AK-65).
  • Fixed (tenants, issue #2166): Only an account that can still act counts as an organization's remaining management. If a second person with the management right has requested their account's deletion or their account is deactivated, you as the last active manager can neither leave nor have the right taken away. The same holds when the second manager is a service account: it cannot administer members, so it does not count. When the longest-serving lead takes over at an account erasure request, only a person qualifies, never a service account; without one, the organization is orphaned (REQ-024 v1.32 AK-10, AK-65).
  • Fixed (API): calendar apps can subscribe to iCal feeds again (issue #2226, REQ-015). The public endpoint GET /api/v1/calendar/feeds/{key}/feed.ics?token=… that the subscription URL shown on creating or regenerating a feed points at had not been mounted since March 2026 and answered 404 — no calendar subscription could load events. It is back, without sign-in (the token in the URL is the authorisation) and in both deployment modes. It returns text/calendar with Cache-Control: no-store and only events of the garden the feed belongs to. A missing, unknown or rotated token, another feed's token and a deactivated feed all answer the same 404; a feed past its expiry date answers 410. Each client IP may fetch 30 times a minute (RATE_LIMIT_CALENDAR_FEED), beyond that 429. Neither the token nor the feed key appears in any access or application log line. The API now builds the subscription URL from the endpoint's own template, so the two cannot drift apart again. The "Not yet implemented" note in the user guide is gone.
  • Security (API): iCal feeds end with the membership, and the answer is hardened (issue #2226, security review, REQ-015). The feed.ics fetch now answers only while the feed's owner is an active member of the garden with an active account and the garden is active — otherwise the same 404; a feed without an owner never answers. When a member is removed or leaves the garden, Kamerplanter deletes their feeds there. The lead lists every feed of the garden with GET …/calendar/feeds?scope=all (other roles get 403). A single feed (GET …/calendar/feeds/{key}) is likewise shown only to its owner and the lead — for every other member another member's feed answers 404 like an unknown one (until now any member with the key could read another member's feed name, owner and filters). Titles and descriptions are escaped per RFC 5545: CR, LF, CRLF and the Unicode breaks NEL, LINE SEPARATOR and PARAGRAPH SEPARATOR become one escaped line break, other control characters are dropped, lines are folded at 75 octets — a task title can no longer write a property of its own (such as ATTACH) into every subscriber's calendar. The feeds' site filter filters.site_key is removed: it was stored but never applied, so a "site X only" feed served the whole garden; sent values are ignored, stored ones skipped on read and removed by the feed's next update. The API builds the subscription URL from APP_BASE_URL instead of the request's Host header, with https:// except for localhost. If APP_BASE_URL lacks the http:///https:// scheme or the host, the backend still starts but logs the warning app_base_url_not_absolute_http (without the value); the subscription URL then adds https:// itself (http:// for localhost, 127.0.0.1 and ::1). Operator note: set APP_BASE_URL in production to the full public https:// address — QR codes and the OAuth callback URL add no scheme. One answer carries at most 2000 events (window 30 days back to 90 days ahead), and besides the per-IP limit there is one per token (RATE_LIMIT_CALENDAR_FEED_PER_TOKEN, default 60/hour). Operator note: an ingress with its access log on records the subscription URL including the token — see the calendar user guide.
  • Security / upgrade note (API): iCal feed tokens are no longer stored in clear (issue #2171, REQ-015). Until now every calendar feed's token sat unencrypted in calendar_feeds.token, and every read of a feed (GET /api/v1/t/{slug}/calendar/feeds, GET …/feeds/{key}, PUT …/feeds/{key}) returned it together with the subscription URL — anyone able to read the database or a backup could fetch every feed. Kamerplanter now stores only the SHA-256 hash (token_hash). The token and ical_url appear only in the response of POST …/calendar/feeds and POST …/feeds/{key}/regenerate-token, i.e. exactly once; every other response carries neither token nor ical_url. You replace a lost URL by regenerating the token. Migration v0090 hashes the existing tokens on the first start and removes the clear value: URLs already handed out still resolve their feed. Measured along the way: the public endpoint GET /api/v1/calendar/feeds/{key}/feed.ics the subscription URL points at had not been mounted since March 2026 (404); issue #2226 mounts it again (separate entry). No image rollback below v0090: an older image re-creates the old unique index on token and fails to start as soon as two feeds no longer have a clear token. API clients that read token or ical_url from a read response must switch to the create or regenerate response. PUT …/feeds/{key} and POST …/feeds/{key}/regenerate-token are now reserved for the member who owns the feed and for the lead; every other member gets 404 (until now any member from grower up could rename another member's feed or regenerate its URL). A PUT no longer writes the token hash back, so it cannot undo a concurrent rotation. Rolling update: a feed that a still-running pod of the old image creates or rotates after v0090 ran keeps its clear token — the migration runs only once. Scale the old deployment down before the update, or check afterwards with FOR f IN calendar_feeds FILTER HAS(f, "token") COLLECT WITH COUNT INTO n RETURN n and have the affected feeds rotated.
  • Security (tenant isolation): the self-hosted plant identification no longer names a species that belongs to another garden (issue #2173). Until now a match from the shared reference-image index could carry the key and scientific name of another garden's private species. You now get matches only for global species, your own and those shared with your garden — in the identification and in the photo-quality assessment. This also covers entries already in the index. The reference-image acquisition indexes global species only; for a garden's species POST /api/v1/admin/reference-images/acquire/{species_key} answers 404. The inference service refuses a user contribution (source=user_contributed) without is_active=false or without contributed_by/tenant_key with 422.
  • Fixed (API): an edit no longer resets fields the form does not contain. An edited system location type (e.g. "Greenhouse") lost its system flag and could be deleted afterwards; it now stays a system type, and deleting it still answers 422. An edited substrate mix stays a mix with its components instead of becoming a plain substrate. When you edit a species, its traits (traits), pruning months and green-manure suitability are kept; an edited growth phase stays recurring and keeps the source of its Kc value. The server maintains these fields — a value in the request body is still ignored.
  • Changed (permissions): Which weather sources a location uses, and in which order, is now decided by the lead only — as the role model specifies. As a grower you still see the selection but can no longer change it or test a source (PUT /api/v1/t/{slug}/sites/{key}/weather-source and POST …/weather-sources/test answer 403). In the interface the "Weather source" section is then read-only for you (issue #2181).
  • Fixed: Editing a site, a location or a slot reset values the server maintains itself. An edited site lost the list of its active weather sources (weather_source_priority). An edited location ended up with depth 0 and no path and showed in the site tree like a top-level location. An edited, occupied slot counted as free afterwards and could be handed out again by the next placement. These values are now kept; a location's depth and path are derived from its parent as on create (issue #2181).
  • Fixed (invitations), issue #2162: An email invitation into a garden was never sent — the settings page said "Invitation sent" and threw the link away. Now an email with the acceptance link ({FRONTEND_URL}/invitations/accept?token=…, valid for 7 days) goes to the invited address after the invitation is created; it names neither the garden nor the inviter. Changed (API): POST /api/v1/tenants/{slug}/invitations/email and …/invitations/link additionally return accept_url, the email invitation also delivered. With delivered: false (no delivering email adapter — EMAIL_ADAPTER=console without DEBUG included —, sending refused or a transport failure) the invitation still exists; the page says so and shows the link to pass on. An invitation link now copies the link instead of the bare token. Whoever opens an invitation link without a session lands on the acceptance page again after signing in — also through a sign-in provider; the registration link carries the invitation along. Bounded: POST …/invitations/email allows RATE_LIMIT_INVITATION_EMAIL per account (default 10/minute) and at most TENANT_INVITATION_EMAILS_PER_DAY email invitations in 24 hours (default 50, across all tenants); beyond that 429 RATE_LIMIT_EXCEEDED, nothing is created or sent. An invitation that can no longer be accepted (#2180) no longer opens the registration under invite_only or the domain list either.
  • Security (invitations), issue #2180: Accepting an invitation checks the rank rule again (REQ-024 AK-58): a lead invitation into the platform tenant whose sender is not (or no longer) lead there answers 403 and creates nothing. Before, such an invitation — issued before the rule (#2084) by a member with the management scope but without the lead role — made the accepting account a platform admin. Migration v0089 revokes every open lead invitation into platform on upgrade and logs only their number (revoke_platform_lead_invitations_applied). Anyone who had such an invitation open has to be invited again — by a platform lead. The migration does not touch already accepted invitations of this kind — how to find and review such platform leads is described in Platform Admin Area.
  • Security (tenant isolation): the activity catalogue (GET /api/v1/activities, GET /api/v1/activities/{key}) shows you the global activities and those of your active garden, never another garden's; a foreign key answers "not found". An automatically generated activity plan that every garden shares is now built from global activities only (issue #2119). Today only platform admins create activities and all of them are global — nothing changes for you.
  • BREAKING (API): knowledge questions only inside a garden and behind the AI admission (issue #2175, REQ-031 v2.5, scenario 15). POST /api/v1/knowledge/ask is removed (no alias, 404). The question to the knowledge base is now POST /api/v1/t/{slug}/ai/knowledge/ask — same response body; the request now takes top_k ≤ 10 (was 20) and a bounded plant context (species, phase, substrate at most 100 characters each, ec 0–20, ph 0–14, otherwise 422). The old route answered any signed-in account, viewers and service accounts included, with a language-model call: no AI toggle, no consent, no daily budget. Like "Why?" and chat, the new route requires the grower role (viewer 403), the operator switch AI_FEATURES_ENABLED (404), the garden's AI setting (403 ai.disabled_for_tenant), its own consent ai_knowledge_question (403 CONSENT_REQUIRED; with plant values in context also ai_tenant_data_access; when the Knowledge Service answers with a cloud model of the platform, also ai_cloud_processing and the garden's cloud approval, otherwise 403 ai.disabled_for_tenant — what counts is the system default provider, not the garden's provider) and the daily AI budget (429 AI_BUDGET_EXCEEDED) — all before the Knowledge Service is called. The per-account minute budget RATE_LIMIT_INFERENCE stays. When the Knowledge Service cannot be reached it answers 502. Light mode has no consent; there the knowledge question is POST /api/v1/public/ai/ask. GET /api/v1/knowledge/search (no language model) is unchanged.
  • BREAKING (API): /api/v1/public/ai/* in light mode only (REQ-031 v2.6 §5.3, REQ-027 v1.8). POST /api/v1/public/ai/ask and GET /api/v1/public/ai/health were reachable without signing in, in both modes — in full mode the ask route answered any anonymous caller with a language-model call, past consent, the garden's AI switch and the daily budget. In full mode both paths no longer exist (404); the knowledge question there is POST /api/v1/t/{slug}/ai/knowledge/ask. In light mode they now require a signed-in principal, which light mode supplies as the system user without a login — nothing changes for the light-mode UI. The address limit AI_PUBLIC_RATE_LIMIT_PER_MIN and the operator switch AI_FEATURES_ENABLED still apply.
  • Privacy (reference image contribution, issue #2174, REQ-034 v1.4 AC-17): Contributing an identification photo as a reference for the self-hosted recognition (POST /api/v1/t/{slug}/identification/reference) now requires the consent "Photo contribution to plant recognition" (reference_contribution) — as the automatic contribution from the gallery already did. Without the consent, or after you revoke it, the API answers 403 (CONSENT_REQUIRED); your daily quota is not consumed, no contribution is recorded and the image is not processed. The species is resolved only within your tenant: another tenant's private species answers 404 like an unknown one. Behaviour change: in Light mode the contribution is no longer possible and answers 409 (ADAPTER_NOT_AVAILABLE) — Light mode has no consent management, and REQ-034 switches the reference contribution off there.
  • Privacy (pest recognition, REQ-044 v1.3/v1.4, REQ-010 v1.6/v1.7): A pest photo that a platform admin promoted to global visibility now enters the recognition index as a reference only if the person who contributed it has given the separate consent "Photo contribution to pest recognition" (pest_reference_contribution, REQ-044 v1.4, REQ-010 v1.7). The consent "Photo contribution to plant recognition" (reference_contribution) names only gallery photos of plants and does not open the pest index; existing grants of it do not carry over — contributors must consent anew. Without the consent, after it is revoked, and always in Light mode, the photo stays promoted and visible, but the background task ends without calling the inference service (no_consent or light_mode). Revoking does not remove vectors that were already indexed; deleting the photo or the account does.
  • Changed (API): generating a glossary explanation needs the garden's AI approval (REQ-035 v1.4). POST /api/v1/t/{slug}/glossary/term/{slug}/generate now requires the garden-level AI toggle (REQ-031 stage 2) in addition to the role: off → 403 (AI_DISABLED_FOR_TENANT) before the cache, the daily quota or the language model is touched. Behaviour change: with AI switched off instance-wide the route now answers 404 instead of the curated short definition. Reading (GET …/glossary/terms, GET …/glossary/term/{slug}, the public and admin routes) is unchanged.
  • Knowledge Service, security (issue #2176): an unknown LLM_PROVIDER value used to fall back silently to the Anthropic adapter — a typo such as openai-compatible sent every question, together with the LLM_API_KEY meant for the local server, to the cloud. The Knowledge Service now refuses to start with an unknown value and names the allowed ones (anthropic, ollama, openai_compatible).
  • Security (tenant isolation, hygiene bundle, issue #2144): A set of small gaps from the multi-tenant audit is closed. Every response under /api/v1/ now carries Cache-Control: no-store unless the route sets its own — attachments, pest images and the chat stream keep theirs. "Prepare a substrate batch for reuse" needs at least the Grower role (Viewer: 403). Changing a member's additional permissions needs the same confirmation as a role change. As a platform admin you can no longer deactivate your own account or the last account holding the platform role (422). A comment key that does not belong to the given task answers 404 instead of 422; the CSV import matches only against species you also see in the species list. A notification without a recipient is no longer stored and readable by nobody. Companion lists, counts and recommendations show only species you may see; companion edges join only global species. Filter field names in database queries are checked before a query is built.
  • Changed (MCP): An idempotency_key sent again with different arguments answers conflict.idempotency_key_reused (409 on the REST alias) instead of replaying the first call's result; an expired entry is no longer replayed (issue #2144).
  • New (operations): python -m app.migrations.audit_legacy_stamps lists, read-only, rows whose author is not a member of their garden — traces of the early tenant backfill (v0004). Migration v0088 removes that stamp from pests, diseases and treatments. Details: src/backend/app/migrations/README.md (issue #2144).
  • New (API): service accounts for a garden (issue #2137, internal reference MT-041). Whoever is a lead and holds the Technical scope in a garden creates an account for a machine (Home Assistant, Grafana, CI/CD) with POST /api/v1/t/{slug}/service-accounts — without a password, as viewer or grower, with a first API key that works in this garden only. Also a list, key rotation (…/{sa_key}/rotate-key, previous keys revoked at once or after an overlap window of up to 24 h) and removal (DELETE …/{sa_key}). Every change asks for your password (step-up service_account_change) and is recorded in the security audit log. At most TENANT_MAX_SERVICE_ACCOUNTS (default 20) per garden; a service account takes a seat of the member limit. Light mode: not available. See Service Accounts & API Keys
  • New (API): An API key gets an IP allowlist (ip_allowlist, CIDR, nothing wider than /8 or /32), a rate limit (rate_limit_per_minute, 1–10000) and an expiry (expires_at, at most 730 days) when it is issued — for POST /api/v1/auth/api-keys as for service accounts. The three values were already enforced, but nothing could set them. An unusable value is a 422 before the password is asked for; the key list shows the values (issue #2137)
  • Changed (API): A service account can neither found a garden (POST /api/v1/tenants) nor accept an invitation (403); the member administration does not make it a lead (403). Service accounts are now managed by Lead and Technical instead of the Management scope (issue #2137)
  • Security (tenant isolation): writes to sites, locations, slots and nutrient plans now check tenant ownership in the service itself instead of only in the calling endpoint (issue #2107). Nothing changes for you; a foreign key still answers "not found". Detaching a nutrient plan from a plant and changing or deleting an activity of the shared catalogue are checked in the service too (issue #2120). Migration v0087 removes the never-populated tenant_key field from stored locations and slots. The same now applies to planting runs (create, update, delete, entries, creating, adopting and detaching plants, phase transition, phase dates, batch removal, removing the nutrient plan) and tanks (delete, state, maintenance, maintenance schedule, fill). Likewise feeding events, watering logs and watering events (update, delete, create), harvest batches (update, quality, yield, completing a harvest), succession plans (update, delete, generating runs), post-harvest batches, overwintering profiles, activity plans and confirming a CSV import. Completing the harvest of a whole run now only ends your own plants, even where a legacy record links the run to someone else's plant. The same goes for applying an activity plan to a run, batch removal, phase dates and re-slotting a run's plants, diary entries within a run and confirming a watering: they only consider your own plants. Confirming a watering only uses a run's nutrient plan if you may read it. Moving a run whose plants sit on slots to another location no longer fails with a server error. The actuator, aquaponics and InvenTree services (connections, links, equipment) now take the tenant only as a named argument in every method, so a swapped key can no longer go unnoticed; nothing changes for you. The same now applies to a plant's photo gallery (uploading and linking a photo, cover photo, caption and capture date, quality check, deleting a photo). Likewise species, cultivars, substrates, substrate mixes and substrate batches (create, update, delete): the service now always requires your tenant, your role and the platform-admin flag explicitly instead of writing unchecked without them. Changing and deleting entries of a plant's phase history now checks the tenant in the service itself. Likewise plants (create, update, remove), IPM treatment applications, the phases of a workflow (create, update, delete) and duplicating a workflow, propagation batches (create, finalize), deleting a diary entry, a site's hardiness zone and the nightly winter-quarter climate check. A workflow phase without a workflow still cannot be changed or deleted; the answer stays "not found".
  • BREAKING (API): AI calls have a daily budget per account and per garden (issue #2110). Every route that calls a language model — generating tips and the daily tip, "Why?", chat message, generating a glossary explanation, AI diagnosis — counts against AI_BUDGET_USER_CALLS_PER_DAY (default 50 per account and garden), AI_BUDGET_TENANT_CALLS_PER_DAY (500 per garden) and AI_BUDGET_TENANT_TOKENS_PER_DAY (2000000 tokens per garden), each per UTC day. Above that the API answers 429 with AI_BUDGET_EXCEEDED (details[0].code names the budget) and Retry-After until the day ends. When Valkey cannot be reached, no AI call runs (503 AI_BUDGET_UNAVAILABLE). These routes also carry the per-account minute budget RATE_LIMIT_INFERENCE. The AI call log stores each call's token counts (prompt_tokens, completion_tokens) so a garden's cost is countable. A refused chat message now arrives as an HTTP error instead of a broken stream; the interface explains every refusal.
  • Fixed (API): A 429 from a limit in the domain code (daily caps of plant identification and reference contribution, API-key limit, AI budget) now carries the Retry-After header (issue #2110). With #2109 only the per-minute limit got it; the wait of the others was in the message text only.
  • Glossary: Whether a generated glossary explanation counts as cloud processing is now decided by the platform's default provider, not by the default of the garden that asked for the term first (issue #2110). The explanation is served to every garden; its cloud notice is now the same for all and stored with the entry.
  • Behaviour change (API): Six lists that used to return every item now read a bounded window with offset (default 0) and limit (default 50, at most 200; issue #2131, MT-035): GET /admin/platform/tenants, GET /admin/platform/users, GET /tenants/{slug}/invitations, GET /t/{slug}/ai/conversations, GET /t/{slug}/tasks/plants/{plant_key} and GET /t/{slug}/post-harvest/{key}/observations. The response stays an array; a client without limit now receives the first 50 items instead of all and has to read further pages with offset. Tenants, users, invitations and conversations are sorted newest first, tasks by due date, observations newest first — on equal timestamps the key decides, so no page returns an item twice or skips one.
  • API: GET /t/{slug}/plant-instances, /watering-logs, /watering-events and /feeding-events also accept after=<key> and page by key instead of by offset (issue #2131). These four lists also no longer count every item of the garden on each page — the number was never in the response. A new index (tenant_key, _key) on the four collections is built on the next start; measured on ArangoDB 3.12 with 50,000 items of one garden, a page drops from 167 ms (page 1) and 262 ms (last page by cursor) to about 4 ms for plants, and the first page of a small garden from up to 132 ms to about 4 ms for watering and feeding events.
  • API: POST /calculations/sun-times-range computes at most 366 days per request; a longer range is refused with 422 (issue #2131). Until now one call computed every day between any two dates.
  • Behaviour change (API): Six more lists now read a bounded window with offset (default 0) and limit (default 50, at most 200; issue #2131, MT-035): GET /tenants/{slug}/members, GET /tenants/{slug}/assignments, GET /admin/platform/tenants/{tenant_key}/members, GET /t/{slug}/tasks/workflows/{key}/executions, GET /t/{slug}/inventree/references and GET /t/{slug}/propagation/stats/by-cultivar. The response stays an array; a client without limit receives the first 50 items instead of all. Members and assignments are sorted oldest first, executions newest first, references by collection, the statistics by cultivar key — on a tie the key decides. Two new indexes are built on the next start: (tenant_key, created_at) on location_assignments and workflow_template_key on workflow_executions; measured on ArangoDB 3.12 with 20,000 items, the first page used to read the whole collection and now reads only the garden's or the template's items.
  • API: Thirteen of a garden's lists no longer count every matching item on each page — the number was never in the response (issue #2131, MT-035): GET /t/{slug}/tasks, GET /t/{slug}/planting-runs, GET /t/{slug}/planting-runs/{key}/diary, GET /t/{slug}/planting-runs/{key}/plants/{plant_key}/diary, GET /t/{slug}/plant-instances/{key}/diary, GET /t/{slug}/ipm/plants/{plant_key}/inspections, GET /t/{slug}/ipm/plants/{plant_key}/treatment-applications, GET /t/{slug}/harvest/plants/{plant_key}/observations, GET /t/{slug}/harvest/batches, GET /t/{slug}/post-harvest, GET /t/{slug}/propagation/events, GET /t/{slug}/propagation/batches and GET /import/jobs. Response, window and filters stay the same; on equal timestamps the key now decides the order, so no page returns an item twice or skips one.
  • API: Fifteen more lists no longer count every matching item on each page — the number was never in the response (issue #2131, MT-035): GET /activities, GET /ipm/pests, GET /ipm/diseases, GET /ipm/treatments, GET /phase-definitions, GET /phase-sequences, GET /t/{slug}/sites, GET /t/{slug}/tanks, GET /t/{slug}/fertilizers, GET /t/{slug}/nutrient-plans, GET /t/{slug}/succession-plans, GET /t/{slug}/overwintering-profiles, GET /t/{slug}/propagation/protocols, GET /t/{slug}/tasks/workflows and GET /t/{slug}/harvest/indicators. The same holds for the MCP tool get_harvest_readiness, the inspection read of the diagnosis tools and a plant's inspection schedule. Response, window and filters stay the same; on equal names the key now decides the order of nutrient plans, rooting protocols, phase definitions and workflow templates, so no page returns an item twice or skips one.
  • Fixed (privacy): Six clean-up tasks failed with TypeError on every run since they were introduced (July 2026) and deleted nothing (issue #2150, NFR-011): ai.cleanup_expired_conversations, ai.cleanup_expired_audit_log, glossary.cleanup_expired_cache, glossary.invalidate_after_reingest, mcp.cleanup_expired_audit_log, mcp.cleanup_expired_idempotency. They now open their database connection through get_db() like every other task. After the update the first runs clear the backlog: AI audit entries older than 30 days, expired glossary cache rows and MCP idempotency records older than 24 hours are deleted. Because the idempotency record never went away, an MCP write tool called again with a reused idempotency_key returned the stored result instead of running, even after more than 24 hours; the REQ-033 window of 24 hours applies again — an expired record is no longer replayed, even before the hourly run removes it (issues #2144, #2169). AI conversations (90 days) and the MCP audit (90 days) had no entries due yet.
  • Privacy: the personal data export (Art. 15/20) now includes your personal garden (issue #2135): sites with GPS coordinates, beds, slots, plants, planting runs, all tasks, the diary and attachment metadata — limited to the personal gardens you own, without other members' account identifiers. From organizations, only the membership and your own contributions remain in the export, as before. Sensor readings and tank, watering and feeding logs are not included yet.
  • Privacy: an account deletion no longer leaves an organization without management (issue #2134). If the person was the last one with the management right, the longest-serving lead takes over management (security audit via: account_erasure, the members are notified). If no lead is left or the person was the only member, the organization becomes orphaned and is deleted after the tenant-deletion grace period; members and platform admins are notified. Until now such an organization stayed forever and could not be administered. The deletion preview (GET /privacy/erasure-preview, GET /admin/platform/users/{key}/erasure-preview) names both cases in the new organizations field. The emergency administration sketched in REQ-023 §5a.5 is dropped.
  • BREAKING (API): A tenant deletion is now scheduled instead of run at once (issue #2123, REQ-024 AK-52). DELETE /tenants/{slug} and DELETE /admin/platform/tenants/{key} still answer 202, but with status: "scheduled" and scheduled_for: the tenant is closed for everybody until then, its members receive an email with the date, and only after RETENTION_TENANT_ERASURE_GRACE_DAYS days (default 90, 0 = at once as before) does the daily run delete it. Until then POST /tenants/{slug}/erasure/cancel (Lead and Management, password) or POST /admin/platform/tenants/{key}/erasure/cancel (platform admin) cancels the deletion. Tenants carry a status (active, suspended, pending_deletion, orphaned, deleted) instead of the is_active field for this; migration v0085 converts existing tenants, and is_active stays in the responses as a derived value. A platform admin can no longer reactivate a tenant whose deletion is scheduled through the active switch (422) — that is what the cancellation is for.
  • Security (sessions): Refresh tokens now form a family per sign-in (issue #2116, MT-019, REQ-023 §3.2a). Presenting an already rotated refresh token again ends the whole family and is logged as auth.refresh_replay_detected; within 60 seconds the same client (same User-Agent) on the cookie path receives an access token and no new cookie instead (two tabs renewing at once). The body transport of paired devices has no grace window. Rotated tokens are kept until their own expiry so a late replay is still recognised. Access tokens end with their session: they carry the account's token generation (claim gen), and every revocation — logout, logout-all, ending one session, a password change or reset, deactivation, an Art. 17 request, the email change and its revert, a detected replay — moves it, so a token issued before is refused with 401 from the next request instead of living out its 15 minutes (REQ-023 AK-14). Ending one session therefore also ends the access tokens of the account's other sessions; those renew silently. No extra database read per request (the account is read anyway). An admin deactivation now ends every session too. Tokens issued before the update stay valid until they expire; no migration, no forced re-login.
  • Security: Password-reset and email-verification tokens are stored only as SHA-256 digests (password_reset_token_hash, email_verification_token_hash; issue #2158). Before, the stored value was the link itself, so a read of the database or a backup could take over an account within the reset hour. Migration v0086 hashes the stored values in place; links mailed before the update keep working once.
  • Web push: an endpoint belongs to one account at a time. Subscribing it removes it from every other account (issue #2117) — on a shared device the previous user no longer receives pushes through it.
  • BREAKING (API), security: Kamerplanter is connected to one Home Assistant instance (the operator's) and uses it for all tenants — documented as a "single-household instance" (issue #2112, MT-015, REQ-005 §4c, REQ-018, REQ-030). Which entities of that instance a tenant may use is now decided by the platform admin per tenant (new collection tenant_ha_entity_grants, endpoints /api/v1/admin/ha-entity-grants/tenants/{tenant_key} to list, view the inventory, release and withdraw). A sensor, actuator, HA weather source or HA notification target naming an entity that is not released → 422 HA_ENTITY_NOT_GRANTED (value-free). …/sensors/live, the tank live state, the frost warning, ingest, tank sync, actuator sync, the control loop and the weather fetch no longer read entities that are not released (counted in the log only); an actuator that is not released falls back to the manual task; HA notifications go only to targets released to the notification's tenant — the default notify.notify included. Breaking: GET /t/{slug}/ha/weather-entities, /ha/sensor-entities and /tanks/ha-entities now require the technical scope (before: every member, viewers included) and, like actuators/integrations/home-assistant/entities, return released entities only. Migration v0084 creates the collection and releases every entity already referenced to the referencing tenant, so nothing stops after the update. In light mode (one household, no admin area) entering an entity releases it.
  • New (operations): Who may create an account is now configurable (issue #2132, MT-036, REQ-023 §3.2d). REGISTRATION_MODE = open (default — no change for existing installations), invite_only or closed, plus an optional REGISTRATION_ALLOWED_DOMAINS (comma-separated email domains). It applies to POST /api/v1/auth/register and to the first sign-in through an OIDC provider. A pending email invitation for exactly that address is the exception to invite_only and to the domain list — locally with its token (invitation_token in the body), through OIDC with the address the provider verified; closed admits nobody, not even with an invitation. A refusal is 403 (REGISTRATION_NOT_ALLOWED), the same for taken and free addresses. GET /api/v1/mode reports registration: {mode, domain_restricted}. Existing accounts sign in in every mode
  • BREAKING (API): A tenant's member limit now takes effect (issue #2133, MT-037, REQ-024 AK-64). Once a tenant is full, accepting an invitation (POST /api/v1/tenants/invitations/accept) and the platform admin's add answer 422 (MEMBER_LIMIT_REACHED); the invitation stays pending. The effective limit is min(max_members, TENANT_MAX_MEMBERS_CEILING) — the new platform ceiling, default 50. A max_members above the ceiling is refused with 422 on creation and update; an organization created without one takes the ceiling. A personal tenant has the limit 1: whoever wants to invite someone into their personal garden raises the limit first (PATCH /api/v1/tenants/{slug} with max_members). Existing members always stay, even where a tenant holds more members today than its limit — only the next join is refused. No migration
  • BREAKING (API, privacy): The consent purposes hibp_check and external_enrichment are no longer offered (issue #2136, MT-040, REQ-025 v1.31 §3.6) — no code read them: there is no HaveIBeenPwned check, and the GBIF/Perenual enrichment sends species names only. GET /api/v1/privacy/consents and the privacy policy no longer list them; POST /api/v1/privacy/consents and DELETE /api/v1/privacy/consents/{purpose} answer 422 for them. Stored consents stay as they are (export, erasure, R-04 unchanged; no migration). The error_tracking purpose is now called "Attribute error reports to my account (Sentry)" and takes effect: an error report carries the account and tenant pseudonyms only with this consent. The consent banner no longer names HaveIBeenPwned or master-data enrichment.

  • Error tracking: An event of an authenticated request carries the pseudonyms id=sub_… and tenant=ten_… in its user block (issue #2129, NFR-011 v1.37). Whatever the Sentry SDK or an integration sets as the user itself is dropped — in the inference and knowledge services too (shared module kp_errortracking).

  • Operations (logs, Celery, MCP): A request's log lines now carry, besides request_id, the pseudonyms actor (account, sub_…) and tenant (tenant, ten_…) once the request is authenticated or its tenant resolved (issue #2130, MT-034, NFR-011 v1.37). A Celery task started from the request takes request_id, actor and tenant along in message headers (kp_request_id, kp_actor, kp_tenant — the pseudonyms only, never keys) and binds them, together with task_id, to each of its log lines; they are unbound after the task. The MCP audit row (mcp_audit_log) additionally carries api_key_ref (a salted reference to the key used), client_ip_ref (truncated like R-03), request_id and entity_keys (the record keys from the tool input's *_key fields); all four are in the Art. 15 export (REQ-033 v1.9). A tenant= a line sets explicitly takes precedence. Dashboards that want to count requests per tenant can now filter on tenant=.

  • Security: The Home Assistant token, the Pl@ntNet API key and the Apprise URLs are stored encrypted (Fernet with FERNET_KEY, like the OpenWeatherMap key already was; issue #2113). Until now they were plaintext in system_settings and notification_preferences — an Apprise URL carries the bot token or webhook secret. New values are encrypted on save, existing ones once by migration v0083 at startup; a read never writes. Without a FERNET_KEY (startable only with DEBUG=true) they stay plaintext and the log reports encryption_disabled. Changed (API): GET/PUT /api/v1/t/{slug}/notifications/preferences return Apprise URLs only as placeholders (tgram://****#1: scheme and position). Sending a placeholder back unchanged keeps the stored URL; a placeholder without a matching stored URL is refused with 422 (APPRISE_URL_MASK_UNKNOWN). The data export (Art. 15) states only the number of URLs (urls_configured). Fixed: Deleting the Home Assistant settings, the global OpenWeatherMap key and the storage settings reported success and left the stored value in place. Upgrade note: After the migration an older image no longer finds these three settings (the HA token and the Pl@ntNet key fall back to the environment variables, Apprise does not send); after a rollback, enter them again.
  • BREAKING (API): Expensive authenticated routes now carry a budget per account (issue #2109). File uploads (attachments, plant and task photos, pest images, the CSV import including its confirmation) allow RATE_LIMIT_UPLOAD (default 30/minute), inference routes (CV diagnosis, pest detection, plant identification, reference contribution) RATE_LIMIT_INFERENCE (20/minute) and the PDF prints under /print RATE_LIMIT_EXPORT (20/minute) — each route counts on its own. Above that the API answers 429 with a Retry-After header, which every 429 response now carries. The RATE_LIMIT_GENERAL setting is gone: nothing ever read it, although the reference described it as the limit of the general endpoints. If your environment sets it, you can delete it.
  • BREAKING (API): HEIC/HEIF photos are no longer accepted (issue #2139). The EXIF strip cannot re-encode them, so they were stored with their GPS position. An upload now answers 415; the app's camera and identification dialogs already convert photos to JPEG. While STORAGE_STRIP_EXIF is on, the upload refuses every image type the strip cannot clean — also when a STORAGE_ALLOWED_MIME_TYPES_<CATEGORY> override admits it again. HEIC photos stored before stay unchanged and an Art. 17 erasure still records them as skipped.
  • BREAKING (API): Images above 40 megapixels are refused with 413 (IMAGE_PIXEL_LIMIT_EXCEEDED) before they are decoded (issue #2108). Until now a 445 KB PNG of 12,000 × 12,000 pixels passed the upload, and the EXIF strip created one Python object per pixel (measured at 80 bytes per pixel, about 11.5 GB). The ceiling applies to every image decode in the backend; identification and CV diagnosis now answer a pixel bomb with this code instead of PAYLOAD_TOO_LARGE. Palette images (PNG in P mode) keep their colours; they used to come back black.
  • Changed (API): A missing thumbnail triggers its generation at most once in five minutes instead of on every request (issue #2108). When the generation fails for good (too large, undecodable, or after three attempts), GET …/attachments/{id}/thumbnails/{size} answers 404 instead of 202 for ever; the MCP diary-photo tool then reports the photo as unavailable.
  • Security: The security audit security_audit_log now also records when a platform admin deactivates or reactivates an account or sets or revokes its email verification (one row per flag that actually changed), when a platform admin suspends or reactivates a tenant, and when a tenant's management or a platform admin requests or cancels its deletion (issue #2111, MT-014, NFR-011 R-38, REQ-024 AK-60). Before, none of this left a row; cancelling a deletion even removed the deletion record. The rows name account and tenant keys only; an account row has no tenant_key, a tenant row no target_user_key — so both fields are now nullable in GET /api/v1/admin/platform/security-audit. Changing a tenant's name, description or member limit writes no row. No migration.
  • Security: Every change of a tenant membership (add, change of role or scopes, removal, leaving, accepting an invitation, creating a tenant) now writes a row to the persistent security audit security_audit_log (issue #2111, MT-014, NFR-011 R-38, migration v0082): who, whose membership, which tenant, old and new role or scopes, request id and time — account and tenant keys only, no free text. Kept 730 days (daily task security_audit.purge_expired); an account erasure turns both account keys into tombstone hashes. Platform admins read the rows through GET /api/v1/admin/platform/security-audit. The service call change_member_scopes takes the acting account (actor_user_key) for it.
  • Security (API): POST /api/v1/admin/platform/tenants/{key}/members and POST /api/v1/admin/platform/users/{key}/memberships now require the administrator's own step-up (issue #2106, MT-008, REQ-024 AK-59): the body carries current_password (or step_up_token / step_up_code for the act admin_membership_add, bound to <tenant_key>|<user_key>); without it 401, from an API key 403, after failed attempts 429. A platform admin cannot add themselves to the platform tenant (422), and the lead role in the platform tenant is handed out only by someone who holds it. Before, the admin session alone added any account — the platform tenant with lead included — without a trace. The two admin pages (tenant, user) ask for the step-up when adding.
  • BREAKING (API): An e-mail invitation is now accepted only by the account that holds the invited address and has confirmed it (issue #2115, MT-018, REQ-024 AK-61). POST /api/v1/tenants/invitations/accept answers 403 when the accepting account has another address or has not confirmed its address; the invitation stays open and no membership is created. Before, the token alone sufficed: a forwarded or intercepted link granted the membership, up to the lead role, to any signed-in account. Invitation links stay valid for any signed-in account. Whoever wants to accept with another account has the invitation re-issued to that account's address.
  • Security: Whoever leaves a tenant or is removed from it no longer stays the assignee of its tasks, and the care reminders go to active members only (issue #2114, MT-017, REQ-024 AK-62). Before, tasks kept the ex-member as assignee and notifications.dispatch_due_care kept notifying them with plant names and due dates. The daily summary (notifications.send_daily_summary) now groups per (user, tenant) — it used to put all of a user's tasks under the tenant of their first task.
  • Security (API): A deactivated tenant now really locks its members out (issue #2105, MT-007, REQ-024 AK-48). Until now PATCH /api/v1/admin/platform/tenants/{key} with is_active=false only removed it from the tenant list; anyone who knew the slug kept reading and writing through /api/v1/t/{slug}/… and the X-Active-Tenant header (measured on ArangoDB 3.12: 200). Both now answer with the same 403 as for an unknown slug, a request without the header falls back to the global catalogue when the personal tenant is deactivated. Over MCP a deactivated tenant was already out of reach (list_tenants omits it, a tool call answers not_found); a test now holds that. The memberships stay stored: reactivating restores access unchanged. Platform administration under /api/v1/admin/platform/… still reaches a deactivated tenant.
  • A tenant is now created atomically with its founder's membership, both edges and the security-audit row — one transaction instead of four separate writes (issue #2118, MT-022, REQ-024 AK-63). Before, a failure between two writes left a stranded tenant behind (slug taken, nobody able to delete it); a registration whose tenant write failed left an account with its sign-in provider and no personal tenant, which the retry answered as "already registered". Now, when a step of the registration fails the account is taken back, and the retry is a genuine registration.
  • Security (MCP): set_nutrient_plan_phase_targets with dry_run=true read the phase entries of another tenant's nutrient plan (preview before, EC values, phase names) because get_phase_entries looked the plan up without a tenant (issue #2104). The plan is now resolved under the caller's tenant first; a foreign or global plan answers not_found. get_phase_entries takes tenant_key for it.
  • BREAKING (API, MCP): Tenant-less MCP write tools require the platform admin role (issue #2103, REQ-033 §4.4). Until now any kp_ key sufficed with lead of its owner's personal tenant, so any key holder could rebind the phase sequence of a global species through assign_species_phase_sequence. The call now ends with permission.denied (audit row DENIED), tools/list shows the tool to platform admins only, and a key restricted to one tenant is never a platform admin. The tool also refuses a tenant-owned species with not_found. Only affects operators who called the tool with a non-admin key.
  • Security (API): The reverse lookups GET /api/v1/phase-sequences/{key}/species and GET /api/v1/phase-definitions/{key}/species and the MCP tool list_species_by_phase_sequence used to name the private species of every tenant (issue #2102). They now return only global, own and granted species; the tool takes the optional argument tenant for that (without it: global species only).
  • BREAKING (API): Shared workflow templates (tenant_key == "", such as a species' generated activity plans) can no longer be changed or deleted in place (issue #2101, REQ-006 §4, REQ-049 §2.3). PUT /api/v1/t/{slug}/tasks/workflows/{key} creates a copy for the tenant and changes that; DELETE on the shared template and writing its phases and task templates through /tasks/phases/* and /tasks/templates/* answer 403. The same goes for task templates without an owner that migration v0035 could not attribute. Anyone who edited them before duplicates the template first.
  • BREAKING (API): Global (shared) fertilizers can now only be changed and deleted by the platform administrator (issue #2100, REQ-049 §2.3). PUT/DELETE /api/v1/t/{slug}/fertilizers/{key} on a fertilizer with tenant_key == "" answer 403 for tenant roles, and 404 for another tenant's fertilizer; your own fertilizer stays editable for growers and deletable only by the lead. Anyone who curated shared fertilizers through a tenant now needs the platform role. Substrates and fertilizers have shared this rule from one implementation since.
  • Security: The e-mail notification channel now mails only addresses whose owner proved them (email_confirmed_at, issue #1948, REQ-023 v1.38, REQ-030 v1.7). An account registered with REQUIRE_EMAIL_VERIFICATION=false receives no notification mail until it follows a verification link — not even after the setting is switched to true; POST /auth/resend-verification issues the link to such accounts too. Existing verified accounts keep their mail (migration v0080 stamps them; the dry run counts them). At startup email_channel_without_verification warns when a sender is configured and verification is off.
  • BREAKING (API): The platform-admin account deletion is asynchronous (issue #1949, REQ-024 §1a.2, REQ-025 AK-IE-01). DELETE /api/v1/admin/platform/users/{key} now answers 202 Accepted with a body {erasure_key, status, requested_at, message} (formerly 204 without a body). The request checks the step-up, records the erasure request, deactivates the account, revokes its sessions and tells the other members of its personal gardens right away (still no grace period); a Celery task (retention.run_account_erasure) then runs the erasure — the personal tenants in bounded batches with a heartbeat, then the account's own plan. The 500 ERASURE_INCOMPLETE and 502 answers of this endpoint are gone — a failed or incomplete run is the request status partially_completed (retried by the daily run), readable at the new GET /api/v1/admin/platform/erasures/{erasure_key}. Still decided synchronously: 401/403/404/422/429 (step-up, permission), 409 (a run holds the request) and 503 (the deployment cannot erase; nothing changed). Callers that waited for a finished erasure must poll the status. Deletions started by the old synchronous route before the deploy finish as before; the daily run resumes one that was cut off
  • Security (API): PATCH /api/v1/admin/platform/users/{key} now requires the administrator's own step-up whenever it changes email_verified or is_active — lowering them, too (issue #1992; formerly only raising them). Previously one PATCH email_verified=false, also through an API key, made an established local account the next run's candidate of the unverified-account clean-up, which erased it with neither a step-up nor a notice to the other members of its personal garden. A lowered email_verified is now recorded (email_verified_lowered_at) and such an account is never removed by the clean-up; the clean-up also tells the other members of a personal garden before erasing it. Accounts an administrator demoted before this release carry no marker; the clean-up also spares every account that ever signed in (last_login_at), which covers them unless they never signed in
  • BREAKING (API): Tenant deletion is asynchronous (issue #1792, REQ-024 AK-53). DELETE /api/v1/tenants/{slug} and DELETE /api/v1/admin/platform/tenants/{key} now answer 202 Accepted with a body {tenant_key, status, requested_at, message} (formerly 200 with {"message": "Tenant deleted"} or 204 without a body). The request checks the permission and the step-up, creates the deletion record and freezes the tenant (memberships deactivated); a Celery task (app.tasks.tenant_tasks.run_tenant_erasure) then deletes in batches of 1000 records, each in its own ArangoDB transaction, and reports a heartbeat on the record between batches. The 500 TENANT_ERASURE_INCOMPLETE and 502 error responses of the delete endpoints are gone — failures of the run are recorded on the record and retried by the daily run; from the third unsuccessful attempt on, the log event tenant_erasure.escalated appears. Callers that waited for a completed deletion must treat the tenant as frozen. Deletions already running at deploy time are taken over by the daily run after six hours without a heartbeat
  • Plant-instance and planting-run plant responses now embed species and cultivar summaries (denormalization), so the frontend can build readable names without extra requests
  • Harvest: batch_id is nullable in API responses (string | null) instead of an empty string; the uniqueness index on harvest_batches.batch_id is unique + sparse. Existing data is migrated by v0030
  • Tenancy: the plant id (instance_id) and the lot label (batch_id) are unique per tenant, the slot id (slot_id) per location — no longer across all tenants (issue #2065, migration v0079). Until now onboarding skipped every plant of the second tenant to pick the same species (onb-<species>-<n> was taken), and an identifier another tenant used was refused with 409 — the refusal revealed that another tenant holds it. Within a tenant or location a duplicate identifier is still a 409
  • Tenancy: a planting run no longer follows an entry's stored species into another tenant's private species (issue #1963, migration v0081). Rows written before the #1871 fixes can still name such a species after migration v0067 removed its edge; starting such a run now answers 404 for the species (edit the entry to a species you can read), its phase timeline and irrigation demand skip it. Migration v0081 also clears equipment.location_key and the foreign keys of watering_logs.slot_keys that name another tenant's location or slot; it only counts location assignments and run entries (their field is required), so check the counts before the deploy
  • Tenancy: a plant that a legacy planting run created with another tenant's private species no longer shows that species' name. The plant lists, the plant detail, the diary overview, print labels and checklists, the overwintering labels, the photo assessment, the diagnosis context, the watering suggestion and the MCP plant detail now resolve the stored species under your tenant: a species you cannot read is shown as unknown, never as the foreign name (issue #2082). Readers that still resolve it unscoped are listed in the guard test_stored_species_key_readers_are_decided.
  • Tenancy: the care dashboard, the winter-reminder gating and the care-profile presets of a plant that a legacy planting run created with another tenant's private species no longer use that species: the dashboard shows no species name for it (it used to show the foreign one), and the foreign species' frost sensitivity, family and watering guide no longer drive its reminders or presets (issue #2082).
  • Plant-protection reference data: the pests, diseases and treatments of plant_info.yaml and adventskalender.yaml, with their treatment links, are now seeded — the loaders read keys the files do not use (Bemisia tabaci, Septoria apiicola and Ferramol, among others, were missing). A row the model refuses is skipped and logged (ipm_seed_row_invalid) instead of inventing data; currently "Alcohol Spray" (chemical without a waiting period)
  • Operations (rollback): No image rollback below the first migration of a retired index (issue #2064). An older backend image re-creates, at startup, the unique indexes a migration dropped, and the migration never runs again — measured on ArangoDB 3.12.8. Floors: v0030 (dense index on harvest_batches.batch_id, shipped since v0.0.24), v0041 (global index on species.scientific_name_normalized, since v0.2.1), v0062 (unique index on attachments.storage_key), v0064 (auth_providers without the configuration), v0069 (fertilizers across tenants), v0075 (tanks.name), v0076 (activities.name), v0077 (workflow_templates.name), v0079 (plant_instances.instance_id, harvest_batches.batch_id, slots.slot_id) — the last seven are in no release yet, so no rollback below the next release. Every start of a new image drops such a re-created index again (log event retired_index_reappeared); if its replacement is missing it stays, retired_index_refused_without_replacement is logged at error level and /api/v1/health/ready reports retired_indexes_refused above 0 (the status stays ready). The unique index on attachments.storage_key is not dropped again. An old pod that starts after the new ones re-creates the index until the next new pod starts. Where two tenants already share a name, the old image does not start at all (ERR 1210 unique constraint violated)
  • Care reminders: completing a due watering task creates the follow-up task immediately — previously it only appeared with the nightly planning run
  • Care reminders: a confirmation now only closes care tasks that are due today or earlier; a follow-up task already scheduled is left in place
  • Tenancy (hardening, issue #2119): every repository whose records belong to a garden now decides its tenant binding explicitly. Twelve more — attachments, plant diary, notifications, calendar feeds, pest detections, pest images, plant identifications, AI providers, invitations, memberships, location assignments and erasure records — refuse a list query without a garden instead of returning every garden's rows. Species, substrates and activities stay deliberately exempt (global catalogue plus your own entries) and state their reason; the guard test_tenant_bearing_repositories_declare_their_scope holds this. Nothing changes for you.
  • Calendar feeds (issue #2119): editing a calendar feed (name, filters, active) keeps it assigned to your garden and your account — before, saving dropped that assignment, the feed vanished from your feed list and could no longer be opened. Edit, delete and token renewal now check ownership themselves and answer "not found" for another garden's feed.
  • Privacy: The personal data export (Art. 15/20) now also contains the readings and logs of your personal garden (issue #2165): tanks with measurements, fills and maintenance, watering and feeding logs, the sensors and their readings (individual values 90 days, hourly averages 2 years, daily averages 5 years). A section with more than 100,000 readings contains the newest and states how many it leaves out. Readings the Home Assistant connection stored before 4 October 2026 without a garden reference are matched through your garden's sensors. The export format moves to version 1.2: every section states records_omitted and omission_note.
  • Fixed (MCP, issue #2169): An MCP idempotency record whose 24 hours have passed is no longer a hit when it is looked up in the database, even before the hourly clean-up has removed it. A record with a missing or unreadable expiry counts as expired; before, an unreadable expiry aborted the tool call with an error.
  • Fixed (AI glossary, issue #2169): After every finished knowledge-base reingest (ai.knowledge_service_ingest, Sundays 03:00 UTC) Kamerplanter empties the glossary cache and regenerates the explanations of all curated terms (REQ-035 §4.3). Until now nothing started that chain: the AI explanations expired after their 7 days and were never regenerated, so every term was left showing only its editorial short text. Emptying now also covers the Redis cache, which is read first — otherwise the regeneration would have taken every variant for still valid. Cost: one LLM call per term, language and expertise level per week (currently 32 × 6 = 192); with AI features switched off (ai_features_enabled) the regeneration is skipped.

Deployment (Helm)

  • Fixed (Helm, Docker): Uploads above 1 MiB failed at the frontend nginx with 413 (issue #2144, audit gap G-10). Neither the image's nginx configuration nor the one the chart mounts over it set client_max_body_size; nginx's default of 1 MiB therefore refused every larger attachment, plant and task photo and CSV import, long before the backend's 25 MiB limit (measured in the nginx-unprivileged image: 2 MiB and 25 MiB → 413). Both configurations now accept bodies up to storage.maxFileSizeMb + 1 MiB (default 26m); anything larger — a JSON body too — is refused by nginx with 413. The chart sets the same limit on the ingress main as nginx.ingress.kubernetes.io/proxy-body-size (ingress-nginx would otherwise cut at 1 MiB too). Upgrade note: ingress.main now has defaults in the chart; the Ingress is still only created when you give hosts, and enabled: false switches it off. The earlier docs recommendation proxy-body-size: "10m" was too small for 25 MiB attachments
  • Fixed (API): A file of exactly the allowed size was refused with 413 before it was read (issue #2144). The Content-Length pre-check compared the whole multipart body — file plus boundaries and form fields — with the file limit. It now allows 64 KiB for that envelope (one shared check for attachments, plant and task photos, pest images, reference images and CV diagnosis); the limit for the file itself stays exact, one byte more still gives 413.
  • Docs / dev chart: The ingress examples (Helm, ArgoCD, Kubernetes) and values-dev.yaml routed /api straight to the backend Service. The backend NetworkPolicy admits only the frontend (issue #1159), and only through the frontend nginx do the upload limit and TRUSTED_PROXY_HOPS: "1" apply. All examples now send / including /api to the frontend; in the dev release Vite proxies /api.
  • Fixed (Helm): A rolling update hung on a multi-node cluster when the new backend pod landed on another node than the ReadWriteOnce attachments volume (Multi-Attach error); a worker on another node than the backend never started (issue #2153). Backend and Celery worker now require the same node via podAffinity while the attachments live on a ReadWriteOnce volume; the rolling update keeps its zero downtime. With storage.backend: s3 or ReadWriteMany there is no constraint.
  • Fixed (Helm): The Celery worker has a startup and a readiness probe (issue #2154). Until now it counted as ready at container start, and a rollout replaced the old worker even when the new one could not log in to ArangoDB and failed every task. Now the worker (WORKER_READY_FILE, set by the chart) checks its login before the first task, exits after 60 seconds without success and reports ready only once its consumer runs; until then the rollout keeps the old worker. The liveness probe asks only its own worker — until now it succeeded as long as any worker in the cluster answered.
  • Changed (operations): Celery spreads tasks over three queues (issue #2128, MT-032): critical (retention and erasure, privacy sweeps, notifications, frost warning, actuators), celery (the default, name unchanged) and bulk (dataset and image acquisition, enrichment, glossary warm-up, storage migration). Every task has a time limit as a backstop (30 minutes soft, 35 hard; resumable or deliberately started runs three hours), each worker process reserves one message ahead only, and the Valkey visibility_timeout of four hours lies above the longest limit, so a reserved message is not delivered a second time. The chart starts the worker with -Q critical,celery,bulk. Upgrade note: if you override the worker args, carry the queue list over or drop -Q; a queue without a reading worker keeps its tasks silently. Rollout order: worker first, then beat and API — an older API keeps sending everything to celery, which the new worker reads. Still open from MT-032: the N+1 access in check_auto_transitions (not scheduled at present).
  • Added (operations): One worker per queue (issue #2128, MT-032): with celeryWorkers.dedicatedQueues: true the chart runs celery-worker for critical only plus two copies of it — celery-worker-default for celery, celery-worker-bulk for bulk — derived at render time from the merged worker values — overlays on controllers.celery-worker and networkpolicies.celery-worker (image, environment, securityContext, egress) apply to all three, and every policy that admits the worker admits the copies too. The default stays one worker for all three queues; the render without the switch is unchanged. With the default ReadWriteOnce volume both copies also run on the backend's node (512 Mi requested each). Idempotent clean-up runs (retention, expired tokens and invitations, audit and cache entries, expired overrides) now acknowledge their message only after the run (acks_late), so a vanished worker pod does not lose the run. The care notifications (notifications.dispatch_due_care, notifications.send_daily_summary) no longer read every task of every tenant into the worker; they ask, per active tenant, for that tenant's open due care reminders in AQL — worker memory no longer depends on the total number of tasks; a tenant whose read fails is logged and skipped, the others still get their reminders. Docs: Helm — One worker per queue.
  • Changed (operations): The beat tasks for actuators, tanks and plants no longer read every tenant's rows into the worker (issue #2128, MT-032). evaluate_control_rules, sync_actuator_states, sync_tank_states_from_ha, check_tank_alerts and evaluate_quarter_climate first fetch the tenants they may work on and then read only those tenants' rows — tenant, removed plants and a missing Home Assistant entity are part of the AQL query — one page of 1000 rows at a time. The worker holds a page instead of the whole installation, and the rows of suspended or pending-deletion tenants are not read at all. The same applies to the currently unscheduled check_auto_transitions, check_dormancy_triggers and update_vernalization_progress; the last two no longer process plants of inactive tenants either. Without a tenant (empty tenant_key): in the control loop an actuator without a tenant is now treated like a device of a suspended tenant — a firing safety rule decides, otherwise a device an emergency switches off (a heater, for example) is switched off once; schedules and overrides no longer apply. It used to run like an active tenant's device. Every run with such devices logs actuator_control_tenantless_devices with their count. A tank without a tenant is still checked for alerts; the Home Assistant tank and actuator syncs and the plant tasks skip rows without a tenant (Home Assistant read nothing for them anyway). A document that fails validation is skipped and logged with its key (tenant_walk_skipped_invalid_document) instead of aborting the run. The tanks_of_closed_tenants log counters are gone.
  • Upgrade note (Helm): The storage block of the chart values now takes effect (issue #2124, NFR-013 v1.9). Until now the chart did not read it — storage.backend: s3 still shipped local-fs with a PVC. The chart now derives the STORAGE_* variables of the backend and the Celery worker, the backend-attachments PVC (only with local-fs) and its mounts from it; the S3 credentials come as exactly two keys of the Secret named in storage.s3.credentialsRef (default storage-s3-credentials with STORAGE_S3_ACCESS_KEY_ID/STORAGE_S3_SECRET_ACCESS_KEY). Your own env entries still override the derived values. Newly refused: more than one backend or celery-worker replica on the default ReadWriteOnce volume aborts helm template/helm upgrade/the ArgoCD sync with a message, because a second pod on another node never starts (Multi-Attach error). Ways out: storage.backend: s3 (mandatory for shared operation), storage.localFs.pvc.accessMode: ReadWriteMany with an RWX storage class, or on a cluster with exactly one node storage.localFs.singleNode: true. The attachment PVC also carries argocd.argoproj.io/sync-options: Prune=false,Delete=false so ArgoCD does not delete it after the switch to S3 — migrate the files first with python -m scripts.storage.migrate --from local-fs --to s3 --checksum-verify
  • New (Helm): ArangoDB backup in the chart (issue #2122, NFR-002 v2.2). With backup.enabled: true and backup.s3.bucket the chart creates the CronJob kamerplanter-arangodb-backup: an arangodump of the application database, uploaded with rclone to s3://<bucket>/<prefix>/<timestamp>/, <prefix>/LATEST as a readable recovery point, server-side encryption and retention (retentionDays; the newest dump is never deleted). Off by default — existing installations do not change. The new page "Backup & Restore" describes the restore into an empty namespace; it was run through with a real dump
  • Fixed (Helm): The backend container starts under a startupProbe (issue #2125, NFR-002 v2.3). Until now the liveness probe killed it about 35 s after start — before it ever listened, while migrations and seeds were still running (measured on an empty database: about 40 s to start); a waiting replica never got the intended up to 600 s wait for the migration lock. The new probe gives the start up to 900 s; liveness and readiness only begin after it
  • Security / upgrade note (Helm): The ArangoDB pod now receives only ARANGO_ROOT_PASSWORD instead of the whole kamerplanter-secrets (issue #2126, NFR-002 v2.4) — until now JWT_SECRET_KEY, FERNET_KEY, INTERNAL_SERVICE_TOKEN and every mail/API key sat in the database pod. The backend, Celery worker, Celery beat and the backup no longer connect as root but with the account kamerplanter (database.arangodb.appUsername), which a new app-user container in the ArangoDB pod creates on every start with ARANGODB_PASSWORD — read/write on the application database only, no access to _system. The backend can start with such an account now (it used to abort with 401 No read access because it listed the databases through _system first). If your values set ARANGODB_USERNAME: root or an envFrom on the ArangoDB container (earlier docs examples), root access stays until you remove both; database.arangodb.appUsername: root restores the old behaviour explicitly. Recommended follow-ups: a separate ARANGODB_PASSWORD and the root password in a Secret of its own (database.arangodb.rootPasswordSecret) — see "Database credentials" in the Kubernetes deployment guide
  • New (Helm): autoscaler, disruption budget and ExternalSecret as opt-in (issue #2127, NFR-002 v2.6). controllers.<name>.horizontalPodAutoscaler and controllers.<name>.podDisruptionBudget add an HPA and a PDB per controller; externalSecrets.app.enabled: true lets the External Secrets Operator write kamerplanter-secrets from your secret store. Without these settings the chart renders exactly as before. Newly refused: an autoscaler for backend or worker on the ReadWriteOnce attachment volume (like several replicas already), more than one pod or an autoscaler for celery-beat, arangodb and vectordb, and a PDB that lets no pod be evicted (every node drain would hang) — also with maxUnavailable: 0 or "0%" and with a percentage minAvailable that Kubernetes rounds up to every pod. The Helm docs also recommend a memory bound with noeviction and AOF for Valkey (not as a default) and record that external databases are not supported yet
  • Docs (Helm): the backend's start-up time is now documented (issue #2125): up to 900 s to start, up to 600 s waiting for another pod's migration, 300 s until a migration lock counts as orphaned — and how to raise the budget for a very long migration. Migrations and seeds keep running when the backend starts; no separate migration job is needed. A test keeps the table in line with chart and code, and the floor for failureThreshold too (70: the 600 s wait plus 100 s of headroom for the pod's own start)
  • Fixed / upgrade note (Helm): Valkey keeps its queue across a restart (issue #2127, NFR-002 v2.8). The chart set valkey.dataStorage.size, a key the Valkey chart does not know — no volume was created, /data sat on an ephemeral emptyDir, and every restart lost the waiting tasks. Valkey now gets its own volume (kamerplanter-valkey, 1 GiB, survives helm uninstall), the Recreate strategy, maxmemory 256mb with noeviction (a full memory refuses writes instead of evicting tasks), the append-only file and a 640 MiB memory limit (the render check requires at least twice maxmemory). The upgrade restarts Valkey once; tasks waiting at that moment are lost, as with every restart so far — let the queue drain first. Without a default StorageClass Valkey stays Pending; then name one in valkey.dataStorage.className. For local development skaffold.yaml fixes the same key.
  • New (Helm): TimescaleDB as a chart controller, off by default (issue #2127, NFR-002 v2.8). controllers.timescaledb.enabled: true switches on the database, its Service, its NetworkPolicy and TIMESCALEDB_ENABLED on backend, worker and beat together. The password comes from the key TIMESCALEDB_PASSWORD in kamerplanter-secrets, never from the values; the database runs without root privileges on a read-only file system. The commented-out example block with POSTGRES_PASSWORD: changeme is gone. The frontend and Valkey init containers now carry resource limits too.
  • New (Helm): external ArangoDB and external Valkey (issue #2127, NFR-002 v2.9). database.arangodb.external.enabled: true switches the bundled ArangoDB off and connects backend, worker, beat and backup to host, port and scheme (http or https); valkey.enabled: false with database.valkey.external.url does the same for Valkey. For both the chart renders an egress NetworkPolicy to the given cidr. The backend reads the scheme from the new variable ARANGODB_SCHEME (default http, hard-coded until now) and refuses to start with any other value or with a URL in ARANGODB_HOST. Without the switches the chart renders as before, adding only ARANGODB_SCHEME=http — the upgrade therefore restarts backend, worker and beat once
  • New (Helm, operations): worker autoscaling by queue length as opt-in (issue #2128, #2127, NFR-002 v2.10). The backend's metrics port now serves celery_queue_length (label queue: critical, celery, bulk) and celery_broker_up — read at scrape time, at most every 5 seconds; when Valkey is unreachable the queue values are absent and the scrape does not fail. celeryWorkers.queueAutoscaling.enabled: true gives each worker a HorizontalPodAutoscaler on that series as an external metric, selected by the queues the worker reads. Prerequisites: monitoring.enabled: true and a Prometheus Adapter in the cluster that you install yourself — the rule is in the Helm docs. Inconsistent settings (no monitoring, no metric name or target, minReplicas not below maxReplicas, a second hand-written autoscaler on the same worker) are refused at render time; minReplicas/maxReplicas and external metrics are now checked on every hand-written autoscaler too. Default: off, existing installations are unchanged.

In Development

  • REQ-025 (Privacy/GDPR): GDPR Art. 15–21 data subject rights — specified, not implemented
  • REQ-027 (Light mode): Anonymous access for local instances — specified, not implemented
  • OAuth/OIDC: Full implementation (engine currently stubbed)
  • TimescaleDB integration: Repository and migrations in place, feature-flag controlled