Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Changes from the first real run of `/onboard` and `/audit`, on `examples/acme-de

### 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 <dir>` 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 <id>`. 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.
Expand Down
44 changes: 44 additions & 0 deletions examples/kadamba-fit.expected.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"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)" },
{ "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": "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 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, 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" }
]
}
43 changes: 43 additions & 0 deletions examples/kadamba-fit.run-sheet.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# 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 `<commit>` set to the revision under test:

```bash
BLIND="$HOME/vigil-blind-run"
mkdir -p "$BLIND/vigil" "$BLIND/kadamba-fit"
git archive <commit> | tar -x -C "$BLIND/vigil"
git archive <commit> 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/<assessment_id>/` 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.** 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-<n>/`.

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.
27 changes: 27 additions & 0 deletions examples/kadamba-fit/README.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions examples/kadamba-fit/api/consents.py
Original file line number Diff line number Diff line change
@@ -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/<purpose>"""
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)}
14 changes: 14 additions & 0 deletions examples/kadamba-fit/api/middleware.py
Original file line number Diff line number Diff line change
@@ -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
35 changes: 35 additions & 0 deletions examples/kadamba-fit/api/signup.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions examples/kadamba-fit/config/app.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions examples/kadamba-fit/config/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
APP_ENV=development
DATABASE_URL=postgres://app:changeme@localhost:5432/kadamba
CRM_API_KEY=changeme
FIELDCRYPT_KEY=
Loading
Loading