Kamerplanter uses ArangoDB as its primary database — a multi-model system combining document and graph database capabilities. All entities are organized as document collections; relationships are modeled as edge collections in the named graph kamerplanter_graph.
The propagation_configs object-array field is the canonical model for a species' propagation. Each PropagationConfig entry describes one propagation method together with its own timing, wood stage, difficulty and notes — replacing the former flat propagation_methods/propagation_months/propagation_notes fields. Subfields:
months (list[int], range 1–12) — months in which this method is most suitable, deduplicated and sorted ascending on the server side, independent of the other configs. The species-level sowing windows for generative propagation remain in the separate direct_sow_months, indoor_start_months and transplant_months fields.
wood_stage (WoodStage \| null) — softwood / semi_hardwood / hardwood / herbaceous; only meaningful for cutting-type methods.
notes (string \| null, max. 1,000 characters) — free-text expert knowledge for this method: typical failure points, species-specific substrate or temperature requirements, acclimatisation steps.
The array is optional in SpeciesCreate (default: empty list) and always present in SpeciesResponse (empty list when not populated). It is maintained for most species in the seed data.
Note (deprecated flat fields): The flat fields propagation_methods/propagation_months/propagation_notes are deprecated in favour of propagation_configs. Legacy flat values are still accepted and adapted on import — one config per method, all sharing the flat propagation_months, with propagation_notes attached to the first method only. New data uses propagation_configs.
The toxicity object field captures a species' toxicity separately for cats, dogs and children (is_toxic_cats/is_toxic_dogs/is_toxic_children), the affected plant parts (toxic_parts), the responsible compounds (toxic_compounds) and the severity (severity: none/mild/moderate/severe). allergen_info adds contact- and pollen-allergen flags. The flat passthrough field toxicity_severity (low/moderate/high) is preserved from historical data and is not mapped onto toxicity.severity. All fields are optional (null when not maintained).
The seed_profile object field bundles genuine seed/germination metadata for seed-propagated species: germination temperature range (germination_temp_min_c/_max_c), sowing depth (sowing_depth_cm), days to germination (days_to_germination), seed viability in years (seed_viability_years), light/dark germination (light_germination: light/dark/indifferent), pretreatment (pretreatment: cold_stratification/warm_stratification/scarification/presoak), thousand-seed weight (thousand_seed_weight_g) and sowing density (sowing_density_per_m2). All fields are optional; purely vegetatively propagated species leave the object empty (null).
Kamerplanter automatically creates the following indexes on startup:
Collection
Index field
Type
Unique
species
scientific_name
Persistent
Yes
botanical_families
name
Persistent
Yes
slots
slot_id
Persistent
Yes
plant_instances
instance_id
Persistent
Yes
users
email
Persistent
Yes
tenants
slug
Persistent
Yes
memberships
user_key, tenant_key
Persistent
Yes
fertilizers
product_name, brand
Persistent
Yes
harvest_batches
batch_id
Persistent (sparse)
Yes
tanks
name
Persistent
Yes
refresh_tokens
token_hash
Persistent
Yes
calendar_feeds
token
Persistent
Yes
tasks
status, plant_key
Persistent
No
feeding_events
plant_key, timestamp
Persistent
No
Sparse indexes
An index marked sparse only considers documents in which the field is actually set. For harvest_batches.batch_id this is required because the batch ID is an optional field: empty input is normalised to null, and any number of batches may exist without a batch ID at the same time, while identifiers that are set must still stay unique. Existing data is migrated by v0030 (empty strings → null, index recreated as unique + sparse).
All tenant-bound resources carry a tenant_key field. On each request the FastAPI dependency get_current_tenant resolves the tenant from the /t/{tenant_slug}/ URL segment and rejects the call if the requesting user holds no active membership there. Writing endpoints additionally require a permission: require_permission(resource, action) for creating/updating/deleting tenant-bound resources, require_tenant_role for a plain minimum role, and require_admin_scope for administrative surfaces (member management, integrations). At the data level the isolation additionally comes from every write path stamping tenant_key out of the resolved tenant context, and every query filtering on that field.
Permission enforcement: require_permission is wired
require_permission(resource, action) decides purely on the tenant role via the pure MembershipEngine predicates: can_edit_resource (lead/grower) for create/update, can_delete_resource (lead only, the REQ-049 §2.3 irreversibility boundary) for delete. Reads stay open to every member. The fine-grained permission matrix (resource type × action × role) in the backend today backs the attachment guard and the interface for external AI clients; its delete grant on domain resources was corrected to lead-only so both enforcement paths agree. A per-resource-type matrix behind require_permission (the resource argument is already carried) is the remaining future refinement.
Global resources (no tenant binding): species, cultivars, botanical_families, pests, diseases, treatments, starter_kits