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 mostTENANT_EXPORTS_PER_DAY(default3) 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 with422without 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;verifiedtells 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_MBandRATE_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 ofTENANT_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 reportscleanup_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 stayskamerplanter-tenant-export/1, the import skips the file and reports its row count. New isTENANT_EXPORT_SIGNING_SECRET: a secret of its own for the archive signature (at least 32 characters; empty = derived fromJWT_SECRET_KEYas before; optional in the Helm chart viatenantExport.signingSecret). The import now has its own upload limitTENANT_IMPORT_MAX_UPLOAD_MBinstead of the general 26 MiB — this route only, at most up toUPLOAD_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/tmpfor 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 planstandard, 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/plansand assign them withPATCH /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 throughGET /api/v1/tenants/{slug}/entitlements. The refusal codes stay the same. New isTENANT_MAX_ORGANIZATIONS_PER_USER(default10,0= only the operator creates organizations): the hard-coded limit of ten organizations per account is now configurable, the refusal now carries the codeORGANIZATION_LIMIT_REACHEDinstead ofVALIDATION_ERROR(status still422), and an organization being erased (deleted) no longer counts. Note: in a plan,0means "unlimited" for storage and photos and "budget off" for the AI budgets; the plan response names such quotas in the fieldunlimited. Every quota has an upper bound, a corrupt plan falls back to the server settings, and theplatformtenant always follows the server settings. Kamerplanter creates the planstandardat startup, and migrationv0091records 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 theplatformtenant). Every member sees their garden's plan and usage in the new Quotas tab of the garden settings. The refusalsORGANIZATION_LIMIT_REACHED,MEMBER_LIMIT_REACHED,SERVICE_ACCOUNT_LIMIT_REACHED,STORAGE_QUOTA_EXCEEDEDandPHOTO_QUOTA_EXCEEDEDnow 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 requiresai_tenant_data_access. Revocable at any time, effective from the next question. It is listed underGET /api/v1/privacy/consentsand granted viaPOST /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 underGET /api/v1/privacy/consentsand in the Consents tab; it is granted viaPOST /api/v1/privacy/consents(there is no dedicated switch in the interface yet). - API:
CONSENT_REQUIREDnames the missing purpose machine-readably indetails[0].purpose(additive,fieldstaysconsent) — 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/askadditionally carriesprovider_typeanduses_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/linkacceptsmax_uses, the invitation list carriesmax_usesanduse_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 theplatformtenant 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 notplatform; otherwise 422), the provider response shows it, andnullor""removes it. If the join fails unexpectedly, the sign-in is not affected. - Documentation (issue #2179): The user guide described a platform role
viewerwith 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 withKAMERPLANTER_MODE=fullfor 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. Akp_key minted without sign-in in light mode then gets401(AK-35); Home Assistant and MCP clients need a new key. The light-mode garden stays untouched. New:GET /api/v1/system/takeover-statusandPOST /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 actiontenant_taken_over). After the security review: taking over and declining require the account's step-up (new step-up actlight_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 actionstakeover_accepted,takeover_declined,takeover_reverted,system_account_sealed; a light start with other accounts writes acriticalwarning. 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, audittakeover_undo_failed) and takes it back before anybody decides again. Refused attempts are in the security audit astakeover_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 = trueinsrc/backend/pyproject.tomlran in no check until now — measured 3452 findings insrc/backend/app, among them since July the[call-arg]error behind #2150.task typecheck:backend(step "mypy ratchet" in the Backend CI joblint-test, locally also a pre-commit hook) compares the findings per file and error code withsrc/backend/mypy-ratchet-baseline.json: a new finding is red, a new file has to be clean. Forcall-arg,name-defined,attr-definedandcall-overloadthe count has to match exactly — when you fix such a finding, lower its entry in the same change withtask typecheck:backend -- --tighten. The run is advisory as long aslint-testis not a required check. -
Metrics (Prometheus): The backend serves
http_request_duration_seconds,http_requests_total,http_requests_in_progressand process metrics on a port of its own (METRICS_PORT, default0= off; issue #2129, MT-033, NFR-007 v1.1). In the Helm chart,monitoring.enabled: trueswitches on port 9464, aServiceMonitor(Prometheus Operator) and the NetworkPolicybackend-metrics, which admits only the configured Prometheus pods to that port./metricsis 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 dependencyprometheus-client(Apache-2.0, bundlesdecoratorunder 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 storeda_plus(the label still shows "A+"), frost sensitivitytenderinstead ofsensitive, nutrient phaseharvestinstead ofripening. 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
standardwith 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_DSNwas 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 fromdetails[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 toSTORAGE_MAX_FILE_SIZE_MB+ 1 MiB (the same limit as the frontend nginx). With a largerContent-Lengthit answers413(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 withinUPLOAD_SPOOL_WAIT_SECONDS(30) receives503withRetry-After. Downloads via token link (/api/v1/attachments/token/…) carryCache-Control: no-storeinstead ofprivate, 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 carryprivate, no-cachewith anETag: the browser asks before every display and gets304when 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 carryprivate, no-cacheinstead ofpublic, max-age=86400, and theETagof all image downloads no longer reveals the hash of the uploaded original file. An oversized photo in pest detection answers413instead of422, so the app shows its "image too large" message again. AContent-Lengthsuch as²used to cause a500. - 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.1or in-cluster services — and each tenant's technical admin sets its own InvenTree URL,httpincluded. The connection now judges the addresses it opens to by the same rule and connects only to a judged address; theHostheader, 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 ashttp://[::1is now refused by the SSRF checks with422instead of a server error. Legitimate connections are unaffected; theHTTP(S)_PROXYenvironment 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 needHA_ALLOW_PRIVATE_ENDPOINTorINVENTREE_ALLOW_PRIVATE_ENDPOINT; an IPv4 address embedded in IPv6 (6to4, NAT64, IPv4-mapped) is judged as that IPv4 address, and the metadata addresses100.100.100.200andfd00:ec2::/32are 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 onlydelivery failedinstead 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 wasactiveafterwards, 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 (toactive); the management gets403(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 answers409and 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 (403on 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 tonull; an explicitnullnow removes the field — only fordeletion_scheduled_at,status_before_deletionanddescription; 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. Everybeat_scheduleentry is classified inTENANT_WRITING_BEATSorTENANT_AGNOSTIC_BEATS(app/tasks/tenant_gate.py, with the reason); the guardtests/unit/guards/test_beats_skip_inactive_tenants.pyfails 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'sfail_safe_statetakes 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}/membersansweredadmin_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=truealso lists the tenants inpending_deletionandorphaned; every entry now carriesstatusanddeletion_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) answered500whenkinddid not match the source (saydwdsent ashome_assistant). It now answersreachable: falsewith 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 answered404— 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 returnstext/calendarwithCache-Control: no-storeand 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 same404; a feed past its expiry date answers410. Each client IP may fetch 30 times a minute (RATE_LIMIT_CALENDAR_FEED), beyond that429. 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.icsfetch 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 same404; 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 withGET …/calendar/feeds?scope=all(other roles get403). 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 answers404like 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 asATTACH) into every subscriber's calendar. The feeds' site filterfilters.site_keyis 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 fromAPP_BASE_URLinstead of the request'sHostheader, withhttps://except forlocalhost. IfAPP_BASE_URLlacks thehttp:///https://scheme or the host, the backend still starts but logs the warningapp_base_url_not_absolute_http(without the value); the subscription URL then addshttps://itself (http://forlocalhost,127.0.0.1and::1). Operator note: setAPP_BASE_URLin production to the full publichttps://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, default60/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 andical_urlappear only in the response ofPOST …/calendar/feedsandPOST …/feeds/{key}/regenerate-token, i.e. exactly once; every other response carries neithertokennorical_url. You replace a lost URL by regenerating the token. Migrationv0090hashes 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 endpointGET /api/v1/calendar/feeds/{key}/feed.icsthe subscription URL points at had not been mounted since March 2026 (404); issue #2226 mounts it again (separate entry). No image rollback belowv0090: an older image re-creates the old unique index ontokenand fails to start as soon as two feeds no longer have a clear token. API clients that readtokenorical_urlfrom a read response must switch to the create or regenerate response.PUT …/feeds/{key}andPOST …/feeds/{key}/regenerate-tokenare now reserved for the member who owns the feed and for the lead; every other member gets404(until now any member from grower up could rename another member's feed or regenerate its URL). APUTno 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 afterv0090ran keeps its clear token — the migration runs only once. Scale the old deployment down before the update, or check afterwards withFOR f IN calendar_feeds FILTER HAS(f, "token") COLLECT WITH COUNT INTO n RETURN nand 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}answers404. The inference service refuses a user contribution (source=user_contributed) withoutis_active=falseor withoutcontributed_by/tenant_keywith422. - 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-sourceandPOST …/weather-sources/testanswer403). 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 depth0and 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/emailand…/invitations/linkadditionally returnaccept_url, the email invitation alsodelivered. Withdelivered: false(no delivering email adapter —EMAIL_ADAPTER=consolewithoutDEBUGincluded —, 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/emailallowsRATE_LIMIT_INVITATION_EMAILper account (default10/minute) and at mostTENANT_INVITATION_EMAILS_PER_DAYemail invitations in 24 hours (default50, across all tenants); beyond that429 RATE_LIMIT_EXCEEDED, nothing is created or sent. An invitation that can no longer be accepted (#2180) no longer opens the registration underinvite_onlyor 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
platformtenant whose sender is not (or no longer) lead there answers403and 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. Migrationv0089revokes every open lead invitation intoplatformon 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/askis removed (no alias,404). The question to the knowledge base is nowPOST /api/v1/t/{slug}/ai/knowledge/ask— same response body; the request now takestop_k≤ 10 (was 20) and a bounded plant context (species,phase,substrateat most 100 characters each,ec0–20,ph0–14, otherwise422). 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 (viewer403), the operator switchAI_FEATURES_ENABLED(404), the garden's AI setting (403 ai.disabled_for_tenant), its own consentai_knowledge_question(403 CONSENT_REQUIRED; with plant values incontextalsoai_tenant_data_access; when the Knowledge Service answers with a cloud model of the platform, alsoai_cloud_processingand the garden's cloud approval, otherwise403 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 budgetRATE_LIMIT_INFERENCEstays. When the Knowledge Service cannot be reached it answers502. Light mode has no consent; there the knowledge question isPOST /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/askandGET /api/v1/public/ai/healthwere 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 isPOST /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 limitAI_PUBLIC_RATE_LIMIT_PER_MINand the operator switchAI_FEATURES_ENABLEDstill 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 answers403(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 answers404like an unknown one. Behaviour change: in Light mode the contribution is no longer possible and answers409(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_consentorlight_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}/generatenow 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 answers404instead 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_PROVIDERvalue used to fall back silently to the Anthropic adapter — a typo such asopenai-compatiblesent every question, together with theLLM_API_KEYmeant 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 carriesCache-Control: no-storeunless 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 answers404instead of422; 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_keysent again with different arguments answersconflict.idempotency_key_reused(409on 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_stampslists, read-only, rows whose author is not a member of their garden — traces of the early tenant backfill (v0004). Migrationv0088removes 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, asviewerorgrower, 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-upservice_account_change) and is recorded in the security audit log. At mostTENANT_MAX_SERVICE_ACCOUNTS(default20) 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/8or/32), a rate limit (rate_limit_per_minute, 1–10000) and an expiry (expires_at, at most 730 days) when it is issued — forPOST /api/v1/auth/api-keysas for service accounts. The three values were already enforced, but nothing could set them. An unusable value is a422before 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
v0087removes the never-populatedtenant_keyfield 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(default50per account and garden),AI_BUDGET_TENANT_CALLS_PER_DAY(500per garden) andAI_BUDGET_TENANT_TOKENS_PER_DAY(2000000tokens per garden), each per UTC day. Above that the API answers429withAI_BUDGET_EXCEEDED(details[0].codenames the budget) andRetry-Afteruntil the day ends. When Valkey cannot be reached, no AI call runs (503 AI_BUDGET_UNAVAILABLE). These routes also carry the per-account minute budgetRATE_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
429from a limit in the domain code (daily caps of plant identification and reference contribution, API-key limit, AI budget) now carries theRetry-Afterheader (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(default0) andlimit(default50, at most200; 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}andGET /t/{slug}/post-harvest/{key}/observations. The response stays an array; a client withoutlimitnow receives the first 50 items instead of all and has to read further pages withoffset. 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-eventsand/feeding-eventsalso acceptafter=<key>and page by key instead of byoffset(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-rangecomputes at most 366 days per request; a longer range is refused with422(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(default0) andlimit(default50, at most200; 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/referencesandGET /t/{slug}/propagation/stats/by-cultivar. The response stays an array; a client withoutlimitreceives 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)onlocation_assignmentsandworkflow_template_keyonworkflow_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/batchesandGET /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/workflowsandGET /t/{slug}/harvest/indicators. The same holds for the MCP toolget_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
TypeErroron 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 throughget_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 reusedidempotency_keyreturned 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 becomesorphanedand 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 neworganizationsfield. 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}andDELETE /admin/platform/tenants/{key}still answer202, but withstatus: "scheduled"andscheduled_for: the tenant is closed for everybody until then, its members receive an email with the date, and only afterRETENTION_TENANT_ERASURE_GRACE_DAYSdays (default 90,0= at once as before) does the daily run delete it. Until thenPOST /tenants/{slug}/erasure/cancel(Lead and Management, password) orPOST /admin/platform/tenants/{key}/erasure/cancel(platform admin) cancels the deletion. Tenants carry astatus(active,suspended,pending_deletion,orphaned,deleted) instead of theis_activefield for this; migrationv0085converts existing tenants, andis_activestays 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 (sameUser-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 (claimgen), 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 with401from 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. Migrationv0086hashes 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 defaultnotify.notifyincluded. Breaking:GET /t/{slug}/ha/weather-entities,/ha/sensor-entitiesand/tanks/ha-entitiesnow require thetechnicalscope (before: every member, viewers included) and, likeactuators/integrations/home-assistant/entities, return released entities only. Migrationv0084creates 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_onlyorclosed, plus an optionalREGISTRATION_ALLOWED_DOMAINS(comma-separated email domains). It applies toPOST /api/v1/auth/registerand to the first sign-in through an OIDC provider. A pending email invitation for exactly that address is the exception toinvite_onlyand to the domain list — locally with its token (invitation_tokenin the body), through OIDC with the address the provider verified;closedadmits nobody, not even with an invitation. A refusal is403(REGISTRATION_NOT_ALLOWED), the same for taken and free addresses.GET /api/v1/modereportsregistration: {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 answer422(MEMBER_LIMIT_REACHED); the invitation stays pending. The effective limit ismin(max_members, TENANT_MAX_MEMBERS_CEILING)— the new platform ceiling, default50. Amax_membersabove the ceiling is refused with422on creation and update; an organization created without one takes the ceiling. A personal tenant has the limit1: whoever wants to invite someone into their personal garden raises the limit first (PATCH /api/v1/tenants/{slug}withmax_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_checkandexternal_enrichmentare 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/consentsand the privacy policy no longer list them;POST /api/v1/privacy/consentsandDELETE /api/v1/privacy/consents/{purpose}answer422for them. Stored consents stay as they are (export, erasure, R-04 unchanged; no migration). Theerror_trackingpurpose 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_…andtenant=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 modulekp_errortracking). -
Operations (logs, Celery, MCP): A request's log lines now carry, besides
request_id, the pseudonymsactor(account,sub_…) andtenant(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 takesrequest_id,actorandtenantalong in message headers (kp_request_id,kp_actor,kp_tenant— the pseudonyms only, never keys) and binds them, together withtask_id, to each of its log lines; they are unbound after the task. The MCP audit row (mcp_audit_log) additionally carriesapi_key_ref(a salted reference to the key used),client_ip_ref(truncated like R-03),request_idandentity_keys(the record keys from the tool input's*_keyfields); all four are in the Art. 15 export (REQ-033 v1.9). Atenant=a line sets explicitly takes precedence. Dashboards that want to count requests per tenant can now filter ontenant=. - 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 insystem_settingsandnotification_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 aFERNET_KEY(startable only withDEBUG=true) they stay plaintext and the log reportsencryption_disabled. Changed (API):GET/PUT /api/v1/t/{slug}/notifications/preferencesreturn 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 with422(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(default30/minute), inference routes (CV diagnosis, pest detection, plant identification, reference contribution)RATE_LIMIT_INFERENCE(20/minute) and the PDF prints under/printRATE_LIMIT_EXPORT(20/minute) — each route counts on its own. Above that the API answers429with aRetry-Afterheader, which every429response now carries. TheRATE_LIMIT_GENERALsetting 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. WhileSTORAGE_STRIP_EXIFis on, the upload refuses every image type the strip cannot clean — also when aSTORAGE_ALLOWED_MIME_TYPES_<CATEGORY>override admits it again. HEIC photos stored before stay unchanged and an Art. 17 erasure still records them asskipped. - 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 ofPAYLOAD_TOO_LARGE. Palette images (PNG inPmode) 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}answers404instead of202for ever; the MCP diary-photo tool then reports the photo asunavailable. - Security: The security audit
security_audit_lognow 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 notenant_key, a tenant row notarget_user_key— so both fields are now nullable inGET /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, migrationv0082): 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 tasksecurity_audit.purge_expired); an account erasure turns both account keys into tombstone hashes. Platform admins read the rows throughGET /api/v1/admin/platform/security-audit. The service callchange_member_scopestakes the acting account (actor_user_key) for it. - Security (API):
POST /api/v1/admin/platform/tenants/{key}/membersandPOST /api/v1/admin/platform/users/{key}/membershipsnow require the administrator's own step-up (issue #2106, MT-008, REQ-024 AK-59): the body carriescurrent_password(orstep_up_token/step_up_codefor the actadmin_membership_add, bound to<tenant_key>|<user_key>); without it401, from an API key403, after failed attempts429. A platform admin cannot add themselves to theplatformtenant (422), and theleadrole in theplatformtenant is handed out only by someone who holds it. Before, the admin session alone added any account — theplatformtenant withleadincluded — 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/acceptanswers403when 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 theleadrole, 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_carekept 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}withis_active=falseonly removed it from the tenant list; anyone who knew the slug kept reading and writing through/api/v1/t/{slug}/…and theX-Active-Tenantheader (measured on ArangoDB 3.12:200). Both now answer with the same403as 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_tenantsomits it, a tool call answersnot_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_targetswithdry_run=trueread the phase entries of another tenant's nutrient plan (previewbefore, EC values, phase names) becauseget_phase_entrieslooked the plan up without a tenant (issue #2104). The plan is now resolved under the caller's tenant first; a foreign or global plan answersnot_found.get_phase_entriestakestenant_keyfor 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 throughassign_species_phase_sequence. The call now ends withpermission.denied(audit rowDENIED),tools/listshows 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 withnot_found. Only affects operators who called the tool with a non-admin key. - Security (API): The reverse lookups
GET /api/v1/phase-sequences/{key}/speciesandGET /api/v1/phase-definitions/{key}/speciesand the MCP toollist_species_by_phase_sequenceused to name the private species of every tenant (issue #2102). They now return only global, own and granted species; the tool takes the optional argumenttenantfor 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;DELETEon the shared template and writing its phases and task templates through/tasks/phases/*and/tasks/templates/*answer403. The same goes for task templates without an owner that migrationv0035could 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 withtenant_key == ""answer403for tenant roles, and404for 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 withREQUIRE_EMAIL_VERIFICATION=falsereceives no notification mail until it follows a verification link — not even after the setting is switched totrue;POST /auth/resend-verificationissues the link to such accounts too. Existing verified accounts keep their mail (migration v0080 stamps them; the dry run counts them). At startupemail_channel_without_verificationwarns 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 answers202 Acceptedwith a body{erasure_key, status, requested_at, message}(formerly204without 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. The500 ERASURE_INCOMPLETEand502answers of this endpoint are gone — a failed or incomplete run is the request statuspartially_completed(retried by the daily run), readable at the newGET /api/v1/admin/platform/erasures/{erasure_key}. Still decided synchronously:401/403/404/422/429(step-up, permission),409(a run holds the request) and503(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 changesemail_verifiedoris_active— lowering them, too (issue #1992; formerly only raising them). Previously onePATCH 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 loweredemail_verifiedis 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}andDELETE /api/v1/admin/platform/tenants/{key}now answer202 Acceptedwith a body{tenant_key, status, requested_at, message}(formerly200with{"message": "Tenant deleted"}or204without 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. The500 TENANT_ERASURE_INCOMPLETEand502error 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 eventtenant_erasure.escalatedappears. 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
speciesandcultivarsummaries (denormalization), so the frontend can build readable names without extra requests - Harvest:
batch_idis nullable in API responses (string | null) instead of an empty string; the uniqueness index onharvest_batches.batch_idisunique + sparse. Existing data is migrated byv0030 - 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, migrationv0079). 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 with409— the refusal revealed that another tenant holds it. Within a tenant or location a duplicate identifier is still a409 - 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 migrationv0067removed its edge; starting such a run now answers404for the species (edit the entry to a species you can read), its phase timeline and irrigation demand skip it. Migrationv0081also clearsequipment.location_keyand the foreign keys ofwatering_logs.slot_keysthat 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.yamlandadventskalender.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 onharvest_batches.batch_id, shipped since v0.0.24),v0041(global index onspecies.scientific_name_normalized, since v0.2.1),v0062(unique index onattachments.storage_key),v0064(auth_providerswithout the configuration),v0069(fertilizersacross 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 eventretired_index_reappeared); if its replacement is missing it stays,retired_index_refused_without_replacementis logged aterrorlevel and/api/v1/health/readyreportsretired_indexes_refusedabove 0 (the status staysready). The unique index onattachments.storage_keyis 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_scopeholds 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_omittedandomission_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 setclient_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 thenginx-unprivilegedimage: 2 MiB and 25 MiB →413). Both configurations now accept bodies up tostorage.maxFileSizeMb+ 1 MiB (default26m); anything larger — a JSON body too — is refused by nginx with413. The chart sets the same limit on the ingressmainasnginx.ingress.kubernetes.io/proxy-body-size(ingress-nginx would otherwise cut at 1 MiB too). Upgrade note:ingress.mainnow has defaults in the chart; the Ingress is still only created when you givehosts, andenabled: falseswitches it off. The earlier docs recommendationproxy-body-size: "10m"was too small for 25 MiB attachments - Fixed (API): A file of exactly the allowed size was refused with
413before it was read (issue #2144). TheContent-Lengthpre-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 gives413. - Docs / dev chart: The ingress examples (Helm, ArgoCD, Kubernetes) and
values-dev.yamlrouted/apistraight to the backend Service. The backend NetworkPolicy admits only the frontend (issue #1159), and only through the frontend nginx do the upload limit andTRUSTED_PROXY_HOPS: "1"apply. All examples now send/including/apito 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
ReadWriteOnceattachments 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 viapodAffinitywhile the attachments live on aReadWriteOncevolume; the rolling update keeps its zero downtime. Withstorage.backend: s3orReadWriteManythere 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) andbulk(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 Valkeyvisibility_timeoutof 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 workerargs, 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 tocelery, which the new worker reads. Still open from MT-032: the N+1 access incheck_auto_transitions(not scheduled at present). - Added (operations): One worker per queue (issue #2128, MT-032): with
celeryWorkers.dedicatedQueues: truethe chart runscelery-workerforcriticalonly plus two copies of it —celery-worker-defaultforcelery,celery-worker-bulkforbulk— derived at render time from the merged worker values — overlays oncontrollers.celery-workerandnetworkpolicies.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 defaultReadWriteOncevolume 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_alertsandevaluate_quarter_climatefirst 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 unscheduledcheck_auto_transitions,check_dormancy_triggersandupdate_vernalization_progress; the last two no longer process plants of inactive tenants either. Without a tenant (emptytenant_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 logsactuator_control_tenantless_deviceswith 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. Thetanks_of_closed_tenantslog counters are gone. - Upgrade note (Helm): The
storageblock of the chart values now takes effect (issue #2124, NFR-013 v1.9). Until now the chart did not read it —storage.backend: s3still shippedlocal-fswith a PVC. The chart now derives theSTORAGE_*variables of the backend and the Celery worker, thebackend-attachmentsPVC (only withlocal-fs) and its mounts from it; the S3 credentials come as exactly two keys of the Secret named instorage.s3.credentialsRef(defaultstorage-s3-credentialswithSTORAGE_S3_ACCESS_KEY_ID/STORAGE_S3_SECRET_ACCESS_KEY). Your ownenventries still override the derived values. Newly refused: more than onebackendorcelery-workerreplica on the defaultReadWriteOncevolume abortshelm 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: ReadWriteManywith an RWX storage class, or on a cluster with exactly one nodestorage.localFs.singleNode: true. The attachment PVC also carriesargocd.argoproj.io/sync-options: Prune=false,Delete=falseso ArgoCD does not delete it after the switch to S3 — migrate the files first withpython -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: trueandbackup.s3.bucketthe chart creates the CronJobkamerplanter-arangodb-backup: anarangodumpof the application database, uploaded with rclone tos3://<bucket>/<prefix>/<timestamp>/,<prefix>/LATESTas 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_PASSWORDinstead of the wholekamerplanter-secrets(issue #2126, NFR-002 v2.4) — until nowJWT_SECRET_KEY,FERNET_KEY,INTERNAL_SERVICE_TOKENand every mail/API key sat in the database pod. The backend, Celery worker, Celery beat and the backup no longer connect asrootbut with the accountkamerplanter(database.arangodb.appUsername), which a newapp-usercontainer in the ArangoDB pod creates on every start withARANGODB_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 with401 No read accessbecause it listed the databases through_systemfirst). If your values setARANGODB_USERNAME: rootor anenvFromon the ArangoDB container (earlier docs examples), root access stays until you remove both;database.arangodb.appUsername: rootrestores the old behaviour explicitly. Recommended follow-ups: a separateARANGODB_PASSWORDand 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
ExternalSecretas opt-in (issue #2127, NFR-002 v2.6).controllers.<name>.horizontalPodAutoscalerandcontrollers.<name>.podDisruptionBudgetadd an HPA and a PDB per controller;externalSecrets.app.enabled: truelets the External Secrets Operator writekamerplanter-secretsfrom your secret store. Without these settings the chart renders exactly as before. Newly refused: an autoscaler for backend or worker on theReadWriteOnceattachment volume (like severalreplicasalready), more than one pod or an autoscaler forcelery-beat,arangodbandvectordb, and a PDB that lets no pod be evicted (every node drain would hang) — also withmaxUnavailable: 0or"0%"and with a percentageminAvailablethat Kubernetes rounds up to every pod. The Helm docs also recommend a memory bound withnoevictionand 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
failureThresholdtoo (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,/datasat on an ephemeralemptyDir, and every restart lost the waiting tasks. Valkey now gets its own volume (kamerplanter-valkey, 1 GiB, surviveshelm uninstall), theRecreatestrategy,maxmemory 256mbwithnoeviction(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 twicemaxmemory). 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 staysPending; then name one invalkey.dataStorage.className. For local developmentskaffold.yamlfixes the same key. - New (Helm): TimescaleDB as a chart controller, off by default (issue #2127, NFR-002 v2.8).
controllers.timescaledb.enabled: trueswitches on the database, its Service, its NetworkPolicy andTIMESCALEDB_ENABLEDon backend, worker and beat together. The password comes from the keyTIMESCALEDB_PASSWORDinkamerplanter-secrets, never from the values; the database runs without root privileges on a read-only file system. The commented-out example block withPOSTGRES_PASSWORD: changemeis 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: trueswitches the bundled ArangoDB off and connects backend, worker, beat and backup tohost,portandscheme(httporhttps);valkey.enabled: falsewithdatabase.valkey.external.urldoes the same for Valkey. For both the chart renders an egress NetworkPolicy to the givencidr. The backend reads the scheme from the new variableARANGODB_SCHEME(defaulthttp, hard-coded until now) and refuses to start with any other value or with a URL inARANGODB_HOST. Without the switches the chart renders as before, adding onlyARANGODB_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(labelqueue:critical,celery,bulk) andcelery_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: truegives each worker aHorizontalPodAutoscaleron that series as an external metric, selected by the queues the worker reads. Prerequisites:monitoring.enabled: trueand 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,minReplicasnot belowmaxReplicas, a second hand-written autoscaler on the same worker) are refused at render time;minReplicas/maxReplicasand 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