"""
+ if purpose not in cfg.signup.purposes:
+ raise NotFound(purpose)
+ consent_ledger.record(member.ref, purpose, bool(body["granted"]), source="settings")
+ return {"purpose": purpose, "granted": consent_ledger.current(member.ref, purpose)}
diff --git a/examples/kadamba-fit/api/middleware.py b/examples/kadamba-fit/api/middleware.py
new file mode 100644
index 0000000..39f63b0
--- /dev/null
+++ b/examples/kadamba-fit/api/middleware.py
@@ -0,0 +1,14 @@
+import logging
+
+logger = logging.getLogger("kadamba.api.middleware")
+
+
+class RequestLogMiddleware:
+ def __init__(self, app):
+ self.app = app
+
+ def __call__(self, request):
+ logger.debug("%s %s headers=%s body=%s", request.method, request.path, dict(request.headers), request.body)
+ response = self.app(request)
+ logger.info("%s %s %s", request.method, request.path, response.status)
+ return response
diff --git a/examples/kadamba-fit/api/signup.py b/examples/kadamba-fit/api/signup.py
new file mode 100644
index 0000000..1b90ad4
--- /dev/null
+++ b/examples/kadamba-fit/api/signup.py
@@ -0,0 +1,35 @@
+from datetime import date
+
+from kadamba.config import cfg
+from kadamba.errors import ValidationError
+from kadamba.models import Member
+from kadamba.services import consent_ledger, otp
+
+
+def age_on(dob, today=None):
+ today = today or date.today()
+ return today.year - dob.year - ((today.month, today.day) < (dob.month, dob.day))
+
+
+def signup(form):
+ """POST /signup"""
+ age = age_on(form["dob"])
+ if age < cfg.signup.min_age[form["region"]]:
+ raise ValidationError("Ask a parent to add you to their family plan.")
+ missing = [p for p in cfg.signup.required_purposes if not form.get(f"consent_{p}")]
+ if missing:
+ raise ValidationError("Tick the boxes needed to continue.")
+
+ member = Member.create(
+ region=form["region"],
+ full_name=form["full_name"],
+ email=form["email"],
+ phone=form["phone"],
+ dob=form["dob"],
+ is_minor=age < 18,
+ marketing_opt_in=bool(form.get("consent_marketing")),
+ )
+ for purpose in cfg.signup.purposes:
+ consent_ledger.record(member.ref, purpose, bool(form.get(f"consent_{purpose}")), source="signup")
+ otp.send(member.phone)
+ return member
diff --git a/examples/kadamba-fit/config/app.yaml b/examples/kadamba-fit/config/app.yaml
new file mode 100644
index 0000000..ff48cea
--- /dev/null
+++ b/examples/kadamba-fit/config/app.yaml
@@ -0,0 +1,14 @@
+app: kadamba-fit
+notice_version: "2026-03"
+signup:
+ min_age:
+ in: 18
+ eu: 16
+ purposes:
+ - terms
+ - wellness_partner
+ - marketing
+ - reminders
+ required_purposes:
+ - terms
+ - wellness_partner
diff --git a/examples/kadamba-fit/config/env.example b/examples/kadamba-fit/config/env.example
new file mode 100644
index 0000000..33d5840
--- /dev/null
+++ b/examples/kadamba-fit/config/env.example
@@ -0,0 +1,4 @@
+APP_ENV=development
+DATABASE_URL=postgres://app:changeme@localhost:5432/kadamba
+CRM_API_KEY=changeme
+FIELDCRYPT_KEY=
diff --git a/examples/kadamba-fit/config/production.yaml b/examples/kadamba-fit/config/production.yaml
new file mode 100644
index 0000000..fdee919
--- /dev/null
+++ b/examples/kadamba-fit/config/production.yaml
@@ -0,0 +1,20 @@
+db:
+ url: ${DATABASE_URL}
+crm:
+ api_key: ${CRM_API_KEY}
+analytics:
+ in: https://ingest.ap-south-1.quantlytics.example/v2/events
+ eu: https://ingest.us-east-1.quantlytics.example/v2/events
+email:
+ in: smtp.ap-south-1.mailwren.example
+ eu: smtp.eu-west-1.mailwren.example
+partners:
+ harbourline:
+ in: https://api.in.harbourline.example/wellness
+ eu: https://api.ie.harbourline.example/wellness
+logging:
+ root: INFO
+ loggers:
+ kadamba.services: INFO
+ kadamba.jobs: INFO
+ kadamba.api.middleware: DEBUG
diff --git a/examples/kadamba-fit/config/support.yaml b/examples/kadamba-fit/config/support.yaml
new file mode 100644
index 0000000..542a43f
--- /dev/null
+++ b/examples/kadamba-fit/config/support.yaml
@@ -0,0 +1,7 @@
+sla_hours:
+ first_response: 48
+ rights_request: 600
+ grievance_resolution: 2880
+escalation:
+ after_hours: 240
+ queue: privacy-desk
diff --git a/examples/kadamba-fit/db/migrations/0001_members.sql b/examples/kadamba-fit/db/migrations/0001_members.sql
new file mode 100644
index 0000000..f666f24
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0001_members.sql
@@ -0,0 +1,24 @@
+CREATE TABLE members (
+ id BIGSERIAL PRIMARY KEY,
+ ref UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
+ region TEXT NOT NULL CHECK (region IN ('in', 'eu')),
+ full_name TEXT NOT NULL,
+ email TEXT UNIQUE,
+ phone TEXT,
+ dob DATE NOT NULL,
+ guardian_id BIGINT REFERENCES members(id),
+ is_minor BOOLEAN NOT NULL DEFAULT FALSE,
+ marketing_opt_in BOOLEAN NOT NULL DEFAULT FALSE,
+ nominee JSONB,
+ push_token TEXT,
+ status TEXT NOT NULL DEFAULT 'active',
+ closed_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE auth_sessions (
+ id BIGSERIAL PRIMARY KEY,
+ member_id BIGINT NOT NULL REFERENCES members(id),
+ token_hash BYTEA NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL
+);
diff --git a/examples/kadamba-fit/db/migrations/0002_consent_events.sql b/examples/kadamba-fit/db/migrations/0002_consent_events.sql
new file mode 100644
index 0000000..122a636
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0002_consent_events.sql
@@ -0,0 +1,11 @@
+CREATE TABLE consent_events (
+ id BIGSERIAL PRIMARY KEY,
+ member_ref UUID NOT NULL,
+ purpose TEXT NOT NULL,
+ granted BOOLEAN NOT NULL,
+ notice_version TEXT NOT NULL,
+ source TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX consent_events_member_purpose ON consent_events (member_ref, purpose, created_at DESC);
diff --git a/examples/kadamba-fit/db/migrations/0003_activity.sql b/examples/kadamba-fit/db/migrations/0003_activity.sql
new file mode 100644
index 0000000..a6ea000
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0003_activity.sql
@@ -0,0 +1,22 @@
+CREATE TABLE workouts (
+ id BIGSERIAL PRIMARY KEY,
+ member_id BIGINT NOT NULL REFERENCES members(id),
+ kind TEXT NOT NULL,
+ started_at TIMESTAMPTZ NOT NULL,
+ duration_seconds INTEGER NOT NULL
+);
+
+CREATE TABLE route_tracks (
+ id BIGSERIAL PRIMARY KEY,
+ member_id BIGINT NOT NULL REFERENCES members(id),
+ workout_id BIGINT NOT NULL REFERENCES workouts(id),
+ points JSONB NOT NULL
+);
+
+CREATE TABLE bookings (
+ id BIGSERIAL PRIMARY KEY,
+ member_id BIGINT NOT NULL REFERENCES members(id),
+ coach_id BIGINT NOT NULL,
+ starts_at TIMESTAMPTZ NOT NULL,
+ status TEXT NOT NULL
+);
diff --git a/examples/kadamba-fit/db/migrations/0004_wearable.sql b/examples/kadamba-fit/db/migrations/0004_wearable.sql
new file mode 100644
index 0000000..2adc822
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0004_wearable.sql
@@ -0,0 +1,27 @@
+CREATE TABLE health_samples (
+ member_ref UUID NOT NULL,
+ device_serial TEXT NOT NULL,
+ taken_at TIMESTAMPTZ NOT NULL,
+ heart_rate SMALLINT,
+ spo2 SMALLINT,
+ sleep_minutes SMALLINT,
+ steps INTEGER
+) PARTITION BY RANGE (taken_at);
+
+CREATE TABLE weekly_summaries (
+ member_ref UUID NOT NULL,
+ week_start DATE NOT NULL,
+ step_total INTEGER NOT NULL,
+ resting_hr_avg SMALLINT,
+ sleep_minutes_avg SMALLINT,
+ activity_tier TEXT NOT NULL CHECK (activity_tier IN ('low', 'medium', 'high')),
+ PRIMARY KEY (member_ref, week_start)
+);
+
+CREATE TABLE cycle_logs (
+ member_ref UUID NOT NULL,
+ start_date DATE NOT NULL,
+ symptoms TEXT[],
+ notes TEXT,
+ PRIMARY KEY (member_ref, start_date)
+);
diff --git a/examples/kadamba-fit/db/migrations/0005_billing.sql b/examples/kadamba-fit/db/migrations/0005_billing.sql
new file mode 100644
index 0000000..99575b1
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0005_billing.sql
@@ -0,0 +1,19 @@
+CREATE TABLE payment_tokens (
+ id BIGSERIAL PRIMARY KEY,
+ member_id BIGINT NOT NULL REFERENCES members(id),
+ psp_token TEXT NOT NULL,
+ card_brand TEXT NOT NULL,
+ card_last4 CHAR(4) NOT NULL,
+ expires_on DATE NOT NULL
+);
+
+CREATE TABLE invoices (
+ id BIGSERIAL PRIMARY KEY,
+ member_ref UUID NOT NULL,
+ billing_name TEXT NOT NULL,
+ billing_email TEXT NOT NULL,
+ tax_number TEXT,
+ amount_minor BIGINT NOT NULL,
+ currency CHAR(3) NOT NULL,
+ issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/examples/kadamba-fit/db/migrations/0006_coach_payouts.sql b/examples/kadamba-fit/db/migrations/0006_coach_payouts.sql
new file mode 100644
index 0000000..62d0a34
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0006_coach_payouts.sql
@@ -0,0 +1,9 @@
+CREATE TABLE coach_payouts (
+ id BIGSERIAL PRIMARY KEY,
+ coach_id BIGINT NOT NULL,
+ region TEXT NOT NULL,
+ tax_id_sealed BYTEA NOT NULL,
+ bank_account_sealed BYTEA NOT NULL,
+ key_alias TEXT NOT NULL DEFAULT 'alias/coach-payouts',
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
diff --git a/examples/kadamba-fit/db/migrations/0007_support.sql b/examples/kadamba-fit/db/migrations/0007_support.sql
new file mode 100644
index 0000000..db14b58
--- /dev/null
+++ b/examples/kadamba-fit/db/migrations/0007_support.sql
@@ -0,0 +1,10 @@
+CREATE TABLE tickets (
+ id BIGSERIAL PRIMARY KEY,
+ member_ref UUID NOT NULL,
+ kind TEXT NOT NULL,
+ text TEXT NOT NULL,
+ opened_at TIMESTAMPTZ NOT NULL,
+ due_at TIMESTAMPTZ NOT NULL,
+ queue TEXT NOT NULL,
+ resolved_at TIMESTAMPTZ
+);
diff --git a/examples/kadamba-fit/deploy/backup.yaml b/examples/kadamba-fit/deploy/backup.yaml
new file mode 100644
index 0000000..3d3b8c0
--- /dev/null
+++ b/examples/kadamba-fit/deploy/backup.yaml
@@ -0,0 +1,10 @@
+clusters:
+ in:
+ primary: ap-south-1
+ dr_copy: ap-southeast-1
+ eu:
+ primary: eu-central-1
+ dr_copy: eu-west-1
+snapshot_every: 6h
+keep_snapshots: 35d
+encryption: per-cluster-kms
diff --git a/examples/kadamba-fit/deploy/deployment.yaml b/examples/kadamba-fit/deploy/deployment.yaml
new file mode 100644
index 0000000..738ab0f
--- /dev/null
+++ b/examples/kadamba-fit/deploy/deployment.yaml
@@ -0,0 +1,30 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: kadamba-api
+spec:
+ replicas: 6
+ selector:
+ matchLabels:
+ app: kadamba-api
+ template:
+ metadata:
+ labels:
+ app: kadamba-api
+ spec:
+ containers:
+ - name: api
+ image: registry.kadamba.example/api:2026.10.2
+ env:
+ - name: APP_ENV
+ value: production
+ - name: CONFIG_PATH
+ value: /etc/kadamba/production.yaml
+ - name: CRM_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: kadamba-crm
+ key: api-key
+ envFrom:
+ - secretRef:
+ name: kadamba-api
diff --git a/examples/kadamba-fit/deploy/logging.yaml b/examples/kadamba-fit/deploy/logging.yaml
new file mode 100644
index 0000000..621ae33
--- /dev/null
+++ b/examples/kadamba-fit/deploy/logging.yaml
@@ -0,0 +1,13 @@
+sink: loki
+clusters:
+ in:
+ region: ap-south-1
+ eu:
+ region: eu-central-1
+tenants:
+ - name: api-app
+ retention: 720h
+ - name: api-access
+ retention: 720h
+ - name: auth-events
+ retention: 720h
diff --git a/examples/kadamba-fit/deploy/secrets.yaml b/examples/kadamba-fit/deploy/secrets.yaml
new file mode 100644
index 0000000..df67648
--- /dev/null
+++ b/examples/kadamba-fit/deploy/secrets.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Secret
+metadata:
+ name: kadamba-api
+type: Opaque
+data:
+ DATABASE_URL: cG9zdGdyZXM6Ly9rYWRhbWJhX2FwcDpzeW50aGV0aWMtWng4MXFRQGRiLnByb2Qua2FkYW1iYS5pbnRlcm5hbDo1NDMyL2thZGFtYmE=
diff --git a/examples/kadamba-fit/deploy/staging-access.yaml b/examples/kadamba-fit/deploy/staging-access.yaml
new file mode 100644
index 0000000..30eae48
--- /dev/null
+++ b/examples/kadamba-fit/deploy/staging-access.yaml
@@ -0,0 +1,8 @@
+environment: staging
+databases:
+ read_write:
+ - group:engineering-all
+ - group:qa-contractors
+refresh:
+ schedule: "0 2 * * 0"
+ script: scripts/refresh_staging.sh
diff --git a/examples/kadamba-fit/docs/retention-schedule.md b/examples/kadamba-fit/docs/retention-schedule.md
new file mode 100644
index 0000000..52cc6be
--- /dev/null
+++ b/examples/kadamba-fit/docs/retention-schedule.md
@@ -0,0 +1,13 @@
+# Retention schedule
+
+Owner: DPO. Last reviewed 12 March 2026.
+
+| Data | Keep for | Why |
+|---|---|---|
+| Member profile, auth sessions, workouts, routes, bookings, payment tokens | 30 days after closure | The purpose has ended |
+| Heart rate, SpO2, sleep and steps samples, weekly summaries, cycle logs | 30 days after closure | The purpose has ended |
+| Invoices | 9 years from issue | Companies Act 2013 s.128(5) (eight financial years) and CGST Act 2017 s.36 (72 months) |
+| Consent events | 3 years after closure, keyed by member ref | Proof of consent: DPDP s.6(10), GDPR Art. 7(1) |
+| Support tickets | 3 years after closure | Handling disputes and rights requests |
+| Access and auth logs | 12 months | Security investigations |
+| Database snapshots | 35 days | Disaster recovery |
diff --git a/examples/kadamba-fit/docs/ropa.md b/examples/kadamba-fit/docs/ropa.md
new file mode 100644
index 0000000..5f20bd0
--- /dev/null
+++ b/examples/kadamba-fit/docs/ropa.md
@@ -0,0 +1,18 @@
+# Record of processing activities
+
+Controllers: Kadamba Fit Private Limited (India members), Kadamba Fit Ireland Limited (EU members). Contact: dpo@kadamba.example. Last reviewed 12 March 2026.
+
+| Activity | Data | Purpose | Lawful basis | Recipients |
+|---|---|---|---|---|
+| Account | Name, email, phone, date of birth | Run the account, check age | Contract, Art. 6(1)(b) | Nimbusline, Mailwren |
+| Workouts and routes | Workouts, GPS routes | Show history | Contract, Art. 6(1)(b) | Nimbusline |
+| Wearable sync | Heart rate, SpO2, sleep, steps | Show trends | Contract, Art. 6(1)(b) | Nimbusline |
+| Cycle tracking | Cycle dates, symptoms, notes | Predict next cycle | Contract, Art. 6(1)(b) | Nimbusline |
+| Coach bookings | Booking times, coach | Run video sessions | Contract, Art. 6(1)(b) | Nimbusline |
+| Billing | Billing name and email, tax number, PSP token | Take payment, keep tax records | Contract and legal obligation, Art. 6(1)(b) and (c) | Nimbusline, payment provider |
+| Coach payouts | Tax ID, bank account | Pay coaches | Contract and legal obligation, Art. 6(1)(b) and (c) | Nimbusline, bank |
+| Product analytics | Member ref, email, IP address, events | Improve the app | Legitimate interests, Art. 6(1)(f) | Quantlytics |
+| Marketing | Name, email | Send offers | Consent, Art. 6(1)(a) | Cadencehive |
+| Reminders | Push token | Send workout reminders | Consent, Art. 6(1)(a) | Nimbusline |
+| Wellness discount | Member ref, weekly activity tier | Partner insurer discount | Consent, Art. 6(1)(a) | Harbourline Mutual |
+| Security logging | Access and auth logs | Detect and investigate incidents | Legitimate interests, Art. 6(1)(f) | Nimbusline |
diff --git a/examples/kadamba-fit/docs/runbooks/incident-response.md b/examples/kadamba-fit/docs/runbooks/incident-response.md
new file mode 100644
index 0000000..a20b70a
--- /dev/null
+++ b/examples/kadamba-fit/docs/runbooks/incident-response.md
@@ -0,0 +1,8 @@
+# Incident response
+
+1. The on-call engineer opens an incident channel and names an incident commander.
+2. The commander assigns a severity from [severity-matrix.md](severity-matrix.md) and records the time in the incident document.
+3. Contain: revoke credentials, isolate hosts, preserve logs.
+4. For reportable cyber incidents, Security files with CERT-In within 6 hours of noticing.
+5. Take the actions listed for the assigned severity.
+6. Write a postmortem within 10 working days.
diff --git a/examples/kadamba-fit/docs/runbooks/severity-matrix.md b/examples/kadamba-fit/docs/runbooks/severity-matrix.md
new file mode 100644
index 0000000..f09d904
--- /dev/null
+++ b/examples/kadamba-fit/docs/runbooks/severity-matrix.md
@@ -0,0 +1,7 @@
+# Severity matrix
+
+| Severity | Trigger | Actions |
+|---|---|---|
+| Sev 1 | More than 10,000 member records, or any payment token | Page the CTO. The DPO files the Data Protection Board report and the EU lead authority notification within 72 hours of the Sev 1 declaration. The Head of Communications decides on member messaging after the review. |
+| Sev 2 | 100 to 10,000 member records | The commander leads. The DPO hears at the weekly review. Postmortem. |
+| Sev 3 | Fewer than 100 member records, or staff records only | Ticket in the security queue. Postmortem optional. |
diff --git a/examples/kadamba-fit/docs/vendors/harbourline-partner-agreement.md b/examples/kadamba-fit/docs/vendors/harbourline-partner-agreement.md
new file mode 100644
index 0000000..684a2d7
--- /dev/null
+++ b/examples/kadamba-fit/docs/vendors/harbourline-partner-agreement.md
@@ -0,0 +1,15 @@
+# Wellness partner agreement
+
+Between Kadamba Fit Private Limited and Harbourline Mutual, India branch, and between Kadamba Fit Ireland Limited and Harbourline Mutual, Ireland branch. Signed 1 September 2025. Each party is an independent controller.
+
+Harbourline gives policyholders a premium discount based on their weekly activity. A member links a policy by entering a link code in the app, and Harbourline matches `member_ref` to the policy through that code. Harbourline does not use the data for underwriting.
+
+## Schedule A: data shared each week
+
+| Field | Description |
+|---|---|
+| `member_ref` | Opaque member reference |
+| `week_start` | Monday of the reported week |
+| `activity_tier` | low, medium or high |
+
+Only members who have opted in are included.
diff --git a/examples/kadamba-fit/docs/vendors/mailwren-dpa.md b/examples/kadamba-fit/docs/vendors/mailwren-dpa.md
new file mode 100644
index 0000000..7582723
--- /dev/null
+++ b/examples/kadamba-fit/docs/vendors/mailwren-dpa.md
@@ -0,0 +1,27 @@
+# Data Processing Addendum
+
+Between Kadamba Fit Private Limited and Kadamba Fit Ireland Limited (each a controller for its own members) and Mailwren Ireland Limited, Dublin (processor). Signed by all parties on 11 June 2025.
+
+## Processing
+
+- Subject matter: sending transactional email
+- Data: email address, first name, message content
+- Duration: the term of the service agreement
+- Location: EU members on eu-west-1 (Dublin), India members on ap-south-1 (Mumbai)
+
+## Processor obligations
+
+Mailwren:
+
+1. acts only on the customer's documented instructions
+2. binds its staff to confidentiality
+3. applies the security measures in Annex II, including encryption in transit and at rest
+4. engages sub-processors only with 30 days' prior written notice and on the same terms (list in Annex III)
+5. helps the customer answer data subject and Data Principal requests
+6. helps with security, DPIAs and breach reporting, and tells the customer of a personal data breach within 24 hours
+7. deletes or returns all data within 30 days of the end of the service
+8. provides the information needed to show compliance and allows audits
+
+## Transfers
+
+Mailwren Inc. (USA) can access EU member data only for support escalations. That access runs under the 2021 Standard Contractual Clauses, Module 3, between Mailwren Ireland Limited and Mailwren Inc., with a transfer impact assessment at Annex IV.
diff --git a/examples/kadamba-fit/docs/vendors/nimbusline-cloud-agreement.md b/examples/kadamba-fit/docs/vendors/nimbusline-cloud-agreement.md
new file mode 100644
index 0000000..4890410
--- /dev/null
+++ b/examples/kadamba-fit/docs/vendors/nimbusline-cloud-agreement.md
@@ -0,0 +1,16 @@
+# Cloud services agreement: data processing terms
+
+Nimbusline Cloud India Private Limited serves Kadamba Fit Private Limited. Nimbusline Cloud Europe GmbH serves Kadamba Fit Ireland Limited. Signed 9 January 2025.
+
+- Nimbusline hosts data only in the regions the customer selects and processes it only on the customer's documented instructions.
+- Staff are bound to confidentiality. Sub-processors need 30 days' prior written notice and the same terms.
+- Volumes and snapshots are encrypted at rest with customer-managed keys. Traffic between regions is encrypted.
+- Nimbusline helps with data subject and Data Principal requests, security, DPIAs and breach reporting, and reports a personal data breach within 24 hours.
+- Data is deleted within 30 days of the end of the service. The customer can audit on reasonable notice.
+
+## Regions selected
+
+| Cluster | Primary | Disaster recovery copy |
+|---|---|---|
+| India | ap-south-1 (Mumbai) | ap-southeast-1 (Singapore) |
+| EU | eu-central-1 (Frankfurt) | eu-west-1 (Dublin) |
diff --git a/examples/kadamba-fit/docs/vendors/quantlytics-order-form.md b/examples/kadamba-fit/docs/vendors/quantlytics-order-form.md
new file mode 100644
index 0000000..c9d1ea0
--- /dev/null
+++ b/examples/kadamba-fit/docs/vendors/quantlytics-order-form.md
@@ -0,0 +1,19 @@
+# Order form QF-2291
+
+| | |
+|---|---|
+| Vendor | Quantlytics Inc., 200 Placeholder Street, Wilmington, Delaware, USA |
+| Customer | Kadamba Fit Private Limited, Bengaluru |
+| Product | Quantlytics Growth, 5 million events a month |
+| Term | 1 April 2025 to 31 March 2027, renews annually |
+| Fees | USD 18,000 a year, invoiced annually in advance |
+
+## Terms
+
+This order form is governed by the Quantlytics Online Terms of Service at https://quantlytics.example/terms, as updated from time to time. The laws of Delaware apply.
+
+Quantlytics may use event and profile data sent by Customer to operate and improve its products.
+
+Support is by email on business days, US Eastern time.
+
+Signed for Customer by the Head of Product, 28 March 2025.
diff --git a/examples/kadamba-fit/docs/vendors/register.md b/examples/kadamba-fit/docs/vendors/register.md
new file mode 100644
index 0000000..032a742
--- /dev/null
+++ b/examples/kadamba-fit/docs/vendors/register.md
@@ -0,0 +1,10 @@
+# Vendor register
+
+| Vendor | Role | Where | Contract | Transfer tool for EU data |
+|---|---|---|---|---|
+| Nimbusline Cloud | Hosting | ap-south-1, ap-southeast-1, eu-central-1, eu-west-1 | [Cloud agreement](nimbusline-cloud-agreement.md) | EU cluster stays in the EU |
+| Mailwren | Transactional email | ap-south-1, eu-west-1 | [DPA](mailwren-dpa.md) | See DPA |
+| Quantlytics | Product analytics | See `config/production.yaml` | [Order form](quantlytics-order-form.md) | See contract |
+| Cadencehive | CRM | ap-south-1, eu-west-1 | DPA signed 2 November 2024, held by Legal | EU region only |
+| Harbourline Mutual | Partner insurer, independent controller | India and Ireland | [Partner agreement](harbourline-partner-agreement.md) | Ireland endpoint for EU members |
+| Kadamba Fit Private Limited | Intra-group engineering support for the EU cluster | Bengaluru | Intra-group agreement, 3 February 2025 | 2021 SCCs, Module 2, with a transfer impact assessment dated 3 February 2025 |
diff --git a/examples/kadamba-fit/jobs/campaign_sync.py b/examples/kadamba-fit/jobs/campaign_sync.py
new file mode 100644
index 0000000..7512dec
--- /dev/null
+++ b/examples/kadamba-fit/jobs/campaign_sync.py
@@ -0,0 +1,8 @@
+from kadamba import crm, db
+
+
+def run():
+ """Nightly. Keeps the CRM offers lists in step with the member base."""
+ rows = db.query("SELECT email, full_name, region FROM members WHERE status = 'active' AND marketing_opt_in")
+ for row in rows:
+ crm.upsert_contact(row.email, row.full_name, list_name=f"offers-{row.region}")
diff --git a/examples/kadamba-fit/jobs/partner_export.py b/examples/kadamba-fit/jobs/partner_export.py
new file mode 100644
index 0000000..72e97af
--- /dev/null
+++ b/examples/kadamba-fit/jobs/partner_export.py
@@ -0,0 +1,20 @@
+import requests
+
+from kadamba import db
+from kadamba.config import cfg
+from kadamba.services import consent_ledger
+
+ROWS = """
+SELECT m.ref AS member_ref, m.email, m.dob, m.region, s.week_start, s.step_total,
+ s.resting_hr_avg, s.sleep_minutes_avg, s.activity_tier
+FROM members m
+JOIN weekly_summaries s ON s.member_ref = m.ref
+WHERE s.week_start = %s AND m.status = 'active'
+"""
+
+
+def run(week_start):
+ """Weekly, Monday 02:00 UTC."""
+ for row in db.query(ROWS, week_start):
+ if consent_ledger.current(row.member_ref, "wellness_partner"):
+ requests.post(cfg.partners.harbourline[row.region], json=dict(row), timeout=10)
diff --git a/examples/kadamba-fit/jobs/purge_closed.py b/examples/kadamba-fit/jobs/purge_closed.py
new file mode 100644
index 0000000..553b428
--- /dev/null
+++ b/examples/kadamba-fit/jobs/purge_closed.py
@@ -0,0 +1,30 @@
+from datetime import timedelta
+
+from kadamba import analytics, crm, db
+
+GRACE_DAYS = 30
+ERASE = ["bookings", "route_tracks", "workouts", "auth_sessions", "payment_tokens"]
+
+
+def erase_member(member):
+ for table in ERASE:
+ db.execute(f"DELETE FROM {table} WHERE member_id = %s", member.id)
+ analytics.delete_profile(member.ref)
+ crm.delete_contact(member.email)
+ db.execute("DELETE FROM members WHERE id = %s", member.id)
+
+
+def run(now):
+ """Nightly."""
+ cutoff = now - timedelta(days=GRACE_DAYS)
+ for member in db.query("SELECT * FROM members WHERE status = 'closed' AND closed_at < %s", cutoff):
+ erase_member(member)
+ db.execute("DELETE FROM invoices WHERE issued_at < %s", now - timedelta(days=9 * 365))
+ db.execute(
+ "DELETE FROM consent_events WHERE member_ref NOT IN (SELECT ref FROM members) AND created_at < %s",
+ now - timedelta(days=3 * 365),
+ )
+ db.execute(
+ "DELETE FROM tickets WHERE member_ref NOT IN (SELECT ref FROM members) AND opened_at < %s",
+ now - timedelta(days=3 * 365),
+ )
diff --git a/examples/kadamba-fit/jobs/reminder_push.py b/examples/kadamba-fit/jobs/reminder_push.py
new file mode 100644
index 0000000..3f3d615
--- /dev/null
+++ b/examples/kadamba-fit/jobs/reminder_push.py
@@ -0,0 +1,9 @@
+from kadamba import db, push
+from kadamba.services import consent_ledger
+
+
+def run():
+ """Daily at 07:00 member local time."""
+ for member in db.query("SELECT ref, push_token FROM members WHERE status = 'active' AND push_token IS NOT NULL"):
+ if consent_ledger.current(member.ref, "reminders"):
+ push.send(member.push_token, "Time to move. Your workout is ready.")
diff --git a/examples/kadamba-fit/scripts/mask_staging.sql b/examples/kadamba-fit/scripts/mask_staging.sql
new file mode 100644
index 0000000..c041896
--- /dev/null
+++ b/examples/kadamba-fit/scripts/mask_staging.sql
@@ -0,0 +1,5 @@
+UPDATE members
+SET full_name = 'Member ' || id,
+ email = 'member-' || id || '@example.test',
+ phone = NULL,
+ nominee = NULL;
diff --git a/examples/kadamba-fit/scripts/refresh_staging.sh b/examples/kadamba-fit/scripts/refresh_staging.sh
new file mode 100644
index 0000000..4bfff43
--- /dev/null
+++ b/examples/kadamba-fit/scripts/refresh_staging.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+for region in in eu; do
+ dump="/tmp/kadamba-$region.dump"
+ pg_dump --format=custom \
+ --exclude-table=payment_tokens \
+ --exclude-table=coach_payouts \
+ "$(vault read -field=url "kv/prod/$region/db")" > "$dump"
+ pg_restore --clean --no-owner -d "$(vault read -field=url "kv/staging/$region/db")" "$dump"
+ rm -f "$dump"
+done
diff --git a/examples/kadamba-fit/services/account_lifecycle.py b/examples/kadamba-fit/services/account_lifecycle.py
new file mode 100644
index 0000000..09f8c6e
--- /dev/null
+++ b/examples/kadamba-fit/services/account_lifecycle.py
@@ -0,0 +1,7 @@
+from kadamba import db
+
+
+def close_account(member, now):
+ """POST /me/close. jobs/purge_closed.py removes the data once the grace period ends."""
+ member.update(status="closed", closed_at=now)
+ db.execute("DELETE FROM auth_sessions WHERE member_id = %s", member.id)
diff --git a/examples/kadamba-fit/services/analytics.py b/examples/kadamba-fit/services/analytics.py
new file mode 100644
index 0000000..c5755d2
--- /dev/null
+++ b/examples/kadamba-fit/services/analytics.py
@@ -0,0 +1,17 @@
+import requests
+
+from kadamba.config import cfg
+
+
+def track(member, event, request):
+ requests.post(cfg.analytics[member.region], json={
+ "distinct_id": str(member.ref),
+ "email": member.email,
+ "ip": request.client_ip,
+ "event": event,
+ }, timeout=5)
+
+
+def delete_profile(member_ref):
+ for url in cfg.analytics.values():
+ requests.delete(f"{url}/profiles/{member_ref}", timeout=5)
diff --git a/examples/kadamba-fit/services/coach_payouts.py b/examples/kadamba-fit/services/coach_payouts.py
new file mode 100644
index 0000000..0a80650
--- /dev/null
+++ b/examples/kadamba-fit/services/coach_payouts.py
@@ -0,0 +1,14 @@
+from kadamba import db
+from kadamba.utils.fieldcrypt import seal, unseal
+
+
+def register(coach_id, region, tax_id, bank_account):
+ db.execute(
+ "INSERT INTO coach_payouts (coach_id, region, tax_id_sealed, bank_account_sealed) VALUES (%s, %s, %s, %s)",
+ coach_id, region, seal(tax_id), seal(bank_account),
+ )
+
+
+def payout_details(coach_id):
+ row = db.one("SELECT tax_id_sealed, bank_account_sealed FROM coach_payouts WHERE coach_id = %s", coach_id)
+ return unseal(row.tax_id_sealed), unseal(row.bank_account_sealed)
diff --git a/examples/kadamba-fit/services/consent_ledger.py b/examples/kadamba-fit/services/consent_ledger.py
new file mode 100644
index 0000000..94a5deb
--- /dev/null
+++ b/examples/kadamba-fit/services/consent_ledger.py
@@ -0,0 +1,17 @@
+from kadamba import db
+from kadamba.config import cfg
+
+
+def record(member_ref, purpose, granted, source):
+ db.execute(
+ "INSERT INTO consent_events (member_ref, purpose, granted, notice_version, source) VALUES (%s, %s, %s, %s, %s)",
+ member_ref, purpose, granted, cfg.notice_version, source,
+ )
+
+
+def current(member_ref, purpose):
+ row = db.one(
+ "SELECT granted FROM consent_events WHERE member_ref = %s AND purpose = %s ORDER BY created_at DESC LIMIT 1",
+ member_ref, purpose,
+ )
+ return bool(row and row.granted)
diff --git a/examples/kadamba-fit/services/cycle.py b/examples/kadamba-fit/services/cycle.py
new file mode 100644
index 0000000..eb64735
--- /dev/null
+++ b/examples/kadamba-fit/services/cycle.py
@@ -0,0 +1,20 @@
+from datetime import timedelta
+
+from kadamba import db
+
+
+def log_cycle(member, start_date, symptoms, notes=None):
+ """POST /me/cycles"""
+ db.execute(
+ "INSERT INTO cycle_logs (member_ref, start_date, symptoms, notes) VALUES (%s, %s, %s, %s)",
+ member.ref, start_date, symptoms, notes,
+ )
+ return predict_next(member)
+
+
+def predict_next(member):
+ rows = db.query("SELECT start_date FROM cycle_logs WHERE member_ref = %s ORDER BY start_date DESC LIMIT 6", member.ref)
+ if len(rows) < 2:
+ return None
+ gaps = [(a.start_date - b.start_date).days for a, b in zip(rows, rows[1:])]
+ return rows[0].start_date + timedelta(days=round(sum(gaps) / len(gaps)))
diff --git a/examples/kadamba-fit/services/family.py b/examples/kadamba-fit/services/family.py
new file mode 100644
index 0000000..2cc80b7
--- /dev/null
+++ b/examples/kadamba-fit/services/family.py
@@ -0,0 +1,16 @@
+from kadamba.models import Member
+
+
+def add_dependant(parent, full_name, dob):
+ """POST /me/family"""
+ return Member.create(
+ region=parent.region,
+ full_name=full_name,
+ dob=dob,
+ guardian_id=parent.id,
+ )
+
+
+def list_dependants(parent):
+ """GET /me/family"""
+ return Member.where(guardian_id=parent.id)
diff --git a/examples/kadamba-fit/services/grievance.py b/examples/kadamba-fit/services/grievance.py
new file mode 100644
index 0000000..0451361
--- /dev/null
+++ b/examples/kadamba-fit/services/grievance.py
@@ -0,0 +1,17 @@
+from datetime import timedelta
+
+from kadamba.config import support_cfg
+from kadamba.models import Ticket
+
+
+def open_ticket(member, kind, text, now):
+ """POST /support/tickets, kind is grievance or rights_request"""
+ sla = "grievance_resolution" if kind == "grievance" else "rights_request"
+ return Ticket.create(
+ member_ref=member.ref,
+ kind=kind,
+ text=text,
+ opened_at=now,
+ due_at=now + timedelta(hours=support_cfg.sla_hours[sla]),
+ queue=support_cfg.escalation.queue,
+ )
diff --git a/examples/kadamba-fit/services/offers.py b/examples/kadamba-fit/services/offers.py
new file mode 100644
index 0000000..3b12a3b
--- /dev/null
+++ b/examples/kadamba-fit/services/offers.py
@@ -0,0 +1,16 @@
+from kadamba import offer_feed
+from kadamba.models import Workout
+
+
+def partner_offers(member):
+ """GET /me/offers: sponsored offers from ad partners, ranked on the last 90 days of activity."""
+ if member.is_minor:
+ return []
+ return offer_feed.rank(segment_for(Workout.recent(member.id, days=90)))
+
+
+def segment_for(history):
+ kinds = {workout.kind for workout in history}
+ if "run" in kinds and len(history) > 30:
+ return "endurance-buyer"
+ return "general-fitness-buyer" if history else "lapsed"
diff --git a/examples/kadamba-fit/services/otp.py b/examples/kadamba-fit/services/otp.py
new file mode 100644
index 0000000..4f75c64
--- /dev/null
+++ b/examples/kadamba-fit/services/otp.py
@@ -0,0 +1,21 @@
+import logging
+import secrets
+
+from kadamba import otp_store, sms
+from kadamba.utils.masking import mask_phone
+
+logger = logging.getLogger("kadamba.services.otp")
+
+
+def send(phone):
+ code = f"{secrets.randbelow(1_000_000):06d}"
+ otp_store.put(phone, code, ttl_seconds=300)
+ sms.send(phone, f"Your Kadamba Fit code is {code}")
+ logger.info("otp dispatched to %s", mask_phone(phone))
+
+
+def verify(phone, code):
+ """POST /otp/verify"""
+ ok = otp_store.check(phone, code)
+ logger.info("otp check for %s: %s", mask_phone(phone), ok)
+ return ok
diff --git a/examples/kadamba-fit/services/rights.py b/examples/kadamba-fit/services/rights.py
new file mode 100644
index 0000000..11be067
--- /dev/null
+++ b/examples/kadamba-fit/services/rights.py
@@ -0,0 +1,29 @@
+from kadamba.jobs import purge_closed
+from kadamba.models import Booking, Workout
+
+EXPORT_SECTIONS = {
+ "profile": lambda member: member.public_fields(),
+ "workouts": lambda member: Workout.for_member(member.id),
+ "bookings": lambda member: Booking.for_member(member.id),
+}
+
+
+def build_export(member):
+ """GET /me/export"""
+ return {name: section(member) for name, section in EXPORT_SECTIONS.items()}
+
+
+def correct_profile(member, changes):
+ """PATCH /me"""
+ allowed = {"full_name", "email", "phone", "dob"}
+ member.update(**{key: value for key, value in changes.items() if key in allowed})
+
+
+def erase(member):
+ """DELETE /me"""
+ purge_closed.erase_member(member)
+
+
+def set_nominee(member, name, contact):
+ """PUT /me/nominee"""
+ member.update(nominee={"name": name, "contact": contact})
diff --git a/examples/kadamba-fit/services/wearable_sync.py b/examples/kadamba-fit/services/wearable_sync.py
new file mode 100644
index 0000000..8fcd409
--- /dev/null
+++ b/examples/kadamba-fit/services/wearable_sync.py
@@ -0,0 +1,13 @@
+from kadamba import db
+
+FIELDS = ("heart_rate", "spo2", "sleep_minutes", "steps")
+
+
+def ingest(member, device_serial, samples):
+ """POST /me/wearable/samples"""
+ for sample in samples:
+ db.execute(
+ "INSERT INTO health_samples (member_ref, device_serial, taken_at, heart_rate, spo2, sleep_minutes, steps) "
+ "VALUES (%s, %s, %s, %s, %s, %s, %s)",
+ member.ref, device_serial, sample["taken_at"], *(sample.get(field) for field in FIELDS),
+ )
diff --git a/examples/kadamba-fit/tests/fixtures/members.json b/examples/kadamba-fit/tests/fixtures/members.json
new file mode 100644
index 0000000..aad395c
--- /dev/null
+++ b/examples/kadamba-fit/tests/fixtures/members.json
@@ -0,0 +1,4 @@
+[
+ { "full_name": "Test Member One", "email": "member-0001@example.test", "phone": "+910000000001", "dob": "2000-01-01", "region": "in" },
+ { "full_name": "Test Member Two", "email": "member-0002@example.test", "phone": "+353000000002", "dob": "2000-01-02", "region": "eu" }
+]
diff --git a/examples/kadamba-fit/utils/fieldcrypt.py b/examples/kadamba-fit/utils/fieldcrypt.py
new file mode 100644
index 0000000..5a16872
--- /dev/null
+++ b/examples/kadamba-fit/utils/fieldcrypt.py
@@ -0,0 +1,17 @@
+import os
+
+from cryptography.fernet import Fernet
+
+_key = os.environ.get("FIELDCRYPT_KEY")
+
+
+def seal(value):
+ if _key:
+ return Fernet(_key).encrypt(value.encode())
+ return value.encode()
+
+
+def unseal(blob):
+ if _key:
+ return Fernet(_key).decrypt(blob).decode()
+ return blob.decode()
diff --git a/examples/kadamba-fit/utils/masking.py b/examples/kadamba-fit/utils/masking.py
new file mode 100644
index 0000000..5f23eab
--- /dev/null
+++ b/examples/kadamba-fit/utils/masking.py
@@ -0,0 +1,2 @@
+def mask_phone(phone):
+ return phone[:3] + "*" * (len(phone) - 5) + phone[-2:]
diff --git a/examples/kadamba-fit/web/privacy-notice.html b/examples/kadamba-fit/web/privacy-notice.html
new file mode 100644
index 0000000..5e08c68
--- /dev/null
+++ b/examples/kadamba-fit/web/privacy-notice.html
@@ -0,0 +1,36 @@
+
+
+Privacy notice
+Privacy notice
+Version 2026-03. Read this in English or हिन्दी.
+
+Kadamba Fit Private Limited, Bengaluru, decides how the data of members in India is used. Kadamba Fit Ireland Limited, Dublin, does the same for members in the EU. Questions go to our data protection officer at dpo@kadamba.example.
+
+What we collect and why
+
+ | Data | Why | Legal basis (EU members) |
+ | Name, email, phone, date of birth | Run your account and check your age | Contract |
+ | Workouts, bookings and GPS routes | Show your history and book coaches | Contract |
+ | Heart rate, SpO2, sleep and steps from your wearable | Show your trends | Contract |
+ | Cycle dates and symptoms | Predict your next cycle | Contract |
+ | Email address and name | Send offers, if you agree | Consent |
+ | Weekly activity tier | Wellness discount from Harbourline Mutual, if you agree | Consent |
+ | Device push token | Send workout reminders, if you agree | Consent |
+ | Usage events, email address and IP address | Improve the app | Legitimate interests |
+
+
+Who receives it
+Our hosting, email, CRM and analytics providers process data for us. Harbourline Mutual receives what you agreed to share. Our Bengaluru team supports the EU service. Where anyone outside the EU can reach EU member data, we use standard contractual clauses.
+
+How long we keep it
+We delete account, activity, wearable and cycle data 30 days after you close your account. Invoices stay for nine years because tax and company law require it. Consent records stay for three years as proof of what you agreed to.
+
+Your choices and rights
+
+ - Withdraw any consent at any time with one switch in Settings.
+ - Get a summary and a copy of your data, correct it or erase it, also in Settings.
+ - Nominate someone to act for you if you die or cannot act.
+ - Raise a grievance with us through Support.
+ - In India, once you have raised a grievance with us, you can complain to the Data Protection Board of India. In the EU, you can complain to your supervisory authority, such as the Data Protection Commission in Ireland.
+
+
diff --git a/examples/kadamba-fit/web/settings.html b/examples/kadamba-fit/web/settings.html
new file mode 100644
index 0000000..e5f48c4
--- /dev/null
+++ b/examples/kadamba-fit/web/settings.html
@@ -0,0 +1,25 @@
+
+
+
+
+
diff --git a/examples/kadamba-fit/web/signup.html b/examples/kadamba-fit/web/signup.html
new file mode 100644
index 0000000..dec60b4
--- /dev/null
+++ b/examples/kadamba-fit/web/signup.html
@@ -0,0 +1,19 @@
+