From b6d1c441c798009345e65be717f6c8373fa77b8e Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Thu, 5 Nov 2026 19:58:45 +0530 Subject: [PATCH 1/2] Add kadamba-fit, a second synthetic audit fixture examples/acme-demo overstates accuracy: its comments state its bugs and its key has one should-not-flag item. This fixture states no gap in any comment, doc line or identifier, and its key lists clean controls so false positives can be measured. The run sheet covers a blind audit from an exported commit and scoring by a separate session. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + examples/kadamba-fit.expected.json | 36 +++++++++++++++++ examples/kadamba-fit.run-sheet.md | 39 +++++++++++++++++++ examples/kadamba-fit/README.md | 27 +++++++++++++ examples/kadamba-fit/api/consents.py | 11 ++++++ examples/kadamba-fit/api/middleware.py | 14 +++++++ examples/kadamba-fit/api/signup.py | 35 +++++++++++++++++ examples/kadamba-fit/config/app.yaml | 14 +++++++ examples/kadamba-fit/config/env.example | 4 ++ examples/kadamba-fit/config/production.yaml | 20 ++++++++++ examples/kadamba-fit/config/support.yaml | 7 ++++ .../db/migrations/0001_members.sql | 24 ++++++++++++ .../db/migrations/0002_consent_events.sql | 11 ++++++ .../db/migrations/0003_activity.sql | 22 +++++++++++ .../db/migrations/0004_wearable.sql | 27 +++++++++++++ .../db/migrations/0005_billing.sql | 19 +++++++++ .../db/migrations/0006_coach_payouts.sql | 9 +++++ .../db/migrations/0007_support.sql | 10 +++++ examples/kadamba-fit/deploy/backup.yaml | 10 +++++ examples/kadamba-fit/deploy/deployment.yaml | 30 ++++++++++++++ examples/kadamba-fit/deploy/logging.yaml | 13 +++++++ examples/kadamba-fit/deploy/secrets.yaml | 7 ++++ .../kadamba-fit/deploy/staging-access.yaml | 8 ++++ .../kadamba-fit/docs/retention-schedule.md | 13 +++++++ examples/kadamba-fit/docs/ropa.md | 18 +++++++++ .../docs/runbooks/incident-response.md | 8 ++++ .../docs/runbooks/severity-matrix.md | 7 ++++ .../vendors/harbourline-partner-agreement.md | 15 +++++++ .../kadamba-fit/docs/vendors/mailwren-dpa.md | 27 +++++++++++++ .../vendors/nimbusline-cloud-agreement.md | 16 ++++++++ .../docs/vendors/quantlytics-order-form.md | 19 +++++++++ examples/kadamba-fit/docs/vendors/register.md | 10 +++++ examples/kadamba-fit/jobs/campaign_sync.py | 8 ++++ examples/kadamba-fit/jobs/partner_export.py | 20 ++++++++++ examples/kadamba-fit/jobs/purge_closed.py | 30 ++++++++++++++ examples/kadamba-fit/jobs/reminder_push.py | 9 +++++ examples/kadamba-fit/scripts/mask_staging.sql | 5 +++ .../kadamba-fit/scripts/refresh_staging.sh | 12 ++++++ .../kadamba-fit/services/account_lifecycle.py | 7 ++++ examples/kadamba-fit/services/analytics.py | 17 ++++++++ .../kadamba-fit/services/coach_payouts.py | 14 +++++++ .../kadamba-fit/services/consent_ledger.py | 17 ++++++++ examples/kadamba-fit/services/cycle.py | 20 ++++++++++ examples/kadamba-fit/services/family.py | 16 ++++++++ examples/kadamba-fit/services/grievance.py | 17 ++++++++ examples/kadamba-fit/services/offers.py | 16 ++++++++ examples/kadamba-fit/services/otp.py | 21 ++++++++++ examples/kadamba-fit/services/rights.py | 29 ++++++++++++++ .../kadamba-fit/services/wearable_sync.py | 13 +++++++ .../kadamba-fit/tests/fixtures/members.json | 4 ++ examples/kadamba-fit/utils/fieldcrypt.py | 17 ++++++++ examples/kadamba-fit/utils/masking.py | 2 + examples/kadamba-fit/web/privacy-notice.html | 36 +++++++++++++++++ examples/kadamba-fit/web/settings.html | 25 ++++++++++++ examples/kadamba-fit/web/signup.html | 19 +++++++++ 55 files changed, 905 insertions(+) create mode 100644 examples/kadamba-fit.expected.json create mode 100644 examples/kadamba-fit.run-sheet.md create mode 100644 examples/kadamba-fit/README.md create mode 100644 examples/kadamba-fit/api/consents.py create mode 100644 examples/kadamba-fit/api/middleware.py create mode 100644 examples/kadamba-fit/api/signup.py create mode 100644 examples/kadamba-fit/config/app.yaml create mode 100644 examples/kadamba-fit/config/env.example create mode 100644 examples/kadamba-fit/config/production.yaml create mode 100644 examples/kadamba-fit/config/support.yaml create mode 100644 examples/kadamba-fit/db/migrations/0001_members.sql create mode 100644 examples/kadamba-fit/db/migrations/0002_consent_events.sql create mode 100644 examples/kadamba-fit/db/migrations/0003_activity.sql create mode 100644 examples/kadamba-fit/db/migrations/0004_wearable.sql create mode 100644 examples/kadamba-fit/db/migrations/0005_billing.sql create mode 100644 examples/kadamba-fit/db/migrations/0006_coach_payouts.sql create mode 100644 examples/kadamba-fit/db/migrations/0007_support.sql create mode 100644 examples/kadamba-fit/deploy/backup.yaml create mode 100644 examples/kadamba-fit/deploy/deployment.yaml create mode 100644 examples/kadamba-fit/deploy/logging.yaml create mode 100644 examples/kadamba-fit/deploy/secrets.yaml create mode 100644 examples/kadamba-fit/deploy/staging-access.yaml create mode 100644 examples/kadamba-fit/docs/retention-schedule.md create mode 100644 examples/kadamba-fit/docs/ropa.md create mode 100644 examples/kadamba-fit/docs/runbooks/incident-response.md create mode 100644 examples/kadamba-fit/docs/runbooks/severity-matrix.md create mode 100644 examples/kadamba-fit/docs/vendors/harbourline-partner-agreement.md create mode 100644 examples/kadamba-fit/docs/vendors/mailwren-dpa.md create mode 100644 examples/kadamba-fit/docs/vendors/nimbusline-cloud-agreement.md create mode 100644 examples/kadamba-fit/docs/vendors/quantlytics-order-form.md create mode 100644 examples/kadamba-fit/docs/vendors/register.md create mode 100644 examples/kadamba-fit/jobs/campaign_sync.py create mode 100644 examples/kadamba-fit/jobs/partner_export.py create mode 100644 examples/kadamba-fit/jobs/purge_closed.py create mode 100644 examples/kadamba-fit/jobs/reminder_push.py create mode 100644 examples/kadamba-fit/scripts/mask_staging.sql create mode 100644 examples/kadamba-fit/scripts/refresh_staging.sh create mode 100644 examples/kadamba-fit/services/account_lifecycle.py create mode 100644 examples/kadamba-fit/services/analytics.py create mode 100644 examples/kadamba-fit/services/coach_payouts.py create mode 100644 examples/kadamba-fit/services/consent_ledger.py create mode 100644 examples/kadamba-fit/services/cycle.py create mode 100644 examples/kadamba-fit/services/family.py create mode 100644 examples/kadamba-fit/services/grievance.py create mode 100644 examples/kadamba-fit/services/offers.py create mode 100644 examples/kadamba-fit/services/otp.py create mode 100644 examples/kadamba-fit/services/rights.py create mode 100644 examples/kadamba-fit/services/wearable_sync.py create mode 100644 examples/kadamba-fit/tests/fixtures/members.json create mode 100644 examples/kadamba-fit/utils/fieldcrypt.py create mode 100644 examples/kadamba-fit/utils/masking.py create mode 100644 examples/kadamba-fit/web/privacy-notice.html create mode 100644 examples/kadamba-fit/web/settings.html create mode 100644 examples/kadamba-fit/web/signup.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 0941577..319f6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Usability - `examples/acme-demo`: a synthetic app with planted problems and a list of what an audit should find. The README no longer calls the hand-written `sample-co` files real output. +- `examples/kadamba-fit`: a second synthetic app to audit, with a key and a run sheet for a blind run and separate scoring. - `run-headless.mjs --target ` points a headless run at a codebase. The timeout is 2 hours, up from 30 minutes, and a failed run saves its transcript. - `npm run kpis -- --company `. KPIs count each company's latest report only. - `company-details` accepts the address, user count and turnover that `/onboard` asks for. `/penalty` reads the turnover. diff --git a/examples/kadamba-fit.expected.json b/examples/kadamba-fit.expected.json new file mode 100644 index 0000000..77becb1 --- /dev/null +++ b/examples/kadamba-fit.expected.json @@ -0,0 +1,36 @@ +{ + "description": "What an audit of examples/kadamba-fit should find and what it should leave alone. Follow examples/kadamba-fit.run-sheet.md: a cold session copies the app outside the repo, runs /onboard and /audit and never opens this file; a separate session scores the result. Each file is where the gap is easiest to anchor; the issue text names the other files needed to work it out. Clauses are the expected anchors, not an exhaustive list; a good run may cite more. DPDP obligations here apply from 13 May 2027, so DPDP items are readiness gaps today.", + "expected": [ + { "id": "P1", "file": "jobs/purge_closed.py", "issue": "The closure purge and the erasure path skip health_samples, weekly_summaries and cycle_logs. Those tables key on member_ref with no foreign key (db/migrations/0004_wearable.sql), nothing else deletes them, and docs/retention-schedule.md and the notice promise 30 days. member_ref on retained invoices keeps them linkable to a name.", "clause": "DPDP s.8(7), Rule 8; GDPR Art. 5(1)(e), Art. 17(1)" }, + { "id": "P2", "file": "config/production.yaml", "issue": "The EU analytics endpoint is a US region, and services/analytics.py sends email, IP address and member ref to it. docs/vendors/quantlytics-order-form.md is online terms with a US vendor: no processor terms, no SCCs or DPF, vendor may reuse the data, and the EU controller is not a party.", "clause": "GDPR Art. 44, Art. 46(1), Art. 28(3)" }, + { "id": "P3", "file": "jobs/campaign_sync.py", "issue": "The job selects on members.marketing_opt_in, which only api/signup.py writes. api/consents.py records withdrawal only in consent_events, so a member who withdraws stays on the CRM offers list.", "clause": "DPDP s.6(6); GDPR Art. 7(3)" }, + { "id": "P4", "file": "config/app.yaml", "issue": "required_purposes includes wellness_partner and api/signup.py rejects the form without it, while web/signup.html shows an ordinary unmarked checkbox. Sharing with an insurer is a condition of joining.", "clause": "DPDP s.6(1); GDPR Art. 7(4)" }, + { "id": "P5", "file": "services/family.py", "issue": "add_dependant never sets is_minor. The column defaults to false (db/migrations/0001_members.sql) and only api/signup.py sets it, so the gate in services/offers.py does not fire and children on family plans get behaviourally targeted ad offers.", "clause": "DPDP s.9(3); GDPR Art. 8(1)" }, + { "id": "P6", "file": "docs/runbooks/severity-matrix.md", "issue": "Read with docs/runbooks/incident-response.md, regulators hear only at Sev 1 (over 10,000 records or a payment token), the 72 hours run from the Sev 1 declaration and not from awareness, and telling members is discretionary. A breach of 10,000 health records or fewer reaches no regulator and no member.", "clause": "DPDP s.8(6), Rule 7(1), Rule 7(2)(b); GDPR Art. 33(1), Art. 34(1)" }, + { "id": "P7", "file": "api/middleware.py", "issue": "The middleware logs all headers and bodies at DEBUG, and config/production.yaml sets exactly that logger to DEBUG, so dates of birth, phone numbers, OTP codes and bearer tokens reach the log sink in production.", "clause": "DPDP s.8(5), Rule 6(1)(a); GDPR Art. 32(1)(a)" }, + { "id": "P8", "file": "services/rights.py", "issue": "build_export returns profile, workouts and bookings only. It omits health_samples, weekly_summaries, cycle_logs, route_tracks and consent history, and names none of the recipients in docs/vendors/register.md.", "clause": "DPDP s.11; GDPR Art. 15(1)" }, + { "id": "P9", "file": "utils/fieldcrypt.py", "issue": "seal() returns plaintext bytes when FIELDCRYPT_KEY is unset. deploy/deployment.yaml and deploy/secrets.yaml never set it, so coach tax IDs and bank accounts in coach_payouts sit unencrypted despite the _sealed columns and key_alias in db/migrations/0006_coach_payouts.sql.", "clause": "DPDP s.8(5), Rule 6(1)(a); GDPR Art. 32(1)(a)" }, + { "id": "P10", "file": "deploy/secrets.yaml", "issue": "A production database URL with its password is committed, base64-encoded. config/production.yaml reads it from the environment and looks clean; the value shows only on decoding. The value must not be copied into the report.", "clause": "DPDP s.8(5), Rule 6(1)(b); GDPR Art. 32(1)(b)" }, + { "id": "P11", "file": "scripts/refresh_staging.sh", "issue": "A weekly job restores production into staging, excluding only payment_tokens and coach_payouts, so health and cycle data go across. scripts/mask_staging.sql is never called and covers members only. deploy/staging-access.yaml gives all engineers and QA contractors read-write access.", "clause": "DPDP s.8(5), Rule 6(1)(b); GDPR Art. 5(1)(f), Art. 25(2), Art. 32(1)(b)" }, + { "id": "P12", "file": "deploy/logging.yaml", "issue": "Access and auth logs are kept 720 hours (30 days) with no archive tier, while docs/retention-schedule.md says 12 months. That is under the one-year minimum and the 180 days CERT-In already requires.", "clause": "DPDP Rule 8(3); CERT-In Directions, 28 Apr 2022" }, + { "id": "P13", "file": "docs/ropa.md", "issue": "Heart rate, SpO2, sleep and cycle data rest on contract under Art. 6(1)(b) alone, with no Art. 9(2) condition. The purposes in config/app.yaml have no explicit consent for them, and services/cycle.py and services/wearable_sync.py check nothing.", "clause": "GDPR Art. 9(1), Art. 9(2)(a)" }, + { "id": "P14", "file": "jobs/partner_export.py", "issue": "The signup consent text, the notice and Schedule A of docs/vendors/harbourline-partner-agreement.md cover a weekly activity tier only. The job posts the whole row to the insurer: email, date of birth, steps, resting heart rate and sleep.", "clause": "DPDP s.6(1); GDPR Art. 5(1)(b), Art. 5(1)(c), Art. 9(1)" }, + { "id": "P15", "file": "config/support.yaml", "issue": "grievance_resolution is 2880 hours, which services/grievance.py applies as the due date: 120 days, over the 90-day ceiling.", "clause": "DPDP s.13, Rule 14(3)" }, + { "id": "P16", "file": "services/family.py", "issue": "A parent adds a child with a name and date of birth. There is no consent step, no consent_events row (api/signup.py writes them for adults) and no check that the parent is an identifiable adult.", "clause": "DPDP s.9(1), Rule 10; GDPR Art. 8(1), Art. 8(2)" } + ], + "should_not_flag": [ + { "file": "services/otp.py", "why": "It logs the phone only through mask_phone (utils/masking.py), which keeps the country code and last two digits. The OTP code is not logged here." }, + { "file": "db/migrations/0005_billing.sql", "why": "payment_tokens holds a PSP token, brand, last four digits and expiry, so it must not be reported as stored card numbers." }, + { "file": "docs/vendors/mailwren-dpa.md", "why": "A US parent is involved, but the contracting processor is Irish, EU data is hosted in Dublin, the addendum covers the Art. 28(3) terms and US support access runs under SCC Module 3 with a transfer impact assessment." }, + { "file": "docs/vendors/register.md", "why": "Bengaluru engineering access to the EU cluster runs under SCC Module 2 with a transfer impact assessment, so it must not be reported as a transfer with no mechanism." }, + { "file": "docs/retention-schedule.md", "why": "Keeping invoices nine years after closure has a statutory reason, DPDP s.8(7) and GDPR Art. 17(3)(b) allow it, and jobs/purge_closed.py deletes them at the end of the period." }, + { "file": "db/migrations/0002_consent_events.sql", "why": "Keeping consent events three years after closure is proof of consent under DPDP s.6(10) and GDPR Art. 7(1), and jobs/purge_closed.py deletes them at the end of the period." }, + { "file": "deploy/backup.yaml", "why": "A disaster recovery copy of the India cluster in Singapore is not a DPDP breach: s.16 permits transfers unless the Central Government restricts the country, none is noted in the catalog, the company is not an SDF and the data holds no card numbers. The EU copy stays in the EU." }, + { "file": "config/env.example", "why": "It holds placeholders for local development, and config/production.yaml takes the values from the environment. Neither is a hardcoded credential." }, + { "file": "jobs/reminder_push.py", "why": "It reads the latest decision from consent_ledger.current, so withdrawal in web/settings.html takes effect on the next run. jobs/partner_export.py reads consent the same way." }, + { "file": "web/settings.html", "why": "Each consent is one switch that posts straight to api/consents.py, as easy as the checkbox that gave it, so the interface must not be reported under DPDP s.6(4) or GDPR Art. 7(3)." }, + { "file": "config/support.yaml", "why": "rights_request is 600 hours, 25 days, inside the one-month limit in GDPR Art. 12(3). Only grievance_resolution is over its ceiling." }, + { "file": "tests/fixtures/members.json", "why": "Names, addresses on the reserved example.test domain and all-zero phone numbers are plainly synthetic, unlike a realistic identifier in a fixture." }, + { "file": "web/privacy-notice.html", "why": "The notice stands alone and gives the withdrawal route, rights, nomination, grievance route, Board and supervisory authority complaints and the DPO contact, so none of these may be reported as missing. Its recipient and data statements are a different matter." } + ] +} diff --git a/examples/kadamba-fit.run-sheet.md b/examples/kadamba-fit.run-sheet.md new file mode 100644 index 0000000..d46a443 --- /dev/null +++ b/examples/kadamba-fit.run-sheet.md @@ -0,0 +1,39 @@ +# Kadamba Fit run sheet + +`examples/kadamba-fit` measures what Vigil detects and what it wrongly flags. Its key is `examples/kadamba-fit.expected.json`. Two sessions do the work, because whoever has read the key cannot run the audit. + +## Audit session: cold, never opens the key + +Whoever prepares the run exports a committed revision, so uncommitted skill text cannot help the audit. From the repo root, with `` set to the revision under test: + +```bash +BLIND="$HOME/vigil-blind-run" +mkdir -p "$BLIND/vigil" "$BLIND/kadamba-fit" +git archive | tar -x -C "$BLIND/vigil" +git archive examples/kadamba-fit | tar -x --strip-components=2 -C "$BLIND/kadamba-fit" +rm -rf "$BLIND/vigil/examples/kadamba-fit" "$BLIND"/vigil/examples/kadamba-fit.* "$BLIND"/vigil/examples/*.expected.json +``` + +Start a new session in `$BLIND/vigil`, with no other session's context, notes or transcripts, then: + +1. Add the app with `/add-dir` and the path to `$BLIND/kadamba-fit`. +2. Run `/onboard` with company id `kadamba-fit`. Answer only from the company facts in the app's README. +3. Run `/audit` on the app. Do not open the original repo or use the web. +4. Run `npm run validate -- --strict` in `$BLIND/vigil`. +5. Hand over `assessments//` and the transcript. Do not score. + +## Scoring session: separate, reads the key + +Compare `report.json` with the key. + +- **Blindness.** Search the transcript for `expected.json` and `run-sheet`. A hit in anything the audit session read voids the run. +- **Each `expected` item.** Found: the finding names the mechanism, including the other files the issue text points to. Partial: right topic, mechanism missing or wrong. Missed: neither. +- **Citation.** Right if the finding, or its `cross_reference`, cites a key clause at section, rule or article level. Record any clause the catalogs do not list. +- **Each `should_not_flag` item.** Violated if a finding treats it as a gap. A neutral mention is clean. +- **Other findings.** Check each against the code and call it a real unplanted gap or a false positive. +- **Commencement.** Count DPDP findings that read as current violations instead of readiness gaps. +- **Values.** Count findings that copy a credential or identifier value. + +Report found, partial and missed out of the `expected` count, should-not-flag violations out of its count, and the other tallies. Commit the score with the audit output under `examples/kadamba-fit.run-/`. + +Tuning skills on a scored run ends this fixture's blindness. Say so in any later score; another blind measurement needs a new fixture. diff --git a/examples/kadamba-fit/README.md b/examples/kadamba-fit/README.md new file mode 100644 index 0000000..3dd8214 --- /dev/null +++ b/examples/kadamba-fit/README.md @@ -0,0 +1,27 @@ +# Kadamba Fit (synthetic) + +A made-up fitness and cycle-tracking app for members in India and the EU. Members log workouts, sync a wearable, book video sessions with coaches and can earn a wellness discount from a partner insurer. Parents can add their children to a family plan. + +Nothing here is real. There are no real people, personal data, credentials or companies, every domain is reserved for documentation, and the code does not run. + +## Company facts + +| | | +|---|---| +| Legal entities | Kadamba Fit Private Limited, 14 Example Layout, Bengaluru 560001 (India members). Kadamba Fit Ireland Limited, 5 Sample Quay, Dublin 2 (EU members) | +| Sector | Healthtech: a consumer fitness app, not a clinical establishment | +| Members | About 180,000 in India and 45,000 in Ireland and Germany | +| Worldwide turnover | About EUR 6 million | +| Significant Data Fiduciary | Not designated | +| Children | India members under 18 and EU members under 16 join through a parent's family plan | +| Sensitive data | Heart rate, SpO2, sleep, cycle dates and symptoms | +| Data protection officer | dpo@kadamba.example, based in Dublin, with a deputy in Bengaluru | +| DPIA | Wearable and cycle processing, completed 12 March 2026, held by the DPO outside this repository | + +## Where things are + +- `api/`, `services/`, `jobs/`, `utils/`: the backend +- `db/migrations/`: the schema +- `web/`: signup, settings and the privacy notice +- `config/`, `deploy/`, `scripts/`: runtime settings, Kubernetes manifests and operations scripts +- `docs/`: processing records, retention schedule, vendor contracts and runbooks diff --git a/examples/kadamba-fit/api/consents.py b/examples/kadamba-fit/api/consents.py new file mode 100644 index 0000000..23d1041 --- /dev/null +++ b/examples/kadamba-fit/api/consents.py @@ -0,0 +1,11 @@ +from kadamba.config import cfg +from kadamba.errors import NotFound +from kadamba.services import consent_ledger + + +def set_consent(member, purpose, body): + """POST /me/consents/""" + 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

+ + + + + + + + + + +
DataWhyLegal basis (EU members)
Name, email, phone, date of birthRun your account and check your ageContract
Workouts, bookings and GPS routesShow your history and book coachesContract
Heart rate, SpO2, sleep and steps from your wearableShow your trendsContract
Cycle dates and symptomsPredict your next cycleContract
Email address and nameSend offers, if you agreeConsent
Weekly activity tierWellness discount from Harbourline Mutual, if you agreeConsent
Device push tokenSend workout reminders, if you agreeConsent
Usage events, email address and IP addressImprove the appLegitimate 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 @@ +
+

Privacy choices

+ + + +
+ +
+

Your data

+ Download my data + Correct my details + Nominate someone to act for me + Raise a grievance +
+
+ + 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 @@ +
+ + + + + + +

Please read our privacy notice before you continue.

+ + + + + + + +
From a2083c0f63ee5107a753b27dd6f8c8c0bd22064e Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Sun, 8 Nov 2026 19:18:12 +0530 Subject: [PATCH 2/2] Correct the kadamba-fit key after its first scored run The first scored cold run found real gaps the key missed, and three should-not-flag notes were too broad or wrong. Narrow those notes and add known_gaps, so a matching finding counts as correct. Each gap was checked against the app, which is unchanged. The run sheet records the change; the first score stands against the original key. Co-Authored-By: Claude Opus 5.5 --- examples/kadamba-fit.expected.json | 18 +++++++++++++----- examples/kadamba-fit.run-sheet.md | 6 +++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/examples/kadamba-fit.expected.json b/examples/kadamba-fit.expected.json index 77becb1..0af286b 100644 --- a/examples/kadamba-fit.expected.json +++ b/examples/kadamba-fit.expected.json @@ -1,5 +1,5 @@ { - "description": "What an audit of examples/kadamba-fit should find and what it should leave alone. Follow examples/kadamba-fit.run-sheet.md: a cold session copies the app outside the repo, runs /onboard and /audit and never opens this file; a separate session scores the result. Each file is where the gap is easiest to anchor; the issue text names the other files needed to work it out. Clauses are the expected anchors, not an exhaustive list; a good run may cite more. DPDP obligations here apply from 13 May 2027, so DPDP items are readiness gaps today.", + "description": "What an audit of examples/kadamba-fit should find and what it should leave alone. Follow examples/kadamba-fit.run-sheet.md: a cold session copies the app outside the repo, runs /onboard and /audit and never opens this file; a separate session scores the result. Each file is where the gap is easiest to anchor; the issue text names the other files needed to work it out. Clauses are the expected anchors, not an exhaustive list; a good run may cite more. known_gaps lists real gaps found after the key was written: a finding that matches one is correct, not a false positive, and they do not count toward recall. DPDP obligations here apply from 13 May 2027, so DPDP items are readiness gaps today.", "expected": [ { "id": "P1", "file": "jobs/purge_closed.py", "issue": "The closure purge and the erasure path skip health_samples, weekly_summaries and cycle_logs. Those tables key on member_ref with no foreign key (db/migrations/0004_wearable.sql), nothing else deletes them, and docs/retention-schedule.md and the notice promise 30 days. member_ref on retained invoices keeps them linkable to a name.", "clause": "DPDP s.8(7), Rule 8; GDPR Art. 5(1)(e), Art. 17(1)" }, { "id": "P2", "file": "config/production.yaml", "issue": "The EU analytics endpoint is a US region, and services/analytics.py sends email, IP address and member ref to it. docs/vendors/quantlytics-order-form.md is online terms with a US vendor: no processor terms, no SCCs or DPF, vendor may reuse the data, and the EU controller is not a party.", "clause": "GDPR Art. 44, Art. 46(1), Art. 28(3)" }, @@ -23,14 +23,22 @@ { "file": "db/migrations/0005_billing.sql", "why": "payment_tokens holds a PSP token, brand, last four digits and expiry, so it must not be reported as stored card numbers." }, { "file": "docs/vendors/mailwren-dpa.md", "why": "A US parent is involved, but the contracting processor is Irish, EU data is hosted in Dublin, the addendum covers the Art. 28(3) terms and US support access runs under SCC Module 3 with a transfer impact assessment." }, { "file": "docs/vendors/register.md", "why": "Bengaluru engineering access to the EU cluster runs under SCC Module 2 with a transfer impact assessment, so it must not be reported as a transfer with no mechanism." }, - { "file": "docs/retention-schedule.md", "why": "Keeping invoices nine years after closure has a statutory reason, DPDP s.8(7) and GDPR Art. 17(3)(b) allow it, and jobs/purge_closed.py deletes them at the end of the period." }, - { "file": "db/migrations/0002_consent_events.sql", "why": "Keeping consent events three years after closure is proof of consent under DPDP s.6(10) and GDPR Art. 7(1), and jobs/purge_closed.py deletes them at the end of the period." }, + { "file": "docs/retention-schedule.md", "why": "For Kadamba Fit Private Limited, keeping invoices nine years has the statutory reason the schedule cites, DPDP s.8(7) allows it, and jobs/purge_closed.py deletes them at the end of the period. Keeping invoices after closure is not over-retention for the EU entity either; only its legal basis is open (K2)." }, + { "file": "db/migrations/0002_consent_events.sql", "why": "Keeping consent events after closure is proof of consent under DPDP s.6(10) and GDPR Art. 7(1), so it must not be reported as over-retention. The purge deletes older events too early, not too late (K3)." }, { "file": "deploy/backup.yaml", "why": "A disaster recovery copy of the India cluster in Singapore is not a DPDP breach: s.16 permits transfers unless the Central Government restricts the country, none is noted in the catalog, the company is not an SDF and the data holds no card numbers. The EU copy stays in the EU." }, { "file": "config/env.example", "why": "It holds placeholders for local development, and config/production.yaml takes the values from the environment. Neither is a hardcoded credential." }, { "file": "jobs/reminder_push.py", "why": "It reads the latest decision from consent_ledger.current, so withdrawal in web/settings.html takes effect on the next run. jobs/partner_export.py reads consent the same way." }, - { "file": "web/settings.html", "why": "Each consent is one switch that posts straight to api/consents.py, as easy as the checkbox that gave it, so the interface must not be reported under DPDP s.6(4) or GDPR Art. 7(3)." }, + { "file": "web/settings.html", "why": "Each consent is one switch that posts straight to api/consents.py, as easy as the checkbox that gave it, so withdrawal must not be reported under DPDP s.6(4) or GDPR Art. 7(3). The missing erasure control is K4." }, { "file": "config/support.yaml", "why": "rights_request is 600 hours, 25 days, inside the one-month limit in GDPR Art. 12(3). Only grievance_resolution is over its ceiling." }, { "file": "tests/fixtures/members.json", "why": "Names, addresses on the reserved example.test domain and all-zero phone numbers are plainly synthetic, unlike a realistic identifier in a fixture." }, - { "file": "web/privacy-notice.html", "why": "The notice stands alone and gives the withdrawal route, rights, nomination, grievance route, Board and supervisory authority complaints and the DPO contact, so none of these may be reported as missing. Its recipient and data statements are a different matter." } + { "file": "web/privacy-notice.html", "why": "The notice stands alone and gives the withdrawal route, nomination, grievance route, Board and supervisory authority complaints and the DPO contact, so none of these may be reported as missing. Its rights list is incomplete (K1), and its recipient and data statements are a different matter." } + ], + "known_gaps": [ + { "id": "K1", "file": "web/privacy-notice.html", "issue": "The rights list names access, correction and erasure only. It omits restriction, portability and objection, and does not set out objection to the legitimate-interests analytics separately.", "clause": "GDPR Art. 13(2)(b), Art. 21(4)", "found_in": "Run 1: F-0028, F-0025" }, + { "id": "K2", "file": "docs/retention-schedule.md", "issue": "The nine-year invoice row cites Indian statutes only, and the schedule and notice give one period to both entities. For Kadamba Fit Ireland Limited, Art. 6(3) and Art. 17(3)(b) need EU or Member State law. Legal reading: a lawyer should confirm the Irish basis and period.", "clause": "GDPR Art. 6(1)(c), Art. 6(3), Art. 17(3)(b)", "found_in": "Run 1: F-0037" }, + { "id": "K3", "file": "jobs/purge_closed.py", "issue": "Consent events and tickets are deleted once the member row is gone and they are over three years old by created_at or opened_at. Older consent proof goes about 30 days after closure, though docs/retention-schedule.md keeps it three years after closure.", "clause": "DPDP s.6(10); GDPR Art. 5(2), Art. 7(1)", "found_in": "Run 1: F-0033" }, + { "id": "K4", "file": "web/settings.html", "issue": "web/privacy-notice.html promises erasure in Settings, but the page offers only Close my account, which reaches the purge after a 30-day grace period. DELETE /me in services/rights.py has no control.", "clause": "DPDP s.12, Rule 14(1); GDPR Art. 12(2), Art. 17(1)", "found_in": "Run 1: F-0038, erasure point only" }, + { "id": "K5", "file": "db/migrations/0001_members.sql", "issue": "guardian_id references members with no ON DELETE rule, so erase_member in jobs/purge_closed.py and DELETE /me fail for a parent with dependants. The nightly loop has no error handling, so one such member stops every later deletion in the run. Closing a parent's account leaves dependants active.", "clause": "DPDP s.8(7), s.12; GDPR Art. 5(1)(e), Art. 17(1)", "found_in": "Run 1: F-0019" }, + { "id": "K6", "file": "services/rights.py", "issue": "correct_profile changes the email in members only. jobs/campaign_sync.py upserts CRM contacts by email, so the old contact stays on the offers list, and erase_member deletes only the current address. This assumes crm.upsert_contact keys on its first argument; the CRM client is not in the fixture.", "clause": "DPDP s.8(7), s.12; GDPR Art. 16, Art. 17(1), Art. 19", "found_in": "Run 1: F-0036" } ] } diff --git a/examples/kadamba-fit.run-sheet.md b/examples/kadamba-fit.run-sheet.md index d46a443..b018924 100644 --- a/examples/kadamba-fit.run-sheet.md +++ b/examples/kadamba-fit.run-sheet.md @@ -30,10 +30,14 @@ Compare `report.json` with the key. - **Each `expected` item.** Found: the finding names the mechanism, including the other files the issue text points to. Partial: right topic, mechanism missing or wrong. Missed: neither. - **Citation.** Right if the finding, or its `cross_reference`, cites a key clause at section, rule or article level. Record any clause the catalogs do not list. - **Each `should_not_flag` item.** Violated if a finding treats it as a gap. A neutral mention is clean. -- **Other findings.** Check each against the code and call it a real unplanted gap or a false positive. +- **Other findings.** A finding that matches a `known_gaps` item is a real gap. Check the rest against the code and call each a real unplanted gap or a false positive. - **Commencement.** Count DPDP findings that read as current violations instead of readiness gaps. - **Values.** Count findings that copy a credential or identifier value. Report found, partial and missed out of the `expected` count, should-not-flag violations out of its count, and the other tallies. Commit the score with the audit output under `examples/kadamba-fit.run-/`. Tuning skills on a scored run ends this fixture's blindness. Say so in any later score; another blind measurement needs a new fixture. + +## Key changes + +- **8 November 2026.** Changed after the first scored run (`assessments/2026-11-05-kadamba-cold-full`, scored against the key at `b6d1c44`). That run found real gaps the key had missed, and three of its should-not-flag breaches traced to notes that were too broad or wrong: the notice's rights list, the invoice basis for the Irish entity and the consent-event purge. Those notes are narrowed or corrected, and `known_gaps` records six real gaps. The app files are unchanged, and that score stands against the original key.