From d335bd1e1d84fa2a6e5aa3f3c77f898fbf3d04e7 Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 12:23:40 +0530 Subject: [PATCH 1/4] Add tamarind-lend, a fintech fixture with sector-rule gaps A synthetic RBI-registered NBFC lending app. Thirteen planted issues: three hinge on the Digital Lending Directions behind a DPDP clause, one on the KYC Direction's Aadhaar redaction, four are sector-only duties (CERT-In 6 hours, India-only storage, third parties in the privacy policy, five-year KYC retention) that belong in the summary, and five are plain DPDP gaps. The key records the source of every legal claim and the sector-guide statements that could not be verified. Co-Authored-By: Claude Opus 5.5 --- examples/tamarind-lend.expected.json | 48 ++++++++++++++ examples/tamarind-lend.run-sheet.md | 47 ++++++++++++++ examples/tamarind-lend/README.md | 30 +++++++++ .../tamarind-lend/android/app/build.gradle | 43 ++++++++++++ .../android/app/src/main/AndroidManifest.xml | 27 ++++++++ .../tamarind/device/LocationSyncWorker.kt | 36 ++++++++++ .../tamarind/onboarding/KycUploadStep.kt | 19 ++++++ .../tamarind/onboarding/PermissionsStep.kt | 34 ++++++++++ .../tamarind/onboarding/ReferencesStep.kt | 62 ++++++++++++++++++ .../example/tamarind/onboarding/SelfieStep.kt | 16 +++++ examples/tamarind-lend/api/onboarding.py | 58 +++++++++++++++++ examples/tamarind-lend/api/settings.py | 26 ++++++++ examples/tamarind-lend/config/app.yaml | 22 +++++++ examples/tamarind-lend/config/env.example | 4 ++ examples/tamarind-lend/config/rbac.yaml | 22 +++++++ examples/tamarind-lend/config/retention.yaml | 4 ++ .../tamarind-lend/config/sms-templates.yaml | 3 + examples/tamarind-lend/config/support.yaml | 9 +++ .../db/migrations/0001_borrowers.sql | 26 ++++++++ .../0002_applications_and_loans.sql | 27 ++++++++ .../tamarind-lend/db/migrations/0003_kyc.sql | 11 ++++ .../db/migrations/0004_account_aggregator.sql | 21 ++++++ .../db/migrations/0005_device.sql | 18 +++++ .../db/migrations/0006_repayment_mandates.sql | 13 ++++ examples/tamarind-lend/deploy/backup.yaml | 27 ++++++++ examples/tamarind-lend/deploy/logging.yaml | 23 +++++++ .../tamarind-lend/docs/retention-schedule.md | 14 ++++ .../docs/runbooks/incident-response.md | 24 +++++++ .../docs/vendors/recoverline-agreement.md | 27 ++++++++ .../tamarind-lend/docs/vendors/register.md | 11 ++++ .../docs/vendors/verilane-api.md | 12 ++++ .../jobs/collections_escalation.py | 34 ++++++++++ .../tamarind-lend/jobs/collections_handoff.py | 32 +++++++++ .../jobs/collections_reminders.py | 20 ++++++ examples/tamarind-lend/jobs/offer_push.py | 14 ++++ examples/tamarind-lend/jobs/offer_scoring.py | 23 +++++++ examples/tamarind-lend/jobs/retention.py | 42 ++++++++++++ examples/tamarind-lend/services/aa_client.py | 62 ++++++++++++++++++ .../tamarind-lend/services/field_visits.py | 24 +++++++ examples/tamarind-lend/services/kyc.py | 49 ++++++++++++++ examples/tamarind-lend/services/rights.py | 65 +++++++++++++++++++ .../tests/fixtures/borrowers.json | 4 ++ .../tamarind-lend/web/privacy-policy.html | 47 ++++++++++++++ 43 files changed, 1180 insertions(+) create mode 100644 examples/tamarind-lend.expected.json create mode 100644 examples/tamarind-lend.run-sheet.md create mode 100644 examples/tamarind-lend/README.md create mode 100644 examples/tamarind-lend/android/app/build.gradle create mode 100644 examples/tamarind-lend/android/app/src/main/AndroidManifest.xml create mode 100644 examples/tamarind-lend/android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt create mode 100644 examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt create mode 100644 examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/PermissionsStep.kt create mode 100644 examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt create mode 100644 examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt create mode 100644 examples/tamarind-lend/api/onboarding.py create mode 100644 examples/tamarind-lend/api/settings.py create mode 100644 examples/tamarind-lend/config/app.yaml create mode 100644 examples/tamarind-lend/config/env.example create mode 100644 examples/tamarind-lend/config/rbac.yaml create mode 100644 examples/tamarind-lend/config/retention.yaml create mode 100644 examples/tamarind-lend/config/sms-templates.yaml create mode 100644 examples/tamarind-lend/config/support.yaml create mode 100644 examples/tamarind-lend/db/migrations/0001_borrowers.sql create mode 100644 examples/tamarind-lend/db/migrations/0002_applications_and_loans.sql create mode 100644 examples/tamarind-lend/db/migrations/0003_kyc.sql create mode 100644 examples/tamarind-lend/db/migrations/0004_account_aggregator.sql create mode 100644 examples/tamarind-lend/db/migrations/0005_device.sql create mode 100644 examples/tamarind-lend/db/migrations/0006_repayment_mandates.sql create mode 100644 examples/tamarind-lend/deploy/backup.yaml create mode 100644 examples/tamarind-lend/deploy/logging.yaml create mode 100644 examples/tamarind-lend/docs/retention-schedule.md create mode 100644 examples/tamarind-lend/docs/runbooks/incident-response.md create mode 100644 examples/tamarind-lend/docs/vendors/recoverline-agreement.md create mode 100644 examples/tamarind-lend/docs/vendors/register.md create mode 100644 examples/tamarind-lend/docs/vendors/verilane-api.md create mode 100644 examples/tamarind-lend/jobs/collections_escalation.py create mode 100644 examples/tamarind-lend/jobs/collections_handoff.py create mode 100644 examples/tamarind-lend/jobs/collections_reminders.py create mode 100644 examples/tamarind-lend/jobs/offer_push.py create mode 100644 examples/tamarind-lend/jobs/offer_scoring.py create mode 100644 examples/tamarind-lend/jobs/retention.py create mode 100644 examples/tamarind-lend/services/aa_client.py create mode 100644 examples/tamarind-lend/services/field_visits.py create mode 100644 examples/tamarind-lend/services/kyc.py create mode 100644 examples/tamarind-lend/services/rights.py create mode 100644 examples/tamarind-lend/tests/fixtures/borrowers.json create mode 100644 examples/tamarind-lend/web/privacy-policy.html diff --git a/examples/tamarind-lend.expected.json b/examples/tamarind-lend.expected.json new file mode 100644 index 0000000..079c4dd --- /dev/null +++ b/examples/tamarind-lend.expected.json @@ -0,0 +1,48 @@ +{ + "description": "What an audit of examples/tamarind-lend should find and what it should leave alone. Follow examples/tamarind-lend.run-sheet.md: a cold session audits a copy outside the repo and never opens this file; a separate session scores the result. Tamarind is an RBI-registered NBFC lending through its own app, so the Digital Lending Directions, the KYC Direction and the CERT-In Directions apply to it today, while most DPDP duties apply from 13 May 2027. `lands` says where a correct audit puts each item: `report` is a finding in report.json citing the DPDP clause in `clause` and the sector rule in `cross_reference`; `summary` is a sector-only duty with no DPDP or GDPR clause behind it, which belongs under 'Sector obligations' in summary.md, not in report.json; `either` accepts both. An item that should land in the summary but is filed as a report finding counts as found and misfiled. 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. `sources` and `unverified` record where every legal claim here comes from.", + "expected": [ + { "id": "P1", "file": "android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt", "issue": "The screen asks for two references, but prepareSuggestions uploads the whole address book with 90-day call counts from the call log (READ_CONTACTS and READ_CALL_LOG in android/app/src/main/AndroidManifest.xml) to contact_graph through api/onboarding.py. jobs/collections_escalation.py texts up to five of those contacts from 15 days past due with the reference_nudge template in config/sms-templates.yaml, which tells them the borrower owes Tamarind, and jobs/collections_handoff.py sends five to the collections agency. The contacts never consented, and web/privacy-policy.html mentions only the two references the borrower chooses. The Directions bar lending apps from contact lists and call logs outright, and they apply today; the DPDP gap is a readiness gap until 13 May 2027.", "clause": "DPDP s.4(1), s.6(1)", "sector": "RBI (Digital Lending) Directions, 2025, para 12(i)", "lands": "report" }, + { "id": "P2", "file": "android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt", "issue": "PermissionsStep.kt asks for background location 'to keep your account secure', then schedules this worker, which uploads the phone's location every 6 hours for as long as the app is installed. services/field_visits.py uses the latest fix to route field collection agents to borrowers 30 or more days past due. web/privacy-policy.html says location is collected once, during KYC. The Directions allow one-time location access for onboarding or KYC only, and the use differs from the purpose the borrower was told.", "clause": "DPDP s.6(1), s.5(1)", "sector": "RBI (Digital Lending) Directions, 2025, para 12(i)", "lands": "report" }, + { "id": "P3", "file": "jobs/collections_handoff.py", "issue": "Recoverline recovers loans for Tamarind, which makes it a lending service provider under the Directions. The job sends b.*, every borrower column (email, date of birth, masked PAN, employer, income, AA handle, nominee), plus five alternative contacts from contact_graph, while Schedule A of docs/vendors/recoverline-agreement.md lists six fields. Clause 9 of the agreement lets the agency keep the data three years after the agreement ends and use it to improve collection models across its clients. The Directions let a service provider store only basic data such as name, address and contact details.", "clause": "DPDP s.8(2), s.8(7)", "sector": "RBI (Digital Lending) Directions, 2025, paras 4(v), 13(i)", "lands": "report" }, + { "id": "P4", "file": "services/kyc.py", "issue": "accept_ovd keeps the original front and back images of an Aadhaar card uploaded as an identity document (android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt asks for all four corners) and masks the number only in kyc_records. Nothing redacts the Aadhaar number on the stored images, which config/rbac.yaml lets the collections partner read. The KYC Direction requires the lender to ensure the customer redacts or blacks out the number when Aadhaar is submitted as proof of possession and authentication is not required. The images are KMS-encrypted, so a finding that asks only for encryption is partial.", "clause": "DPDP s.8(5), Rule 6(1)(a)", "sector": "RBI Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, para 16, Explanation 1", "lands": "either" }, + { "id": "P5", "file": "docs/runbooks/incident-response.md", "issue": "CERT-In hears within 24 hours of the security lead confirming a reportable incident. The CERT-In Directions require a report within 6 hours of noticing the incident or being told of it, FAQ 30 allows a partial first report, and FAQ 24 starts the clock at noticing, so waiting for confirmation breaks it twice. No DPDP or GDPR clause carries this duty: the Board and borrower rows already match Rule 7. It applies today.", "clause": "", "sector": "CERT-In Directions under IT Act s.70B(6), 28 Apr 2022, direction (ii); CERT-In FAQs, May 2022, Q24 and Q30", "lands": "summary" }, + { "id": "P6", "file": "deploy/backup.yaml", "issue": "cross_region_copy sends a daily copy of the whole tamarind-core database, kept 35 days, to ap-southeast-1 (Singapore); docs/vendors/register.md lists that region for the cloud provider. The Directions require all data to be stored only on servers in India, and data processed abroad to be deleted there and brought back within 24 hours. DPDP s.16 allows the transfer, because no country is restricted, so a report finding that treats it as a DPDP gap is misfiled. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 13(iv)", "lands": "summary" }, + { "id": "P7", "file": "web/privacy-policy.html", "issue": "The sharing section names 'trusted technology partners whose software in our app helps us detect fraud and measure app performance' without saying who they are. android/app/build.gradle bundles the Signalgrid and Pulsemetric SDKs, and docs/vendors/register.md says both collect device data directly from the phone. The Directions require the privacy policy to disclose the third parties allowed to collect personal information through the app. DPDP s.5(1) and Rule 3 need the data and purposes, which the policy itemises, not the names of recipients. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 14(ii)", "lands": "summary" }, + { "id": "P8", "file": "config/retention.yaml", "issue": "kyc_after_closure_days is 365, so jobs/retention.py purges KYC records and document images a year after a borrower's last loan closes. docs/retention-schedule.md, services/rights.py and the privacy policy all say five years, and the KYC Direction requires identification and address records to be kept at least five years after the business relationship ends. DPDP sets no such minimum and its one-year floor is met, so the early deletion breaches only the sector rule. It applies today.", "clause": "", "sector": "RBI Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, para 46(b)", "lands": "summary" }, + { "id": "P9", "file": "jobs/retention.py", "issue": "Rejected applicants' data is never deleted. The KYC purge joins loans, and the rejected-application delete uses USING loans with l.application_id = a.id, so it matches only applications that became loans, which are never rejected. An applicant with no loan keeps the application, KYC images, selfie, bank statements and contact_graph rows indefinitely, while docs/retention-schedule.md and the privacy policy promise deletion 90 days after the decision. Readiness gap until 13 May 2027.", "clause": "DPDP s.8(7)", "sector": null, "lands": "report" }, + { "id": "P10", "file": "jobs/offer_scoring.py", "issue": "Each month the job reads the stored account aggregator statements of every active and closed borrower, including those with offers_opt_in false, to set top-up limits. The consent in services/aa_client.py is a one-time fetch with a one-month data life for 'Assess your loan application'. Nothing deletes bank_statements, though docs/retention-schedule.md says jobs/retention.py ends them with the data life, and the privacy policy says offers use repayment history. Readiness gap until 13 May 2027.", "clause": "DPDP s.6(1), s.8(7)", "sector": null, "lands": "report" }, + { "id": "P11", "file": "services/aa_client.py", "issue": "When parsing fails, handle_fi_ready logs the whole decrypted account aggregator payload (account holder profile, balances and transactions) at WARNING. deploy/logging.yaml keeps the api-app stream 400 days and lets all of engineering read it. Readiness gap until 13 May 2027.", "clause": "DPDP s.8(5), Rule 6(1)(a)", "sector": null, "lands": "report" }, + { "id": "P12", "file": "android/app/build.gradle", "issue": "The live Verilane API key is compiled into the app through buildConfigField. docs/vendors/verilane-api.md says a live key can call every endpoint, including GET /verifications/{reference}, which returns past verifications with their extracted fields and image links, and that references are sequential. Anyone who unpacks the APK can pull every borrower's KYC record. The key value must not be copied into the report. Readiness gap until 13 May 2027.", "clause": "DPDP s.8(5), Rule 6(1)(b)", "sector": null, "lands": "report" }, + { "id": "P13", "file": "config/rbac.yaml", "issue": "collections_partner, Recoverline staff on the operations console, can read KYC document images, bank statements and contact_graph for every overdue account from one day past due. The agreement assigns accounts from 60 days past due and Schedule A lists six fields, and the in-house collections role sees only assigned accounts, with no KYC or statement access. Readiness gap until 13 May 2027.", "clause": "DPDP s.8(5), Rule 6(1)(b)", "sector": null, "lands": "report" } + ], + "should_not_flag": [ + { "file": "db/migrations/0006_repayment_mandates.sql", "why": "It holds the payment aggregator's token reference, the last four digits, network and expiry, and the e-mandate reference, so it must not be reported as stored card numbers." }, + { "file": "docs/runbooks/incident-response.md", "why": "The Board and borrower rows match s.8(6) and Rule 7: intimation without delay, the detailed report within 72 hours, and every affected borrower told what happened, the likely consequences and what to do. Only the CERT-In row is wrong (P5). The RBI row repeats the catalog's 6-hour entry and is not scored either way." }, + { "file": "deploy/logging.yaml", "why": "Logs are kept 400 days in ap-south-1 (Mumbai), over the one-year floor in Rule 6(1)(e) and Rule 8(3) and the 180 days in India that CERT-In direction (iv) requires, and the time source follows NPL and NIC as direction (i) requires. Its reader groups matter only for P11." }, + { "file": "jobs/collections_reminders.py", "why": "It texts only the borrower, on the borrower's own number, about their own loan, between 08:00 and 19:00. The messages to third parties are in jobs/collections_escalation.py (P1)." }, + { "file": "jobs/offer_push.py", "why": "It sends offers only to active borrowers whose offers_opt_in is true at send time, so withdrawal through api/settings.py takes effect on the next run. The gap is offer_scoring.py's use of statements (P10)." }, + { "file": "config/env.example", "why": "Placeholders for local development, not a hardcoded credential." }, + { "file": "tests/fixtures/borrowers.json", "why": "Addresses on the reserved example.test domain, all-zero phone numbers and X-masked identifiers are plainly synthetic." }, + { "file": "android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt", "why": "One burst of frames at onboarding for liveness, with the camera released afterwards and consent recorded in PermissionsStep.kt. The Directions allow one-time camera access for onboarding or KYC with the borrower's explicit consent (para 12(i))." }, + { "file": "services/rights.py", "why": "Refusing to erase KYC and loan records during their statutory period, and everything while a loan is still open, with the reason given, is allowed: s.8(7) and s.12(3) except data that a law or the specified purpose still requires, and the fintech sector guide says statutory retention overrides erasure. Its five-year statement is right; the purge that breaks it is P8." }, + { "file": "config/support.yaml", "why": "Grievances are resolved within 30 days, inside the 90-day ceiling in Rule 14(3), and the escalation note matches the 30-day trigger for the RBI Ombudsman in para 11(iv) of the Directions." }, + { "file": "config/app.yaml", "why": "Loans go only to residents of India aged 21 to 58 with a PAN and an Indian address, and the README says the company has no establishment, staff or customers outside India. GDPR does not reach it under Art. 3, and no borrower is a child under DPDP s.2(f) and s.9, so GDPR findings and children's findings are out of scope. The required credit_assessment consent covers only what the application needs, and offers are a separate optional consent, off by default." }, + { "file": "web/privacy-policy.html", "why": "The policy itemises the data and purposes and gives withdrawal, access, correction, erasure, nomination, the grievance officer and the route to the Board, as s.5(1) and Rule 3 require, so none of these may be reported as missing. Its unnamed SDK partners are P7, and its statements on references, location, the collections partner and offers are evidence for P1, P2, P3 and P10." } + ], + "known_gaps": [], + "sources": [ + { "claim": "Digital Lending Directions: para 3 applies to all NBFCs; para 4(v) defines a lending service provider to include recovery; para 11(iv) lets a borrower go to the RBI Ombudsman with no reply in 30 days; para 12(i) requires need-based collection with prior explicit consent and an audit trail, bars access to file and media, contact list, call logs and telephony functions, and allows one-time camera, microphone or location access for onboarding or KYC only; para 13(i) limits service providers to basic data; para 13(iv) requires storage only in India, with data processed abroad deleted there and brought back within 24 hours; para 14(ii) requires the privacy policy to disclose third parties collecting through the app.", "source": "Reserve Bank of India (Digital Lending) Directions, 2025, RBI/2025-26/36, 8 May 2025, https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=12848, read 11 Nov 2026. /dpdp-catalog has the instrument and paras 12-14." }, + { "claim": "CERT-In: direction (i) syncs clocks to NIC or NPL; direction (ii) reports incidents within 6 hours of noticing or being told; direction (iv) keeps ICT logs 180 days within Indian jurisdiction. FAQ 24 starts the clock at noticing; FAQ 25 makes any company a body corporate; FAQ 30 allows a partial first report and lists data breaches and leaks; FAQ 35 lets logs sit outside India if produced in reasonable time, so log location is not planted.", "source": "https://www.cert-in.org.in/PDF/CERT-In_Directions_70B_28.04.2022.pdf and https://www.cert-in.org.in/PDF/FAQs_on_CyberSecurityDirections_May2022.pdf, text extracted 11 Nov 2026. /dpdp-catalog has the 6-hour and 180-day rows." }, + { "claim": "KYC Direction: para 3(b)(xiv) covers NBFCs; para 16, Explanation 1, makes the lender ensure the customer redacts or blacks out the Aadhaar number where authentication is not required; para 46(b) keeps identification and address records at least five years after the business relationship ends.", "source": "Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=11566, read 11 Nov 2026. The fintech sector guide's 'KYC records: 5 years after business relationship ends' agrees." }, + { "claim": "DPDP sections and rules, commencement and the absence of a restricted country under s.16.", "source": "/dpdp-catalog, last verified 2 Nov 2026. The wording of s.2(f) (child) and s.12(3) (erasure unless retention is necessary for the purpose or a law) is from the Act and was not rechecked against the Gazette here." }, + { "claim": "GDPR territorial scope.", "source": "Regulation (EU) 2016/679, Art. 3, on EUR-Lex. /gdpr-catalog has no Art. 3 row." } + ], + "unverified": [ + "Sector guide and /dpdp-catalog: 6-hour reporting to RBI under the RBI Cybersecurity Directions, 2026. Not opened, so the key does not score the runbook's RBI row.", + "Sector guide: payment system data must be stored in India under the 2018 RBI circular, 'reinforced 2024'. The circular was not opened and the 2024 reinforcement was not traced. The key relies on para 13(iv) of the Digital Lending Directions instead.", + "Sector guide: 'Transaction records: 10 years (RBI for banks)'. Not traced. Para 46(a) of the KYC Direction says five years from the transaction. Not used.", + "Sector guide: 'Aadhaar Act restrictions apply; never store Aadhaar number in plain text'. The Aadhaar Act was not checked. The key relies on para 16 of the KYC Direction.", + "Card-on-file limits for merchants: the RBI tokenisation circulars were not found. The Payment Aggregator Master Direction of 15 Sep 2025, Annexure-1 clause 2.1, keeps card credentials off servers the merchant accesses. The key makes no card-storage claim; the repayment_mandates item rests on the table's columns.", + "PMLA s.12 record-keeping: not opened. The key cites the KYC Direction only." + ] +} diff --git a/examples/tamarind-lend.run-sheet.md b/examples/tamarind-lend.run-sheet.md new file mode 100644 index 0000000..ca37052 --- /dev/null +++ b/examples/tamarind-lend.run-sheet.md @@ -0,0 +1,47 @@ +# Tamarind run sheet + +`examples/tamarind-lend` measures what Vigil finds when a gap turns on a sector rule: RBI's Digital Lending Directions and KYC Direction, and CERT-In's directions. Its key is `examples/tamarind-lend.expected.json`. As with kadamba-fit, one session audits and never opens the key, and a second session scores. + +Four of the planted items are sector-only duties with no DPDP or GDPR clause behind them. A run can only place those correctly if its `/audit` sends such duties to a "Sector obligations" section of `summary.md` instead of filing a DPDP finding for them, so test a revision whose `AGENTS.md` and `/audit` step 7 say that. + +## Audit session: cold, never opens the key + +Export a committed revision, so uncommitted skill text can't help the audit. From the repo root, with `` set to the revision under test: + +```bash +BLIND="$HOME/vigil-blind-tamarind" +mkdir -p "$BLIND/vigil" "$BLIND/tamarind-lend" +git archive | tar -x -C "$BLIND/vigil" +git archive examples/tamarind-lend | tar -x --strip-components=2 -C "$BLIND/tamarind-lend" +rm -rf "$BLIND"/vigil/examples/tamarind-lend* "$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/tamarind-lend`. +2. Run `/onboard` with company id `tamarind-lend`. 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` and `summary.md` with the key. + +- **Blindness.** Search the transcript for `expected.json`, `run-sheet` and `tamarind-lend.`. A hit in anything the audit session read voids the run. +- **Each `expected` item.** Found: the finding or summary entry names the mechanism, including the other files the issue text points to. Partial: right topic, mechanism missing or wrong. Missed: neither. +- **Landing.** For `lands: report`, the item should be a finding in `report.json`. For `lands: summary`, it should be under "Sector obligations" in `summary.md`; a `report.json` finding standing in for it counts as found but misfiled. For `lands: either`, both places are right. +- **Sector rule.** Record whether the finding's `cross_reference` or the summary entry names the instrument in the item's `sector` field, at instrument level. Record any instrument the run names that the catalog, the fintech guide and the key's `sources` do not. +- **Citation.** For report items, right if the finding or its `cross_reference` cites a key clause at section, rule or article level. +- **Timing.** Count DPDP findings that read as current violations instead of readiness gaps, and sector duties described as starting in 2027 instead of applying today. +- **Each `should_not_flag` item.** Violated if a finding or summary entry 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. GDPR findings are out of scope for this company. +- **Values.** Count files that copy a credential or identifier value from the app, including the Verilane key in `android/app/build.gradle`. + +Report found, partial and missed out of the `expected` count, misfiled items, should-not-flag violations out of its count, and the other tallies. Commit the score with the audit output under `examples/tamarind-lend.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 + +None yet. diff --git a/examples/tamarind-lend/README.md b/examples/tamarind-lend/README.md new file mode 100644 index 0000000..144dfb7 --- /dev/null +++ b/examples/tamarind-lend/README.md @@ -0,0 +1,30 @@ +# Tamarind (synthetic) + +A made-up digital lending app. Salaried borrowers in India apply for small personal loans on an Android app, complete KYC, share bank statements through an account aggregator and repay by card, UPI Autopay or e-mandate. Overdue accounts go to an in-house collections team and then to an outside collections agency. + +Nothing here is real. There are no real people, personal data, credentials or companies, every domain is reserved for documentation, every identifier-shaped value is deliberately invalid, and the code does not run. + +## Company facts + +| | | +|---|---| +| Legal entity | Tamarind Finserv Private Limited, 7 Example Road, Pune 411001 | +| Sector | Fintech: a non-banking financial company registered with the Reserve Bank of India, lending through its own app | +| RBI registration | Synthetic placeholder N-00.00000 | +| Borrowers | About 350,000 applicants and borrowers, all resident in India | +| Products | Personal loans of Rs 5,000 to Rs 2,00,000 over 3 to 24 months, for applicants aged 21 to 58 with an Indian PAN and address | +| Outside India | No establishment, staff or customers outside India | +| Turnover | About Rs 180 crore | +| Significant Data Fiduciary | Not designated | +| Grievance redressal officer | grievance@tamarind.example, Pune | +| Data protection contact | privacy@tamarind.example, Pune. No DPO has been appointed | +| Vendors | Listed in `docs/vendors/register.md` | + +## Where things are + +- `android/`: the borrower app (manifest, Gradle build, onboarding and device code) +- `api/`, `services/`, `jobs/`: the backend +- `db/migrations/`: the schema +- `web/`: the public privacy policy +- `config/`, `deploy/`: runtime settings, roles, backups and logging +- `docs/`: retention schedule, incident runbook and vendor documents diff --git a/examples/tamarind-lend/android/app/build.gradle b/examples/tamarind-lend/android/app/build.gradle new file mode 100644 index 0000000..24e424e --- /dev/null +++ b/examples/tamarind-lend/android/app/build.gradle @@ -0,0 +1,43 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'example.tamarind' + compileSdk 35 + + defaultConfig { + applicationId 'example.tamarind' + minSdk 26 + targetSdk 35 + versionCode 412 + versionName '4.12.0' + + buildConfigField 'String', 'API_BASE', '"https://api.tamarind.example"' + buildConfigField 'String', 'VERILANE_BASE', '"https://api.verilane.example/v2"' + buildConfigField 'String', 'VERILANE_API_KEY', '"vl_live_FAKE0000000000000000EXAMPLE"' + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + buildFeatures { buildConfig true } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.13.1' + implementation 'androidx.work:work-runtime-ktx:2.9.1' + implementation 'androidx.camera:camera-camera2:1.3.4' + implementation 'com.google.android.gms:play-services-location:21.3.0' + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + + // Device risk signals for fraud checks at signup and disbursal + implementation 'example.signalgrid:device-sdk:4.2.0' + // Screen analytics and crash reports + implementation 'example.pulsemetric:analytics:2.8.1' +} diff --git a/examples/tamarind-lend/android/app/src/main/AndroidManifest.xml b/examples/tamarind-lend/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..31e7ca4 --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/tamarind-lend/android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt new file mode 100644 index 0000000..0a129d7 --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt @@ -0,0 +1,36 @@ +package example.tamarind.device + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.android.gms.location.LocationServices +import example.tamarind.api.DeviceApi +import example.tamarind.api.LocationRow +import kotlinx.coroutines.tasks.await +import java.time.Instant +import java.util.concurrent.TimeUnit + +class LocationSyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val location = LocationServices.getFusedLocationProviderClient(applicationContext).lastLocation.await() + ?: return Result.retry() + DeviceApi.get(applicationContext).postLocation( + LocationRow(location.latitude, location.longitude, location.accuracy, Instant.now()), + ) + return Result.success() + } + + companion object { + fun schedule(context: Context) { + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + "location-sync", + ExistingPeriodicWorkPolicy.KEEP, + PeriodicWorkRequestBuilder(6, TimeUnit.HOURS).build(), + ) + } + } +} diff --git a/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt new file mode 100644 index 0000000..97ae941 --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt @@ -0,0 +1,19 @@ +package example.tamarind.onboarding + +import example.tamarind.api.KycApi + +/** Onboarding step 5: identity and address proof. */ +class KycUploadStep(private val api: KycApi) { + + val documentTypes = listOf("aadhaar", "passport", "voter_id", "driving_licence") + + val instructions = mapOf( + "aadhaar" to "Take clear photos of the front and back of your Aadhaar card. Make sure all four corners are visible.", + "passport" to "Take clear photos of the first and last pages of your passport.", + "voter_id" to "Take clear photos of the front and back of your voter ID.", + "driving_licence" to "Take clear photos of the front and back of your driving licence.", + ) + + suspend fun submit(applicationId: Long, docType: String, front: ByteArray, back: ByteArray) = + api.uploadOvd(applicationId, docType, front, back) +} diff --git a/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/PermissionsStep.kt b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/PermissionsStep.kt new file mode 100644 index 0000000..8b2e13d --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/PermissionsStep.kt @@ -0,0 +1,34 @@ +package example.tamarind.onboarding + +import android.Manifest +import example.tamarind.api.OnboardingApi +import example.tamarind.device.LocationSyncWorker + +/** Onboarding step 2: ask for the device permissions the next steps use. */ +class PermissionsStep( + private val api: OnboardingApi, + private val prompter: PermissionPrompter, +) { + + private val rationale = linkedMapOf( + Manifest.permission.CAMERA to "Take a selfie so we can confirm it's you", + Manifest.permission.ACCESS_FINE_LOCATION to "Confirm your current city for KYC", + Manifest.permission.READ_CONTACTS to "Pick two people who know you as references", + Manifest.permission.READ_CALL_LOG to "Suggest references you talk to often", + Manifest.permission.ACCESS_BACKGROUND_LOCATION to "Keep your account secure", + ) + + suspend fun run(context: android.content.Context) { + for ((permission, reason) in rationale) { + val granted = prompter.request(permission, reason) + api.recordConsent(purpose = "permission:$permission", granted = granted, noticeVersion = NOTICE_VERSION) + } + if (prompter.isGranted(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) { + LocationSyncWorker.schedule(context) + } + } + + companion object { + const val NOTICE_VERSION = "2026-04" + } +} diff --git a/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt new file mode 100644 index 0000000..d0ce279 --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt @@ -0,0 +1,62 @@ +package example.tamarind.onboarding + +import android.content.ContentResolver +import android.provider.CallLog +import android.provider.ContactsContract +import example.tamarind.api.ContactRow +import example.tamarind.api.OnboardingApi +import java.util.concurrent.TimeUnit + +/** Onboarding step 4: "Add two references". */ +class ReferencesStep( + private val api: OnboardingApi, + private val resolver: ContentResolver, +) { + + // Upload once, then rank the picker by how often the borrower calls each number. + suspend fun prepareSuggestions(): List { + val calls = callCounts(days = 90) + val rows = addressBook().map { (name, phone) -> + ContactRow(name = name, phone = phone, callCount90d = calls[phone] ?: 0) + } + api.uploadContactGraph(rows) + return rows.sortedByDescending { it.callCount90d }.take(20) + } + + suspend fun onReferencesChosen(first: ContactRow, second: ContactRow) { + api.markReferences(listOf(first.phone, second.phone)) + } + + private fun addressBook(): List> { + val out = mutableListOf>() + resolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + arrayOf( + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.NUMBER, + ), + null, null, null, + )?.use { c -> + while (c.moveToNext()) out += c.getString(0) to normalise(c.getString(1)) + } + return out + } + + private fun callCounts(days: Int): Map { + val since = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days.toLong()) + val counts = mutableMapOf() + resolver.query( + CallLog.Calls.CONTENT_URI, + arrayOf(CallLog.Calls.NUMBER), + "${CallLog.Calls.DATE} > ?", arrayOf(since.toString()), null, + )?.use { c -> + while (c.moveToNext()) { + val number = normalise(c.getString(0)) + counts[number] = (counts[number] ?: 0) + 1 + } + } + return counts + } + + private fun normalise(raw: String): String = raw.filter { it.isDigit() || it == '+' } +} diff --git a/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt new file mode 100644 index 0000000..64bfbf8 --- /dev/null +++ b/examples/tamarind-lend/android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt @@ -0,0 +1,16 @@ +package example.tamarind.onboarding + +import example.tamarind.api.KycApi +import example.tamarind.camera.OneShotCamera + +/** Onboarding step 3: a liveness selfie, matched against the KYC document photo. */ +class SelfieStep( + private val api: KycApi, + private val camera: OneShotCamera, +) { + + suspend fun capture(applicationId: Long): Boolean { + val frames = camera.use { it.captureBurst(frames = 3) } // camera is released when the burst ends + return api.submitSelfie(applicationId, frames).livenessPassed + } +} diff --git a/examples/tamarind-lend/api/onboarding.py b/examples/tamarind-lend/api/onboarding.py new file mode 100644 index 0000000..bdae91d --- /dev/null +++ b/examples/tamarind-lend/api/onboarding.py @@ -0,0 +1,58 @@ +from tamarind import db +from tamarind.auth import current_borrower +from tamarind.clock import now + + +def record_consent(purpose, granted, notice_version): + """POST /v1/consents""" + borrower = current_borrower() + db.insert( + "consent_events", + borrower_id=borrower.id, + purpose=purpose, + granted=granted, + notice_version=notice_version, + ) + + +def upload_contact_graph(rows): + """POST /v1/onboarding/contacts""" + borrower = current_borrower() + db.execute("DELETE FROM contact_graph WHERE borrower_id = %s AND NOT is_reference", borrower.id) + db.insert_many( + "contact_graph", + [ + { + "borrower_id": borrower.id, + "contact_name": row.get("name"), + "contact_phone": row["phone"], + "call_count_90d": row.get("callCount90d", 0), + } + for row in rows + ], + ) + + +def mark_references(phones): + """POST /v1/onboarding/references""" + borrower = current_borrower() + if len(phones) != 2: + raise ValueError("choose exactly two references") + db.execute( + "UPDATE contact_graph SET is_reference = true WHERE borrower_id = %s AND contact_phone = ANY(%s)", + borrower.id, + phones, + ) + + +def post_location(row): + """POST /v1/device/location""" + borrower = current_borrower() + db.insert( + "device_locations", + borrower_id=borrower.id, + lat=round(row["lat"], 5), + lon=round(row["lon"], 5), + accuracy_m=row.get("accuracy"), + captured_at=row.get("capturedAt") or now(), + ) diff --git a/examples/tamarind-lend/api/settings.py b/examples/tamarind-lend/api/settings.py new file mode 100644 index 0000000..6ae2ad8 --- /dev/null +++ b/examples/tamarind-lend/api/settings.py @@ -0,0 +1,26 @@ +from tamarind import db +from tamarind.auth import current_borrower + +NOTICE_VERSION = "2026-04" + + +def get_preferences(): + """GET /v1/me/preferences""" + borrower = current_borrower() + return {"offers": borrower.offers_opt_in} + + +def set_offers(opt_in): + """PUT /v1/me/preferences/offers + + The app shows this as one switch on the Profile screen, the same switch used at signup. + """ + borrower = current_borrower() + db.execute("UPDATE borrowers SET offers_opt_in = %s WHERE id = %s", bool(opt_in), borrower.id) + db.insert( + "consent_events", + borrower_id=borrower.id, + purpose="offers", + granted=bool(opt_in), + notice_version=NOTICE_VERSION, + ) diff --git a/examples/tamarind-lend/config/app.yaml b/examples/tamarind-lend/config/app.yaml new file mode 100644 index 0000000..bc67667 --- /dev/null +++ b/examples/tamarind-lend/config/app.yaml @@ -0,0 +1,22 @@ +service: tamarind-api +region: ap-south-1 + +eligibility: + countries: [IN] + min_age: 21 + max_age: 58 + requires: [pan, indian_address, salaried] + +signup: + consents: + - purpose: credit_assessment + required: true + text: "I agree that Tamarind may check my identity, credit history and bank statements to assess this application." + - purpose: offers + required: false + default: false + text: "Send me offers for future loans." + +collections: + handoff_dpd: 60 + agency: recoverline diff --git a/examples/tamarind-lend/config/env.example b/examples/tamarind-lend/config/env.example new file mode 100644 index 0000000..f55b332 --- /dev/null +++ b/examples/tamarind-lend/config/env.example @@ -0,0 +1,4 @@ +APP_ENV=development +DATABASE_URL=postgres://app:changeme@localhost:5432/tamarind +LEDGERBRIDGE_CLIENT_ID=changeme +RECOVERLINE_SFTP_HOST=sftp.recoverline.example diff --git a/examples/tamarind-lend/config/rbac.yaml b/examples/tamarind-lend/config/rbac.yaml new file mode 100644 index 0000000..dffd087 --- /dev/null +++ b/examples/tamarind-lend/config/rbac.yaml @@ -0,0 +1,22 @@ +# Roles on the operations console (ops.tamarind.example). SSO for staff; partner users get +# named accounts provisioned by the vendor manager. +roles: + support_agent: + grants: [borrower:read, loan:read] + scope: assigned_tickets + + credit_analyst: + grants: [borrower:read, application:read, kyc_documents:read, bank_statements:read] + scope: assigned_applications + + collections_inhouse: + grants: [borrower:read, loan:read, field_plan:read] + scope: assigned_accounts + + collections_partner: + grants: [borrower:read, loan:read, kyc_documents:read, bank_statements:read, contact_graph:read] + scope: overdue_accounts + + risk_admin: + grants: ["*"] + scope: all diff --git a/examples/tamarind-lend/config/retention.yaml b/examples/tamarind-lend/config/retention.yaml new file mode 100644 index 0000000..99dab95 --- /dev/null +++ b/examples/tamarind-lend/config/retention.yaml @@ -0,0 +1,4 @@ +# Read by jobs/retention.py as settings.retention +kyc_after_closure_days: 365 +rejected_application_days: 90 +device_locations_days: 90 diff --git a/examples/tamarind-lend/config/sms-templates.yaml b/examples/tamarind-lend/config/sms-templates.yaml new file mode 100644 index 0000000..bcd3818 --- /dev/null +++ b/examples/tamarind-lend/config/sms-templates.yaml @@ -0,0 +1,3 @@ +emi_overdue: "Your Tamarind EMI of Rs {amount} for loan {loan_id} is {dpd} days overdue. Pay in the app or call 020-0000-0000." +reference_nudge: "{borrower_name} gave your number to Tamarind. Please ask them to call us at 020-0000-0000 about their account." +topup_offer: "You are pre-approved for a Tamarind top-up of up to Rs {limit}. Open the app to apply." diff --git a/examples/tamarind-lend/config/support.yaml b/examples/tamarind-lend/config/support.yaml new file mode 100644 index 0000000..cfa8ea1 --- /dev/null +++ b/examples/tamarind-lend/config/support.yaml @@ -0,0 +1,9 @@ +grievance: + officer: grievance@tamarind.example + acknowledge_hours: 24 + resolve_days: 30 + escalation_note: "If we have not resolved your complaint within 30 days, or you are not satisfied with our reply, you can complain to the RBI Ombudsman at cms.rbi.org.in." + +rights_requests: + channel: privacy@tamarind.example + resolve_days: 30 diff --git a/examples/tamarind-lend/db/migrations/0001_borrowers.sql b/examples/tamarind-lend/db/migrations/0001_borrowers.sql new file mode 100644 index 0000000..26a4bfc --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0001_borrowers.sql @@ -0,0 +1,26 @@ +CREATE TABLE borrowers ( + id BIGSERIAL PRIMARY KEY, + phone TEXT NOT NULL UNIQUE, + email TEXT, + full_name TEXT NOT NULL, + date_of_birth DATE NOT NULL, + address TEXT, + pan_masked TEXT, + employer TEXT, + monthly_income INTEGER, + offers_opt_in BOOLEAN NOT NULL DEFAULT false, + aa_handle TEXT, + nominee JSONB, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + closed_at TIMESTAMPTZ +); + +CREATE TABLE consent_events ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + purpose TEXT NOT NULL, + granted BOOLEAN NOT NULL, + notice_version TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/examples/tamarind-lend/db/migrations/0002_applications_and_loans.sql b/examples/tamarind-lend/db/migrations/0002_applications_and_loans.sql new file mode 100644 index 0000000..96a2c7d --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0002_applications_and_loans.sql @@ -0,0 +1,27 @@ +CREATE TABLE applications ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + amount INTEGER NOT NULL, + tenure_months INTEGER NOT NULL, + status TEXT NOT NULL, -- submitted | approved | rejected | withdrawn + decided_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE loans ( + id BIGSERIAL PRIMARY KEY, + application_id BIGINT NOT NULL REFERENCES applications(id), + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + principal INTEGER NOT NULL, + outstanding INTEGER NOT NULL, + dpd INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL, -- open | closed | written_off + disbursed_at TIMESTAMPTZ NOT NULL, + closed_at TIMESTAMPTZ +); + +CREATE TABLE preapproved_offers ( + borrower_id BIGINT PRIMARY KEY REFERENCES borrowers(id), + limit_inr INTEGER NOT NULL, + computed_at TIMESTAMPTZ NOT NULL +); diff --git a/examples/tamarind-lend/db/migrations/0003_kyc.sql b/examples/tamarind-lend/db/migrations/0003_kyc.sql new file mode 100644 index 0000000..74504d5 --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0003_kyc.sql @@ -0,0 +1,11 @@ +CREATE TABLE kyc_records ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id) ON DELETE CASCADE, + method TEXT NOT NULL, -- ovd_upload | digilocker + doc_type TEXT NOT NULL, -- aadhaar | passport | voter_id | driving_licence + doc_number_masked TEXT NOT NULL, + document_key TEXT, -- object storage prefix for the uploaded images + selfie_key TEXT, + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/examples/tamarind-lend/db/migrations/0004_account_aggregator.sql b/examples/tamarind-lend/db/migrations/0004_account_aggregator.sql new file mode 100644 index 0000000..75c8e11 --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0004_account_aggregator.sql @@ -0,0 +1,21 @@ +CREATE TABLE aa_consents ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + application_id BIGINT NOT NULL REFERENCES applications(id), + consent_handle TEXT NOT NULL UNIQUE, + fetch_type TEXT NOT NULL, + data_life_unit TEXT NOT NULL, + data_life_value INTEGER NOT NULL, + consent_expiry TIMESTAMPTZ NOT NULL, + status TEXT NOT NULL, -- requested | active | revoked | expired + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE bank_statements ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + aa_consent_id BIGINT NOT NULL REFERENCES aa_consents(id), + account_masked TEXT NOT NULL, + fi_payload JSONB NOT NULL, + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/examples/tamarind-lend/db/migrations/0005_device.sql b/examples/tamarind-lend/db/migrations/0005_device.sql new file mode 100644 index 0000000..d24c96c --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0005_device.sql @@ -0,0 +1,18 @@ +CREATE TABLE contact_graph ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + contact_name TEXT, + contact_phone TEXT NOT NULL, + call_count_90d INTEGER NOT NULL DEFAULT 0, + is_reference BOOLEAN NOT NULL DEFAULT false, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE device_locations ( + id BIGSERIAL PRIMARY KEY, + borrower_id BIGINT NOT NULL REFERENCES borrowers(id), + lat NUMERIC(8, 5) NOT NULL, + lon NUMERIC(8, 5) NOT NULL, + accuracy_m REAL, + captured_at TIMESTAMPTZ NOT NULL +); diff --git a/examples/tamarind-lend/db/migrations/0006_repayment_mandates.sql b/examples/tamarind-lend/db/migrations/0006_repayment_mandates.sql new file mode 100644 index 0000000..37f7fac --- /dev/null +++ b/examples/tamarind-lend/db/migrations/0006_repayment_mandates.sql @@ -0,0 +1,13 @@ +CREATE TABLE repayment_mandates ( + id BIGSERIAL PRIMARY KEY, + loan_id BIGINT NOT NULL REFERENCES loans(id), + method TEXT NOT NULL, -- card | upi_autopay | enach + pa_token_ref TEXT, -- reference to the payment aggregator's token for the card + card_last4 CHAR(4), + card_network TEXT, + expiry_month SMALLINT, + expiry_year SMALLINT, + umrn TEXT, -- e-mandate reference + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/examples/tamarind-lend/deploy/backup.yaml b/examples/tamarind-lend/deploy/backup.yaml new file mode 100644 index 0000000..4377ad7 --- /dev/null +++ b/examples/tamarind-lend/deploy/backup.yaml @@ -0,0 +1,27 @@ +database: tamarind-core +engine: postgres-16 + +snapshots: + every: 6h + keep: 35d + region: ap-south-1 + encryption: kms + +replicas: + - region: ap-south-2 + mode: streaming + purpose: failover + +cross_region_copy: + enabled: true + region: ap-southeast-1 + schedule: "daily 02:30 Asia/Kolkata" + keep: 35d + encryption: kms + purpose: disaster recovery if both India regions are lost + +object_storage: + bucket: tamarind-kyc-docs + region: ap-south-1 + versioning: true + replication: ap-south-2 diff --git a/examples/tamarind-lend/deploy/logging.yaml b/examples/tamarind-lend/deploy/logging.yaml new file mode 100644 index 0000000..975e98b --- /dev/null +++ b/examples/tamarind-lend/deploy/logging.yaml @@ -0,0 +1,23 @@ +sink: opensearch +region: ap-south-1 +time_source: ntp.tamarind.internal # stratum 2, synced to the NPL and NIC servers + +streams: + - name: api-access + level: INFO + retention: 400d + - name: api-app + level: INFO + retention: 400d + - name: auth-events + level: INFO + retention: 400d + - name: console-audit + level: INFO + retention: 400d + +readers: + - group: engineering + streams: [api-access, api-app] + - group: security + streams: ["*"] diff --git a/examples/tamarind-lend/docs/retention-schedule.md b/examples/tamarind-lend/docs/retention-schedule.md new file mode 100644 index 0000000..3797823 --- /dev/null +++ b/examples/tamarind-lend/docs/retention-schedule.md @@ -0,0 +1,14 @@ +# Retention schedule + +Owner: Compliance. Approved by the Risk Management Committee, April 2026. + +| Data | Kept for | Why | How it is deleted | +|---|---|---|---| +| KYC records and document images | 5 years after the borrower's last loan closes | RBI KYC Direction, record management | `jobs/retention.py` | +| Loan and repayment records | 5 years after each transaction | RBI KYC Direction, record management | Annual archive review | +| Rejected or withdrawn applications, with the applicant's KYC images, bank statements and references | 90 days after the decision | Fraud checks on repeat applications | `jobs/retention.py` | +| Bank statements from the account aggregator | Until the data life in the borrower's consent ends | The borrower's AA consent | `jobs/retention.py` | +| Device locations | 90 days | Fraud checks | `jobs/retention.py` | +| References | Until the loan closes | Collections | Erasure request, or account closure | +| Consent records | 8 years after account closure | Proof of consent | Annual archive review | +| Logs | 400 days | DPDP Rules and CERT-In Directions | Log platform retention | diff --git a/examples/tamarind-lend/docs/runbooks/incident-response.md b/examples/tamarind-lend/docs/runbooks/incident-response.md new file mode 100644 index 0000000..3ad6738 --- /dev/null +++ b/examples/tamarind-lend/docs/runbooks/incident-response.md @@ -0,0 +1,24 @@ +# Incident response + +Owner: Head of Information Security. Applies to every security incident, whether or not personal data is involved. + +## First hour + +1. Whoever notices the incident pages the security on-call. The on-call opens an incident channel, names an incident commander and writes the time the incident was noticed at the top of the incident document. +2. Contain: revoke credentials, isolate hosts, snapshot affected systems and preserve logs. +3. The security lead reviews the evidence and decides whether the incident is reportable. The decision and its time go in the incident document. + +## Regulators and people affected + +| Who | When | Owner | +|---|---|---| +| CERT-In | Within 24 hours of the security lead confirming a reportable incident, on the CERT-In incident form by email | Security lead | +| Reserve Bank of India | Within 6 hours of noticing, through the RBI reporting portal | Chief Compliance Officer | +| Data Protection Board of India | Without delay once a personal data breach is known, then the detailed report within 72 hours of becoming aware of it | Data protection contact | +| Every borrower affected | Without delay, by SMS and email: what happened, when, the likely consequences, what we are doing and what they can do, and who to contact | Data protection contact | +| Recoverline, Verilane, Ledgerbridge | Within 24 hours if their systems or our data in them are involved | Vendor manager | + +## Afterwards + +4. Keep the incident document, the evidence and every notice sent for at least five years. +5. Write a postmortem within 10 working days and track its actions to closure. diff --git a/examples/tamarind-lend/docs/vendors/recoverline-agreement.md b/examples/tamarind-lend/docs/vendors/recoverline-agreement.md new file mode 100644 index 0000000..9b5e1e7 --- /dev/null +++ b/examples/tamarind-lend/docs/vendors/recoverline-agreement.md @@ -0,0 +1,27 @@ +# Collections services agreement (extract) + +Between Tamarind Finserv Private Limited ("Tamarind") and Recoverline Services Private Limited ("Agency"), effective 1 July 2025. + +1. **Services.** The Agency contacts borrowers whose loans Tamarind assigns to it and collects overdue amounts on Tamarind's behalf, by phone, SMS and field visits, under Tamarind's fair practices code. + +2. **Assigned accounts.** Tamarind assigns accounts once they are 60 or more days past due. Assignment ends when the account is repaid, settled, written off or recalled by Tamarind. + +3. **Borrower Data.** Tamarind gives the Agency the Borrower Data in Schedule A for each assigned account. The Agency uses it only to provide the Services, except as clause 9 allows. + +4. **Security.** The Agency keeps Borrower Data on systems in India, encrypted at rest, and limits access to its staff working on Tamarind accounts. + +5. **Sub-contracting.** The Agency may not sub-contract field visits without Tamarind's written consent. + +6. **Incidents.** The Agency tells Tamarind of any security incident affecting Borrower Data within 24 hours of becoming aware of it. + +7. **Audit.** Tamarind and its regulators may audit the Agency's handling of Borrower Data on 15 days' notice. + +8. **Conduct.** The Agency contacts borrowers between 08:00 and 19:00 and never threatens or shames them. + +9. **Retention.** The Agency may keep Borrower Data for three years after the end of this agreement, for audit, dispute handling and the improvement of its collection models across its clients. After that it deletes it and certifies deletion on request. + +10. **Term.** Two years, renewing yearly unless either party gives 90 days' notice. + +## Schedule A: Borrower Data + +Loan ID, borrower name, mobile number, address, outstanding amount, days past due. diff --git a/examples/tamarind-lend/docs/vendors/register.md b/examples/tamarind-lend/docs/vendors/register.md new file mode 100644 index 0000000..146f021 --- /dev/null +++ b/examples/tamarind-lend/docs/vendors/register.md @@ -0,0 +1,11 @@ +# Vendor register + +| Vendor | Service | Data | Where | Contract | +|---|---|---|---|---| +| Stratacloud India Private Limited | Hosting, database, object storage, backups | Everything | ap-south-1, ap-south-2, ap-southeast-1 | Cloud agreement with data processing terms, 2025 | +| Verilane Technologies Private Limited | KYC: document OCR, liveness, name match | Document images, selfie, name, date of birth, address | India | Services agreement with processing terms, 2025. API keys issued per customer account | +| Ledgerbridge Account Aggregator Private Limited | RBI-licensed account aggregator | Consent requests; bank statements pass through encrypted | India | AA framework participation agreement | +| Recoverline Services Private Limited | Collections agency for accounts 60 or more days past due | See the agreement | India | `recoverline-agreement.md` | +| Signalgrid | Device risk SDK inside the Android app. Collects device model, OS version, device identifiers, IP address and network type directly from the phone and returns a risk score | Device and network data | India | SDK licence with data processing terms, 2024 | +| Pulsemetric | Analytics and crash-report SDK inside the Android app. Collects device identifiers, screen events and crash traces directly from the phone | Device and usage data | India | Online terms with a data processing addendum, 2025 | +| Bulk SMS gateway | Transactional SMS | Phone number, message text | India | Services agreement, 2024 | diff --git a/examples/tamarind-lend/docs/vendors/verilane-api.md b/examples/tamarind-lend/docs/vendors/verilane-api.md new file mode 100644 index 0000000..d529202 --- /dev/null +++ b/examples/tamarind-lend/docs/vendors/verilane-api.md @@ -0,0 +1,12 @@ +# Verilane API v2: integration notes + +Kept by the mobile and KYC teams. + +- Base URL: `https://api.verilane.example/v2` +- Authentication: `X-Api-Key` header. Keys are issued per customer account: one sandbox key and one live key. A live key can call every endpoint for that account. +- `POST /ocr` takes document images and returns name, date of birth, address and document number. +- `POST /liveness` takes selfie frames and returns a pass or fail and the best frame. +- `GET /verifications/{reference}` returns a past verification: the extracted fields and links to the stored images. References are sequential per account. +- Verilane keeps images for 30 days unless the customer deletes them sooner. +- The app calls `/ocr` and `/liveness` directly from the phone to save a round trip; the backend calls them for document re-checks. +- Key rotation: through the Verilane dashboard; the old key stops working at once. diff --git a/examples/tamarind-lend/jobs/collections_escalation.py b/examples/tamarind-lend/jobs/collections_escalation.py new file mode 100644 index 0000000..69523a5 --- /dev/null +++ b/examples/tamarind-lend/jobs/collections_escalation.py @@ -0,0 +1,34 @@ +from datetime import time + +from tamarind import db, sms + +SEND_WINDOW = (time(8, 0), time(19, 0)) +ESCALATE_FROM_DPD = 15 +NUDGES_PER_LOAN = 5 + + +def run(now): + """Every morning at 10:00 IST.""" + if not SEND_WINDOW[0] <= now.time() <= SEND_WINDOW[1]: + return + loans = db.query( + """ + SELECT l.id, l.borrower_id, b.full_name + FROM loans l JOIN borrowers b ON b.id = l.borrower_id + WHERE l.status = 'open' AND l.dpd >= %s + """, + ESCALATE_FROM_DPD, + ) + for loan in loans: + contacts = db.query( + """ + SELECT contact_phone FROM contact_graph + WHERE borrower_id = %s + ORDER BY is_reference DESC, call_count_90d DESC + LIMIT %s + """, + loan["borrower_id"], + NUDGES_PER_LOAN, + ) + for contact in contacts: + sms.send(contact["contact_phone"], template="reference_nudge", borrower_name=loan["full_name"]) diff --git a/examples/tamarind-lend/jobs/collections_handoff.py b/examples/tamarind-lend/jobs/collections_handoff.py new file mode 100644 index 0000000..77b031a --- /dev/null +++ b/examples/tamarind-lend/jobs/collections_handoff.py @@ -0,0 +1,32 @@ +import csv +import io + +from tamarind import db, settings +from tamarind.vendors import recoverline + + +def run(now): + """Weekly, Monday 06:00 IST: accounts past the hand-off threshold go to Recoverline.""" + rows = db.query( + """ + SELECT l.id AS loan_id, l.dpd, l.outstanding, l.principal, b.* + FROM loans l JOIN borrowers b ON b.id = l.borrower_id + WHERE l.status = 'open' AND l.dpd >= %s + """, + settings.collections["handoff_dpd"], + ) + for row in rows: + refs = db.query( + """ + SELECT contact_name, contact_phone FROM contact_graph + WHERE borrower_id = %s ORDER BY is_reference DESC, call_count_90d DESC LIMIT 5 + """, + row["id"], + ) + row["alt_contacts"] = "; ".join(f"{r['contact_name']} {r['contact_phone']}" for r in refs) + + out = io.StringIO() + writer = csv.DictWriter(out, fieldnames=list(rows[0].keys()) if rows else ["loan_id"]) + writer.writeheader() + writer.writerows(rows) + recoverline.sftp_put(f"inbound/tamarind-{now:%Y%m%d}.csv", out.getvalue().encode()) diff --git a/examples/tamarind-lend/jobs/collections_reminders.py b/examples/tamarind-lend/jobs/collections_reminders.py new file mode 100644 index 0000000..09c69f9 --- /dev/null +++ b/examples/tamarind-lend/jobs/collections_reminders.py @@ -0,0 +1,20 @@ +from datetime import time + +from tamarind import db, sms + +SEND_WINDOW = (time(8, 0), time(19, 0)) + + +def run(now): + """Every morning at 09:30 IST.""" + if not SEND_WINDOW[0] <= now.time() <= SEND_WINDOW[1]: + return + rows = db.query( + """ + SELECT l.id, l.dpd, l.outstanding, b.phone + FROM loans l JOIN borrowers b ON b.id = l.borrower_id + WHERE l.status = 'open' AND l.dpd BETWEEN 1 AND 59 + """ + ) + for row in rows: + sms.send(row["phone"], template="emi_overdue", loan_id=row["id"], amount=row["outstanding"], dpd=row["dpd"]) diff --git a/examples/tamarind-lend/jobs/offer_push.py b/examples/tamarind-lend/jobs/offer_push.py new file mode 100644 index 0000000..679049c --- /dev/null +++ b/examples/tamarind-lend/jobs/offer_push.py @@ -0,0 +1,14 @@ +from tamarind import db, push + + +def run(now): + """Monthly, 2nd at 11:00 IST.""" + rows = db.query( + """ + SELECT o.borrower_id, o.limit_inr + FROM preapproved_offers o JOIN borrowers b ON b.id = o.borrower_id + WHERE b.offers_opt_in AND b.status = 'active' + """ + ) + for row in rows: + push.send(row["borrower_id"], template="topup_offer", limit=row["limit_inr"]) diff --git a/examples/tamarind-lend/jobs/offer_scoring.py b/examples/tamarind-lend/jobs/offer_scoring.py new file mode 100644 index 0000000..f3ef202 --- /dev/null +++ b/examples/tamarind-lend/jobs/offer_scoring.py @@ -0,0 +1,23 @@ +from tamarind import db +from tamarind.models import topup + +from .features import statement_features + + +def run(now): + """Monthly, 1st at 03:00 IST: refresh pre-approved top-up limits.""" + borrowers = db.query("SELECT id FROM borrowers WHERE status IN ('active', 'closed')") + for borrower in borrowers: + statements = db.query( + "SELECT fi_payload, fetched_at FROM bank_statements WHERE borrower_id = %s ORDER BY fetched_at", + borrower["id"], + ) + if not statements: + continue + repayments = db.query( + "SELECT principal, dpd, status, disbursed_at, closed_at FROM loans WHERE borrower_id = %s", + borrower["id"], + ) + limit = topup.score(statement_features(statements), repayments) + if limit: + db.upsert("preapproved_offers", key="borrower_id", borrower_id=borrower["id"], limit_inr=limit, computed_at=now) diff --git a/examples/tamarind-lend/jobs/retention.py b/examples/tamarind-lend/jobs/retention.py new file mode 100644 index 0000000..c342c99 --- /dev/null +++ b/examples/tamarind-lend/jobs/retention.py @@ -0,0 +1,42 @@ +from datetime import timedelta + +from tamarind import db, settings, storage + + +def purge_kyc(borrower_id): + for row in db.query("SELECT document_key, selfie_key FROM kyc_records WHERE borrower_id = %s", borrower_id): + storage.delete_prefix(row["document_key"]) + if row["selfie_key"]: + storage.delete(row["selfie_key"]) + db.execute("DELETE FROM kyc_records WHERE borrower_id = %s", borrower_id) + + +def run(now): + """Nightly at 01:00 IST.""" + days = settings.retention + + closed_before = now - timedelta(days=days["kyc_after_closure_days"]) + finished = db.query( + """ + SELECT b.id FROM borrowers b JOIN loans l ON l.borrower_id = b.id + GROUP BY b.id + HAVING bool_and(l.status = 'closed') AND max(l.closed_at) < %s + """, + closed_before, + ) + for row in finished: + purge_kyc(row["id"]) + + decided_before = now - timedelta(days=days["rejected_application_days"]) + db.execute( + """ + DELETE FROM applications a USING loans l + WHERE l.application_id = a.id AND a.status = 'rejected' AND a.decided_at < %s + """, + decided_before, + ) + + db.execute( + "DELETE FROM device_locations WHERE captured_at < %s", + now - timedelta(days=days["device_locations_days"]), + ) diff --git a/examples/tamarind-lend/services/aa_client.py b/examples/tamarind-lend/services/aa_client.py new file mode 100644 index 0000000..0efb06f --- /dev/null +++ b/examples/tamarind-lend/services/aa_client.py @@ -0,0 +1,62 @@ +import json +import logging +from datetime import timedelta + +from tamarind import db +from tamarind.clock import now +from tamarind.vendors import ledgerbridge_aa as aa + +from .statements import StatementParseError, parse_statement + +log = logging.getLogger("tamarind.aa") + +CONSENT_TEMPLATE = { + "consentMode": "STORE", + "fetchType": "ONETIME", + "consentTypes": ["PROFILE", "SUMMARY", "TRANSACTIONS"], + "fiTypes": ["DEPOSIT"], + "Purpose": {"text": "Assess your loan application"}, + "FIDataRange": {"monthsBack": 6}, + "DataLife": {"unit": "MONTH", "value": 1}, +} + + +def request_consent(borrower, application): + """Called when the borrower taps 'Share bank statement' on the application.""" + expiry = now() + timedelta(days=7) + handle = aa.create_consent( + customer_vua=borrower.aa_handle, + template=CONSENT_TEMPLATE, + consent_expiry=expiry, + ) + db.insert( + "aa_consents", + borrower_id=borrower.id, + application_id=application.id, + consent_handle=handle, + fetch_type=CONSENT_TEMPLATE["fetchType"], + data_life_unit=CONSENT_TEMPLATE["DataLife"]["unit"], + data_life_value=CONSENT_TEMPLATE["DataLife"]["value"], + consent_expiry=expiry, + status="requested", + ) + return handle + + +def handle_fi_ready(notification): + """POST /v1/aa/notifications (FI ready)""" + consent = db.one("SELECT * FROM aa_consents WHERE consent_handle = %s", notification["consentHandle"]) + payload = aa.fetch_fi(notification["sessionId"]) # decrypted account profile, summary and transactions + try: + statement = parse_statement(payload) + except StatementParseError as exc: + log.warning("fi parse failed consent=%s err=%s payload=%s", consent["id"], exc, json.dumps(payload)) + raise + db.insert( + "bank_statements", + borrower_id=consent["borrower_id"], + aa_consent_id=consent["id"], + account_masked=statement.account_masked, + fi_payload=payload, + ) + db.execute("UPDATE aa_consents SET status = 'active' WHERE id = %s", consent["id"]) diff --git a/examples/tamarind-lend/services/field_visits.py b/examples/tamarind-lend/services/field_visits.py new file mode 100644 index 0000000..8752cb9 --- /dev/null +++ b/examples/tamarind-lend/services/field_visits.py @@ -0,0 +1,24 @@ +from tamarind import db +from tamarind.geo import depot_for, route_planner, zone_of + +VISIT_FROM_DPD = 30 + + +def visit_plan(zone): + """GET /v1/collections/field/plan?zone= : the morning route for an in-house field agent.""" + rows = db.query( + """ + SELECT l.id AS loan_id, l.outstanding, b.full_name, b.phone, b.address, + loc.lat, loc.lon, loc.captured_at + FROM loans l + JOIN borrowers b ON b.id = l.borrower_id + JOIN LATERAL ( + SELECT lat, lon, captured_at FROM device_locations d + WHERE d.borrower_id = b.id ORDER BY captured_at DESC LIMIT 1 + ) loc ON true + WHERE l.status = 'open' AND l.dpd >= %s + """, + VISIT_FROM_DPD, + ) + stops = [row for row in rows if zone_of(row["lat"], row["lon"]) == zone] + return route_planner.order(stops, start=depot_for(zone)) diff --git a/examples/tamarind-lend/services/kyc.py b/examples/tamarind-lend/services/kyc.py new file mode 100644 index 0000000..be667e3 --- /dev/null +++ b/examples/tamarind-lend/services/kyc.py @@ -0,0 +1,49 @@ +from tamarind import db, storage +from tamarind.clock import now +from tamarind.vendors import verilane + +from .matching import names_match + + +class KycMismatch(Exception): + pass + + +def mask_id(number): + digits = "".join(ch for ch in number if ch.isalnum()) + return "X" * (len(digits) - 4) + digits[-4:] + + +def accept_ovd(borrower, application_id, doc_type, front, back): + """POST /v1/kyc/ovd""" + ocr = verilane.ocr(doc_type=doc_type, front=front, back=back) # name, dob, address, number + if not names_match(ocr.name, borrower.full_name): + raise KycMismatch("name on document does not match the application") + + prefix = f"kyc-docs/{borrower.id}/{application_id}/{doc_type}" + storage.put(f"{prefix}-front.jpg", front, sse="aws:kms") + storage.put(f"{prefix}-back.jpg", back, sse="aws:kms") + + db.insert( + "kyc_records", + borrower_id=borrower.id, + method="ovd_upload", + doc_type=doc_type, + doc_number_masked=mask_id(ocr.number), + document_key=prefix, + ) + db.execute("UPDATE borrowers SET address = %s WHERE id = %s", ocr.address, borrower.id) + + +def accept_selfie(borrower, application_id, frames): + """POST /v1/kyc/selfie""" + result = verilane.liveness(frames) + key = f"kyc-docs/{borrower.id}/{application_id}/selfie.jpg" + storage.put(key, result.best_frame, sse="aws:kms") + db.execute( + "UPDATE kyc_records SET selfie_key = %s, verified_at = %s WHERE borrower_id = %s AND verified_at IS NULL", + key, + now() if result.passed else None, + borrower.id, + ) + return {"livenessPassed": result.passed} diff --git a/examples/tamarind-lend/services/rights.py b/examples/tamarind-lend/services/rights.py new file mode 100644 index 0000000..4fc14c9 --- /dev/null +++ b/examples/tamarind-lend/services/rights.py @@ -0,0 +1,65 @@ +from tamarind import db +from tamarind.clock import now +from tamarind.vendors import register + +EXPORT_TABLES = [ + "consent_events", + "applications", + "loans", + "kyc_records", + "aa_consents", + "bank_statements", + "contact_graph", + "device_locations", + "repayment_mandates", +] + +ERASE = ["contact_graph", "device_locations", "bank_statements", "aa_consents", "preapproved_offers"] + +RETAINED = { + "kyc_records": "Identity and address records are kept for five years after your last loan closes, as RBI's KYC rules require.", + "loans": "Loan and repayment records are kept for five years after each transaction, as RBI's KYC rules require.", + "consent_events": "Records of the consents you gave and withdrew are kept as proof of consent.", +} + + +class RightsError(Exception): + pass + + +def export(borrower): + """GET /v1/me/data""" + data = {"profile": db.one("SELECT * FROM borrowers WHERE id = %s", borrower.id)} + for table in EXPORT_TABLES: + column = "loan_id IN (SELECT id FROM loans WHERE borrower_id = %s)" if table == "repayment_mandates" else "borrower_id = %s" + data[table] = db.query(f"SELECT * FROM {table} WHERE {column}", borrower.id) + data["shared_with"] = register.recipients() + return data + + +def correct(borrower, changes): + """PATCH /v1/me""" + allowed = {"email", "address", "employer", "monthly_income"} + updates = {key: value for key, value in changes.items() if key in allowed} + if updates: + db.update("borrowers", borrower.id, **updates) + + +def erase(borrower): + """DELETE /v1/me""" + open_loans = db.scalar("SELECT count(*) FROM loans WHERE borrower_id = %s AND status = 'open'", borrower.id) + if open_loans: + raise RightsError("You have a loan that is still open. We can erase your data once it is repaid.") + for table in ERASE: + db.execute(f"DELETE FROM {table} WHERE borrower_id = %s", borrower.id) + db.execute( + "UPDATE borrowers SET email = NULL, offers_opt_in = false, status = 'closed', closed_at = %s WHERE id = %s", + now(), + borrower.id, + ) + return {"erased": ERASE, "retained": RETAINED} + + +def set_nominee(borrower, name, contact): + """PUT /v1/me/nominee""" + db.update("borrowers", borrower.id, nominee={"name": name, "contact": contact}) diff --git a/examples/tamarind-lend/tests/fixtures/borrowers.json b/examples/tamarind-lend/tests/fixtures/borrowers.json new file mode 100644 index 0000000..6284253 --- /dev/null +++ b/examples/tamarind-lend/tests/fixtures/borrowers.json @@ -0,0 +1,4 @@ +[ + { "full_name": "Test Borrower One", "email": "borrower-0001@example.test", "phone": "+910000000001", "date_of_birth": "1990-01-01", "pan_masked": "XXXXXX000X", "doc_number_masked": "XXXXXXXX0000", "employer": "Example Employer One" }, + { "full_name": "Test Borrower Two", "email": "borrower-0002@example.test", "phone": "+910000000002", "date_of_birth": "1990-01-02", "pan_masked": "XXXXXX000X", "doc_number_masked": "XXXXXXXX0000", "employer": "Example Employer Two" } +] diff --git a/examples/tamarind-lend/web/privacy-policy.html b/examples/tamarind-lend/web/privacy-policy.html new file mode 100644 index 0000000..2e9ea60 --- /dev/null +++ b/examples/tamarind-lend/web/privacy-policy.html @@ -0,0 +1,47 @@ + + + + + Tamarind privacy policy + + +
+

How Tamarind uses your personal data

+

Version 2026-04. Tamarind Finserv Private Limited, 7 Example Road, Pune 411001, is a non-banking financial company registered with the Reserve Bank of India. This policy covers the Tamarind app and website. It is available in English and Hindi.

+ +

What we collect, and why

+ + + + + + + + + + + +
DataWhy we use it
Name, date of birth, mobile number, email, address, PANTo open your account, check your identity and contact you about your loan
Identity document photos and a selfieKYC, as RBI's rules require
Employer and monthly incomeTo assess what you can repay
Credit history from credit information companiesTo assess your application
Bank statements you share through an account aggregatorTo assess this application
The two references you chooseTo verify your application
Your location, once, during KYCTo confirm the city you live in
Device model, device identifiers, IP address and network typeTo detect fraud and fix app crashes
Loan, repayment and mandate detailsTo service and collect your loan
+

We ask for your consent before we collect any of this, and you can say no to anything that is not needed for the loan. If you opt in, we also use your repayment history to send you offers for future loans.

+ +

Who we share it with

+

Our cloud provider, our KYC verification partner, the account aggregator you choose, credit information companies, our collections partner for overdue accounts, our SMS provider, and trusted technology partners whose software in our app helps us detect fraud and measure app performance. Each works under a contract that limits what it may do with your data. We share data with regulators and law enforcement when the law requires it.

+ +

How long we keep it

+

KYC and loan records for five years after your last loan closes, as RBI's rules require. Rejected applications for 90 days. Everything else for as long as we need it for the purpose above.

+ +

Your choices and rights

+
    +
  • Withdraw a consent at any time: Profile > Privacy in the app, as easily as you gave it. Withdrawing consent for offers stops them from the next cycle.
  • +
  • Get a summary of your data and who we have shared it with, correct or update it, or ask us to erase it: Profile > Privacy > My data, or write to privacy@tamarind.example. Some records must be kept for the periods above even if you ask us to erase them.
  • +
  • Nominate someone to exercise these rights if you die or cannot act: Profile > Privacy > Nominee.
  • +
+ +

Complaints

+

Our grievance redressal officer is at grievance@tamarind.example, 7 Example Road, Pune 411001. We reply within 30 days. If you are not satisfied, you can complain to the RBI Ombudsman at cms.rbi.org.in, and about your personal data to the Data Protection Board of India once you have used our grievance process.

+ +

Questions

+

Write to privacy@tamarind.example.

+
+ + From 94a2881c5934c121b869b6f2caa63bda2937e71d Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 12:56:50 +0530 Subject: [PATCH 2/4] Score the first blind run of tamarind-lend A fresh subagent audited an export of d335bd1 plus the uncommitted sector-obligations guardrail diff, and a second subagent scored it. 13 of 13 planted items found, 17 of 17 citation anchors exact. Three of the four sector-only duties landed under "Sector obligations"; the Singapore backup was filed as a DPDP s.16(2) finding. 5 of 12 should-not-flag items were violated, three of them likely key defects. The audit's injected git status showed this branch's commit subject, which qualifies the sector results; score.md says how. Co-Authored-By: Claude Opus 5.5 --- examples/tamarind-lend.run-1/report.json | 301 +++++++++++++++++++ examples/tamarind-lend.run-1/score-check.mjs | 110 +++++++ examples/tamarind-lend.run-1/score.md | 172 +++++++++++ examples/tamarind-lend.run-1/summary.md | 131 ++++++++ 4 files changed, 714 insertions(+) create mode 100644 examples/tamarind-lend.run-1/report.json create mode 100644 examples/tamarind-lend.run-1/score-check.mjs create mode 100644 examples/tamarind-lend.run-1/score.md create mode 100644 examples/tamarind-lend.run-1/summary.md diff --git a/examples/tamarind-lend.run-1/report.json b/examples/tamarind-lend.run-1/report.json new file mode 100644 index 0000000..0cc67d2 --- /dev/null +++ b/examples/tamarind-lend.run-1/report.json @@ -0,0 +1,301 @@ +{ + "assessment_id": "2026-11-11-tamarind-lend-full", + "company_id": "tamarind-lend", + "scope": ["DPDP"], + "started_at": "2026-11-11T12:26:00+05:30", + "completed_at": "2026-11-11T13:15:00+05:30", + "assessor": "Vigil on Claude Code (headless run)", + "target": { "repo": "tamarind-lend (local copy, not a git repository)" }, + "model": "claude-opus-5-5", + "vigil_version": "0.3.1", + "law_as_at": "2026-11-02", + "coverage": { "applicable": 21, "examined": 18 }, + "findings": [ + { + "id": "F-0001", + "severity": "critical", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(b)", + "cross_reference": "IT Act 2000 s.43A and SPDI Rules 2011 (reasonable security practices; in force until 13 May 2027). RBI Cybersecurity Directions 2026.", + "issue": "The Android build embeds Verilane's live API key as a BuildConfig field, so it ships in every APK and comes out with a decompiler. Per docs/vendors/verilane-api.md:6-9, a live key can call every endpoint for the account, including GET /verifications/{reference}, which returns the extracted name, date of birth, address and document number with links to the stored images, and references are sequential. Anyone holding the APK can walk through every borrower's KYC record. Readiness gap under DPDP: s.8(5) and Rule 6 apply from 13 May 2027; the exposure is live today under IT Act s.43A.", + "recommendation": "Rotate the live key now in the Verilane dashboard (the old key stops at once). Remove VERILANE_API_KEY and VERILANE_BASE from build.gradle, route /ocr and /liveness through the Tamarind backend, and ask Verilane for endpoint-scoped or short-lived credentials. Get Verilane's access logs for /verifications and treat unexplained reads as a personal data breach (/dpdp-breach).", + "file": "android/app/build.gradle", + "line": 19, + "status": "open", + "found_at": "2026-11-11T12:40:00+05:30" + }, + { + "id": "F-0002", + "severity": "critical", + "framework": "DPDP", + "clause": "s.4(1), s.5(1), s.6(1), Rule 3(b)", + "cross_reference": "RBI (Digital Lending) Directions 2025: need-based data collection with prior explicit consent (para 12 in the May 2025 text); RBI's digital lending rules also bar lending apps from reading contact lists and call logs. Applies: Tamarind is an NBFC lending through its own app. Verify paragraph numbers against RBI's current Master Direction.", + "issue": "To fill a two-reference picker, prepareSuggestions reads the whole address book and the last 90 days of the call log, and uploads every contact's name, number and call count before the borrower picks anyone (ReferencesStep.kt:17-24, api/onboarding.py:18-33). Non-reference rows stay in contact_graph and nothing deletes them. The notice lists only 'the two references you choose' (web/privacy-policy.html:20). Those contacts are Data Principals who got no notice and gave no consent, and no s.7 use covers them; across about 350,000 applicants the graph may also hold children's numbers (s.9). Readiness gap: these obligations apply from 13 May 2027.", + "recommendation": "Stop uploading the address book and call log. Use the Android contact picker so only the two chosen references leave the phone, drop READ_CALL_LOG and READ_CONTACTS from the manifest, and delete every non-reference row already in contact_graph. Say in the notice how references are used.", + "file": "android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt", + "line": 22, + "status": "open", + "found_at": "2026-11-11T12:42:00+05:30" + }, + { + "id": "F-0003", + "severity": "critical", + "framework": "DPDP", + "clause": "s.4(1), s.6(1)", + "cross_reference": "RBI fair practices and recovery-conduct rules for NBFCs and the Digital Lending Directions 2025 (might apply to contacting third parties about a borrower's dues; verify the current paragraphs).", + "issue": "From 15 days past due, every morning, the job sends the reference_nudge SMS to up to five numbers from contact_graph for each overdue loan, ordered by reference flag and then call count. Once the two references are used, the rest go to contacts the borrower never chose. The message names the borrower and asks the recipient to get them to call about their account (config/sms-templates.yaml:2), which tells third parties the borrower has a problem account; to non-references it also says the borrower gave their number, which is untrue. The query has no upper limit on days past due, so the messages keep going daily after the account moves to Recoverline. Neither the borrower nor the contacts were told of this purpose. Readiness gap: applies from 13 May 2027.", + "recommendation": "Stop the job. If references are contacted at all, limit it to the two chosen references, tell the borrower at onboarding, confirm contact details only without naming the account, and cap the frequency. Remove the reference_nudge template.", + "file": "jobs/collections_escalation.py", + "line": 34, + "status": "open", + "found_at": "2026-11-11T12:43:00+05:30" + }, + { + "id": "F-0004", + "severity": "critical", + "framework": "DPDP", + "clause": "s.5(1), s.6(1), Rule 3(b)", + "cross_reference": "RBI (Digital Lending) Directions 2025: one-time access to location for onboarding or KYC, with explicit consent, and need-based collection (para 12 in the May 2025 text). Applies. Verify paragraph numbers.", + "issue": "The permission step asks for background location with the rationale 'Keep your account secure' and, if granted, schedules LocationSyncWorker to post the phone's position to about 1 metre every 6 hours with no end (PermissionsStep.kt:18, 26-28; LocationSyncWorker.kt:28-33; api/onboarding.py:48-58). Nothing cancels the worker when a loan closes or the borrower asks for erasure. The notice says location is taken once, during KYC, to confirm the city (web/privacy-policy.html:21), and the KYC rationale asks for fine location to confirm a city. The actual use is the in-house field visit plan, which routes agents to each overdue borrower's latest position (services/field_visits.py:7-24). Consent given for a different stated purpose is not informed or specific under s.6(1). Readiness gap: applies from 13 May 2027.", + "recommendation": "Remove ACCESS_BACKGROUND_LOCATION and LocationSyncWorker, cancel the periodic work on installed apps, and take one coarse location at KYC as the notice says. Delete stored device_locations rows. Plan field visits from the address the borrower gave; if location is wanted for collections, ask for it separately with that purpose stated.", + "file": "android/app/src/main/java/example/tamarind/onboarding/PermissionsStep.kt", + "line": 18, + "status": "open", + "found_at": "2026-11-11T12:44:00+05:30" + }, + { + "id": "F-0005", + "severity": "high", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(a)", + "cross_reference": "Aadhaar Act 2016; RBI KYC Master Direction 2016 and PML Rules 2005: where Aadhaar authentication is not required, the customer redacts or blacks out the Aadhaar number. Applies; verify the current paragraph.", + "issue": "Aadhaar is accepted as an uploaded document, and the app asks for clear photos of the front and back with all four corners visible (KycUploadStep.kt:11), with no step to mask the number. accept_ovd stores both images as captured; only doc_number_masked in the database is masked. The full Aadhaar number therefore sits in the tamarind-kyc-docs bucket, which the credit_analyst and collections_partner console roles can read (config/rbac.yaml:9, 17). KMS encryption at rest does not limit who reads it through the console. Readiness gap: applies from 13 May 2027.", + "recommendation": "Mask the first eight digits before storage, either by asking the borrower to cover them or server-side from the OCR coordinates, and re-process existing Aadhaar images. Prefer DigiLocker, which kyc_records.method already allows.", + "file": "services/kyc.py", + "line": 24, + "status": "open", + "found_at": "2026-11-11T12:45:00+05:30" + }, + { + "id": "F-0006", + "severity": "high", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(a)", + "cross_reference": "IT Act 2000 s.43A and SPDI Rules 2011 (financial information is sensitive personal data; in force until 13 May 2027).", + "issue": "When a statement fails to parse, handle_fi_ready logs the whole decrypted account aggregator payload (account profile, summary and six months of transactions) at WARNING. It lands in the api-app stream, kept 400 days and readable by the whole engineering group (deploy/logging.yaml:9-10, 19-21), a far wider audience than the credit_analyst role that bank_statements:read is granted to. Readiness gap: applies from 13 May 2027.", + "recommendation": "Log the consent ID, the error and a payload hash only. Purge api-app entries that hold FI payloads, and add a log filter that drops account and transaction structures.", + "file": "services/aa_client.py", + "line": 53, + "status": "open", + "found_at": "2026-11-11T12:46:00+05:30" + }, + { + "id": "F-0007", + "severity": "high", + "framework": "DPDP", + "clause": "s.5(1), s.6(1), Rule 3(b)", + "cross_reference": "Account aggregator consent artefact: purpose 'Assess your loan application', one-time fetch (services/aa_client.py:13-21).", + "issue": "Every month offer_scoring reads all stored bank statements and loan history for every active and closed borrower and writes a pre-approved top-up limit, without checking offers_opt_in; only the push in jobs/offer_push.py checks it. The statements were fetched under an AA consent to assess one application, and the notice says only that opted-in borrowers' repayment history is used for offers (web/privacy-policy.html:25). Profiling borrowers who did not opt in, including former borrowers, from their bank data falls outside both. Readiness gap: applies from 13 May 2027.", + "recommendation": "Score only borrowers with offers_opt_in set, and only from repayment history, as the notice says. If bank data is wanted for top-ups, request a fresh AA consent for that purpose. Delete preapproved_offers rows for borrowers who have not opted in.", + "file": "jobs/offer_scoring.py", + "line": 9, + "status": "open", + "found_at": "2026-11-11T12:47:00+05:30" + }, + { + "id": "F-0008", + "severity": "high", + "framework": "DPDP", + "clause": "s.6(6), s.8(7), Rule 8", + "cross_reference": "Account aggregator consent artefact data life of 1 month (services/aa_client.py:20).", + "issue": "The retention schedule deletes bank statements when the data life in the borrower's AA consent ends, through jobs/retention.py (docs/retention-schedule.md:10). The consent template sets that data life to one month, but jobs/retention.py has no step for bank_statements or aa_consents, so full FI payloads are kept with no end. The code also has no handler for AA consent revocation or expiry: only the FI-ready notification is handled, and aa_consents.status never moves to revoked or expired. Readiness gap: applies from 13 May 2027.", + "recommendation": "Add a nightly purge of bank_statements past the consent's data life, keeping only the derived features the credit decision needs. Handle AA revoke and expiry notifications by stopping use and deleting the payload, and record the new status in aa_consents.", + "file": "jobs/retention.py", + "line": 14, + "status": "open", + "found_at": "2026-11-11T12:48:00+05:30" + }, + { + "id": "F-0009", + "severity": "high", + "framework": "DPDP", + "clause": "s.8(7), Rule 8", + "issue": "The schedule deletes rejected or withdrawn applications, with the applicant's KYC images, bank statements and references, 90 days after the decision (docs/retention-schedule.md:9). The job's DELETE joins applications to loans, and a rejected application never has a loan, so it deletes nothing. Withdrawn applications are not selected at all, and purge_kyc only reaches borrowers who had a loan. Rejected applicants' KYC images, selfies, bank statements and contact graphs are kept with no end. Readiness gap: applies from 13 May 2027.", + "recommendation": "Select applications with status rejected or withdrawn, decided more than 90 days ago and with no loan; for applicants with no other live application, delete kyc_records and the stored images, bank_statements, aa_consents, contact_graph and device_locations. Add a test with a rejected-applicant fixture.", + "file": "jobs/retention.py", + "line": 33, + "status": "open", + "found_at": "2026-11-11T12:49:00+05:30" + }, + { + "id": "F-0010", + "severity": "high", + "framework": "DPDP", + "clause": "s.6(1), s.8(2)", + "cross_reference": "RBI outsourcing and recovery-agent rules for NBFCs and the Digital Lending Directions 2025 on sharing borrower data with recovery agents (might apply; verify).", + "issue": "The weekly hand-off selects b.* and writes every borrowers column into the CSV sent to Recoverline: email, date of birth, masked PAN, employer, monthly income, AA handle, the nominee's name and contact, the offers consent flag and more, plus names and numbers of up to five contacts from contact_graph (lines 12, 19-26). Schedule A of the agreement covers six fields: loan ID, name, mobile, address, outstanding amount and days past due (docs/vendors/recoverline-agreement.md:25-27). Data sent beyond the contract is not processed under a valid contract (s.8(2)), and the nominee and contacts have no ground for this disclosure at all. Readiness gap: applies from 13 May 2027.", + "recommendation": "Select the Schedule A columns by name, drop alt_contacts, and ask Recoverline to delete the extra fields from past files and certify it.", + "file": "jobs/collections_handoff.py", + "line": 12, + "status": "open", + "found_at": "2026-11-11T12:50:00+05:30" + }, + { + "id": "F-0011", + "severity": "high", + "framework": "DPDP", + "clause": "s.8(2), s.8(7)", + "issue": "Clause 9 lets Recoverline keep Borrower Data for three years after the agreement ends and use it to improve its collection models across its clients. That use is Recoverline's own purpose, which makes it a Data Fiduciary for it with no ground from the borrowers, and the three-year tail conflicts with Tamarind's duty to have its processor erase data once the purpose is served (s.8(7)(b)). Nothing in the code or runbook tells Recoverline when an account is repaid, recalled or erased. Readiness gap: applies from 13 May 2027.", + "recommendation": "Amend clause 9: no use for Recoverline's own models, deletion within a short fixed period after each account's assignment ends, and deletion on Tamarind's instruction when a borrower's data is erased. Send recall and erasure notices from the hand-off job.", + "file": "docs/vendors/recoverline-agreement.md", + "line": 21, + "status": "open", + "found_at": "2026-11-11T12:51:00+05:30" + }, + { + "id": "F-0012", + "severity": "high", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(b)", + "issue": "The collections_partner role, given to agency staff through vendor-provisioned named accounts, can read KYC documents, bank statements and the contact graph for every overdue account, not only accounts assigned to the agency at 60 days past due (config/rbac.yaml:1-2, 16-18). The agreement gives the agency six fields per assigned account, and the in-house collections role has none of these grants. Readiness gap: applies from 13 May 2027.", + "recommendation": "Cut collections_partner to the Schedule A fields on accounts currently assigned to Recoverline, and review the console-audit stream for partner reads of KYC documents and statements.", + "file": "config/rbac.yaml", + "line": 17, + "status": "open", + "found_at": "2026-11-11T12:52:00+05:30" + }, + { + "id": "F-0013", + "severity": "high", + "framework": "DPDP", + "clause": "s.16(2)", + "cross_reference": "RBI (Digital Lending) Directions 2025: data stored on servers in India, and data processed abroad brought back within 24 hours (para 13 in the May 2025 text). Applies. RBI Storage of Payment System Data circular 2018 might apply to repayment mandate data; verify.", + "issue": "A full copy of the tamarind-core database goes to ap-southeast-1 every day and is kept 35 days; the file describes it as the fallback if both India regions are lost (deploy/backup.yaml:15-21, docs/vendors/register.md:5). No country is restricted under s.16(1) yet, but s.16(2) keeps in force any Indian law that restricts transfers abroad, and RBI's digital lending rules require borrower data to stay on servers in India. Readiness gap: s.16 applies from 13 May 2027; the RBI requirement applies today.", + "recommendation": "Stop the cross-region copy or point it at an India region, delete the copies held in ap-southeast-1, and record backup locations in the data map. Get sign-off against the RBI Directions before adding any region outside India.", + "file": "deploy/backup.yaml", + "line": 17, + "status": "open", + "found_at": "2026-11-11T12:53:00+05:30" + }, + { + "id": "F-0014", + "severity": "medium", + "framework": "DPDP", + "clause": "s.8(6), Rule 7(1)", + "issue": "The breach runbook intimates every borrower affected (docs/runbooks/incident-response.md:18). Tamarind also holds personal data of people who are not borrowers: rejected applicants, the contacts and call counts in contact_graph, references and nominees. s.8(6) and Rule 7(1) require intimation to each affected Data Principal. The rest of the runbook covers the Board's initial intimation, the 72-hour report and the contents Rule 7(1) lists. Readiness gap: applies from 13 May 2027.", + "recommendation": "Change the row to every affected Data Principal, say how applicants, references, contacts and nominees are reached (the numbers held), and record what was tried when someone cannot be reached.", + "file": "docs/runbooks/incident-response.md", + "line": 18, + "status": "open", + "found_at": "2026-11-11T12:54:00+05:30" + }, + { + "id": "F-0015", + "severity": "medium", + "framework": "DPDP", + "clause": "s.6(4), s.6(6)", + "issue": "The notice says any consent can be withdrawn at Profile > Privacy as easily as it was given (web/privacy-policy.html:35). The only withdrawal endpoint is the offers switch (api/settings.py:13-26). Contacts, call log and location consents were each given with one tap (PermissionsStep.kt:21-25), but no route withdraws them or stops use of what was already uploaded: revoking the Android permission stops new reads, while the stored contact graph and location history stay in use for collections. Readiness gap: applies from 13 May 2027.", + "recommendation": "Add a withdrawal toggle for each permission-based consent under Profile > Privacy. On withdrawal, write a consent_events row, cancel LocationSyncWorker, and delete the borrower's contact_graph and device_locations rows.", + "file": "api/settings.py", + "line": 13, + "status": "open", + "found_at": "2026-11-11T12:55:00+05:30" + }, + { + "id": "F-0016", + "severity": "medium", + "framework": "DPDP", + "clause": "s.8(7), s.12(3)", + "issue": "DELETE /v1/me refuses everything while any loan is open, including the contact graph and location history that no law requires Tamarind to keep (services/rights.py:50-52). When it does run, it keeps the AA handle, nominee, employer, income, applications and repayment mandates without listing them in the retained explanation it returns, and it sends nothing to Recoverline, Verilane, Signalgrid or Pulsemetric, so processors keep their copies (s.8(7)(b)). Readiness gap: applies from 13 May 2027.", + "recommendation": "While a loan is open, still erase data held only on consent (contact graph, location, statements past their data life). Return the full list of what is kept and the legal reason for each item. Send erasure instructions to every processor that received the borrower's data and log the confirmations.", + "file": "services/rights.py", + "line": 50, + "status": "open", + "found_at": "2026-11-11T12:56:00+05:30" + }, + { + "id": "F-0017", + "severity": "medium", + "framework": "DPDP", + "clause": "s.5(1), s.6(1), Rule 3(b)", + "issue": "Two SDKs collect directly from the phone: Signalgrid (device model, OS, device identifiers, IP address, network type) and Pulsemetric (device identifiers, screen events, crash traces) (android/app/build.gradle:39-42, docs/vendors/register.md:9-10). The signup consents cover credit_assessment and offers only (config/app.yaml:11-18), and no consent event is recorded for either SDK. The notice gives device data the purposes of detecting fraud and fixing crashes (web/privacy-policy.html:22) and does not mention screen analytics. Readiness gap: applies from 13 May 2027.", + "recommendation": "List screen analytics in the notice as its own purpose and start Pulsemetric analytics only after a separate opt-in recorded in consent_events. Record the consent relied on for Signalgrid's fraud checks.", + "file": "android/app/build.gradle", + "line": 42, + "status": "open", + "found_at": "2026-11-11T12:57:00+05:30" + }, + { + "id": "F-0018", + "severity": "medium", + "framework": "DPDP", + "clause": "s.8(2), s.8(5), Rule 6(1)(f)", + "issue": "The bulk SMS gateway receives borrower numbers, loan amounts and days past due, and borrower names in messages to contacts. It is not named in the vendor register, and its 2024 services agreement is the only listed contract without processing terms (docs/vendors/register.md:11). The payment aggregator whose card token reference is stored in repayment_mandates (db/migrations/0006_repayment_mandates.sql:5) is not in the register at all. Processing and security terms for both are not found in the reviewed material. Readiness gap: applies from 13 May 2027.", + "recommendation": "Name both vendors in the register, confirm whether each acts as Tamarind's processor, and put processing terms with security safeguards, breach notice and deletion clauses in place with the SMS gateway.", + "file": "docs/vendors/register.md", + "line": 11, + "status": "open", + "found_at": "2026-11-11T12:58:00+05:30" + }, + { + "id": "F-0019", + "severity": "low", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(b)", + "issue": "risk_admin holds every grant over all data (config/rbac.yaml:20-22). How many people hold the role is not in the reviewed material. Readiness gap: applies from 13 May 2027.", + "recommendation": "List the holders, split the role into the grants each job needs, and review its use in the console-audit stream every quarter.", + "file": "config/rbac.yaml", + "line": 21, + "status": "open", + "found_at": "2026-11-11T12:59:00+05:30" + }, + { + "id": "F-0020", + "severity": "low", + "framework": "DPDP", + "clause": "s.12(2), Rule 14(1)", + "issue": "PATCH /v1/me lets a borrower correct email, address, employer and monthly income only. Name, date of birth and mobile number have no correction route, while the notice offers correction of all data (web/privacy-policy.html:36). Readiness gap: applies from 13 May 2027.", + "recommendation": "Add a correction request for name, date of birth and mobile number that goes through re-verification (KYC or OTP) instead of a direct update.", + "file": "services/rights.py", + "line": 42, + "status": "open", + "found_at": "2026-11-11T13:00:00+05:30" + }, + { + "id": "F-0021", + "severity": "low", + "framework": "DPDP", + "clause": "s.11(1)(a)", + "issue": "The access export (GET /v1/me/data) covers the profile and nine tables but not preapproved_offers, the top-up limit Tamarind computes about the borrower every month. Readiness gap: applies from 13 May 2027.", + "recommendation": "Add preapproved_offers to EXPORT_TABLES and describe the monthly scoring in the summary returned.", + "file": "services/rights.py", + "line": 5, + "status": "open", + "found_at": "2026-11-11T13:01:00+05:30" + }, + { + "id": "F-0022", + "severity": "low", + "framework": "DPDP", + "clause": "s.13(3)", + "issue": "The escalation note in the grievance configuration, which reads as the text sent to complainants, points only to the RBI Ombudsman after 30 days (config/support.yaml:5). The published notice does name the Data Protection Board (web/privacy-policy.html:41), and a complainant who has used the grievance route may go to the Board (s.13(3)). Readiness gap: applies from 13 May 2027.", + "recommendation": "Add the Data Protection Board route for personal data complaints to the escalation note, as the notice already does.", + "file": "config/support.yaml", + "line": 5, + "status": "open", + "found_at": "2026-11-11T13:02:00+05:30" + }, + { + "id": "F-0023", + "severity": "low", + "framework": "DPDP", + "clause": "s.8(5), Rule 6(1)(a)", + "issue": "The test fixture holds borrower records with identifier-shaped values: full names, emails, Indian-format mobile numbers, dates of birth, masked PAN and document numbers. The README calls everything synthetic and the values look like placeholders (reserved example.test domain, zero-filled numbers), but this was not verified. Readiness gap: applies from 13 May 2027.", + "recommendation": "Keep fixtures visibly synthetic: generate them from a seeded generator with reserved ranges, and add a check that fails on numbers in live mobile ranges.", + "file": "tests/fixtures/borrowers.json", + "line": 2, + "status": "open", + "found_at": "2026-11-11T13:03:00+05:30" + } + ] +} diff --git a/examples/tamarind-lend.run-1/score-check.mjs b/examples/tamarind-lend.run-1/score-check.mjs new file mode 100644 index 0000000..f7f0512 --- /dev/null +++ b/examples/tamarind-lend.run-1/score-check.mjs @@ -0,0 +1,110 @@ +// Scoring checks for the tamarind-lend blind audit. Run from the repo root: +// node examples/tamarind-lend.run-1/score-check.mjs examples/tamarind-lend examples/tamarind-lend.expected.json +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const [appDir, keyFile] = process.argv.slice(2); +const dir = 'examples/tamarind-lend.run-1'; +const read = f => readFileSync(f, 'utf8'); +const r = JSON.parse(read(join(dir, 'report.json'))); +const summary = read(join(dir, 'summary.md')); +const key = JSON.parse(read(keyFile)); +const by = Object.fromEntries(r.findings.map(f => [f.id, f])); +for (const f of ['report.json', 'summary.md']) console.log(f, 'sha256', createHash('sha256').update(readFileSync(join(dir, f))).digest('hex')); + +// The scorer's judgement, recorded in score.md. Findings that cover each item; P6 is a stand-in (lands summary, filed in the report). +const map = { P1: ['F-0002', 'F-0003', 'F-0010'], P2: ['F-0004'], P3: ['F-0010', 'F-0011'], P4: ['F-0005'], P6: ['F-0013'], P9: ['F-0009'], P10: ['F-0007', 'F-0008'], P11: ['F-0006'], P12: ['F-0001'], P13: ['F-0012'] }; +// Entry number under "Sector obligations" in summary.md for each item that has one +const entryOf = { P5: 1, P7: 3, P8: 2 }; + +const sector = summary.split('## Sector obligations')[1].split('\n## ')[0]; +const entries = Object.fromEntries([...sector.matchAll(/^(\d+)\. (.*)$/gm)].map(m => [+m[1], m[2]])); +const norm = t => t.replace(/Section\s+/g, 's.').replace(/\s+/g, ' '); +const tokens = c => c.split(/[;,]/).map(x => x.replace(/^\s*DPDP\s+/, '').trim()).filter(Boolean); +const top = t => (t.match(/^(s\.\d+|Rule \d+)/) || [t])[0]; +// Token present and not followed by a digit, so s.1 does not match s.11 +const has = (text, tok) => { for (let i = text.indexOf(tok); i !== -1; i = text.indexOf(tok, i + 1)) if (!/[0-9]/.test(text[i + tok.length] ?? '')) return true; return false; }; +const instrument = s => /Digital Lending/.test(s) ? 'Digital Lending' : /KYC/.test(s) ? 'KYC' : /CERT-In/.test(s) ? 'CERT-In' : s; +// Top-level paragraph numbers in a key sector string: "paras 4(v), 13(i)" gives 4 and 13 +const paras = s => { const i = s.search(/\bparas? \d/); return i < 0 ? [] : [...s.slice(i).matchAll(/(?:paras? |, )(\d+)(?=[(,]|$)/g)].map(m => m[1]); }; +console.assert(paras('Directions, 2025, paras 4(v), 13(i)') + '' === '4,13' && paras('2025, para 16, Explanation 1') + '' === '16' && !paras('direction (ii)').length, 'paras parser'); + +console.log('--- citation anchors, report items with a clause (exact-or-deeper / section-rule level)'); +let exact = 0, level = 0, total = 0, right = 0, items = 0; +for (const e of key.expected.filter(e => e.clause && e.lands !== 'summary')) { + items++; + const ids = map[e.id] ?? []; + const text = norm(ids.map(id => `${by[id].clause}; ${by[id].cross_reference ?? ''}`).join(' ; ')); + const res = tokens(e.clause).map(t => { total++; const ex = has(text, t), lv = ex || has(text, top(t)); if (ex) exact++; if (lv) level++; return `${t}=${ex ? 'exact' : lv ? 'level' : 'MISS'}`; }); + if (res.some(x => !x.endsWith('MISS'))) right++; + const files = ids.map(id => by[id].file); + console.log(e.id, ids.join(',') || 'NONE', '|', res.join(' '), '| file', files.includes(e.file) ? 'match' : 'OTHER:' + files.join(',')); +} +console.log(`exact ${exact}/${total}, level ${level}/${total}, citation right ${right}/${items}`); +const mapped = new Set(Object.values(map).flat()); +console.log('mapped', mapped.size, '| other', r.findings.filter(f => !mapped.has(f.id)).map(f => f.id).join(',')); + +console.log('--- landing and sector rule (instrument named / top-level paragraph named)'); +for (const e of key.expected) { + const ids = map[e.id] ?? [], n = entryOf[e.id]; + const where = [ids.length ? 'report ' + ids.join(',') : '', n ? `summary entry ${n}` : ''].filter(Boolean).join(' + ') || 'NOWHERE'; + const verdict = e.lands === 'summary' ? (n ? (ids.length ? 'both' : 'right') : ids.length ? 'MISFILED' : 'missing') : e.lands === 'report' ? (ids.length ? 'right' : 'missing') : (ids.length || n ? 'right' : 'missing'); + let sec = 'n/a'; + if (e.sector) { + const text = ids.map(id => by[id].cross_reference ?? '').join(' ') + ' ' + (n ? entries[n] : ''); + const inst = instrument(e.sector), ps = paras(e.sector); + sec = `${inst}=${text.includes(inst) ? 'named' : 'NOT NAMED'}` + (ps.length ? ' paras ' + ps.map(p => `${p}=${new RegExp(`para[^\\d]{0,3}${p}(?!\\d)`).test(text) ? 'yes' : 'no'}`).join(',') : ''); + } + console.log(e.id, `lands ${e.lands}`, '|', where, '|', verdict, '|', sec); +} +for (const e of key.expected.filter(e => e.lands === 'summary')) { + const n = entryOf[e.id]; + console.log(e.id, e.file, '| entry names file:', n ? entries[n].includes(e.file) : '-', '| findings filed on file:', r.findings.filter(f => f.file === e.file).map(f => f.id).join(',') || '-', '| named in issue:', r.findings.filter(f => f.file !== e.file && f.issue.includes(e.file)).map(f => f.id).join(',') || '-'); +} +console.log('sector entries', Object.keys(entries).length, '| not in the scorer map:', Object.keys(entries).filter(k => !Object.values(entryOf).includes(+k)).join(',') || 'none'); + +console.log('--- should-not-flag: findings filed on the file / naming it / sector entries naming it'); +for (const s of key.should_not_flag) console.log(s.file, '| filed:', r.findings.filter(f => f.file === s.file).map(f => f.id).join(',') || '-', '| named in issue:', r.findings.filter(f => f.file !== s.file && f.issue.includes(s.file)).map(f => f.id).join(',') || '-', '| sector entries:', Object.entries(entries).filter(([, t]) => t.includes(s.file)).map(([k]) => k).join(',') || '-'); +console.log('GDPR findings:', r.findings.filter(f => f.framework !== 'DPDP').map(f => f.id).join(',') || 'none', '| findings citing s.9:', r.findings.filter(f => /s\.9\b/.test(f.clause)).map(f => f.id).join(',') || 'none', '| issues mentioning s.9:', r.findings.filter(f => /s\.9\)|s\.9\b/.test(f.issue)).map(f => f.id).join(',') || 'none'); + +console.log('--- paths'); +const isAbs = p => /^[A-Za-z]:/.test(p) || p.startsWith('/') || p.startsWith(String.fromCharCode(92)) || p.startsWith('../'); +const bad = r.findings.filter(f => !f.file || isAbs(f.file) || !existsSync(join(appDir, f.file)) || read(join(appDir, f.file)).split('\n').length < (f.line || 1)); +console.log('findings', r.findings.length, '| missing, absolute, missing file or line past end:', bad.map(f => `${f.id} ${f.file}:${f.line}`).join(', ') || 'none'); + +console.log('--- timing'); +console.log('DPDP findings with no 13 May 2027 timing in issue:', r.findings.filter(f => f.framework === 'DPDP' && !f.issue.includes('13 May 2027')).map(f => f.id).join(',') || 'none'); +console.log('cross_references mentioning 2027:', r.findings.filter(f => /2027/.test(f.cross_reference ?? '')).map(f => f.id).join(',') || 'none'); +console.log('sector entries mentioning 2027:', Object.entries(entries).filter(([, t]) => /2027/.test(t)).map(([k, t]) => `${k} (${t.match(/[^.]*2027[^.]*/)[0].trim()})`).join(' | ') || 'none'); + +console.log('--- values (names only, never the values)'); +// Every value is read from the app at run time, so this file holds none of them +const values = { + verilaneKey: read(join(appDir, 'android/app/build.gradle')).match(/VERILANE_API_KEY',\s*'"([^"]+)"/)[1], + envExampleUrl: read(join(appDir, 'config/env.example')).match(/DATABASE_URL=(\S+)/)[1], +}; +JSON.parse(read(join(appDir, 'tests/fixtures/borrowers.json'))).forEach((b, i) => Object.entries(b).forEach(([k, v]) => { if (typeof v === 'string') values[`fixture${i}.${k}`] = v; })); +const walk = d => readdirSync(d).flatMap(n => { const p = join(d, n); return statSync(p).isDirectory() ? walk(p) : [p]; }); +// The run folder, plus the run's gitignored outputs when this checkout still has them +const dirs = [dir, 'company-profile/tamarind-lend', `assessments/${r.assessment_id}`].filter(d => existsSync(d)); +const files = dirs.flatMap(walk); +let leaks = 0; +for (const f of files) { const t = read(f); const hits = Object.entries(values).filter(([, v]) => t.includes(v)).map(([k]) => k); if (hits.length) { leaks++; console.log('HIT', f, hits.join(',')); } } +console.log('values', Object.keys(values).length, '| dirs', dirs.join(', '), '| files checked', files.length, '| files with a value', leaks); + +console.log('--- clauses outside DPDP sections 1-44 and rules 1-23'); +const clauses = r.findings.map(f => f.clause).join(' '); +const secs = [...new Set([...clauses.matchAll(/s\.(\d+)/g)].map(m => +m[1]))].sort((a, b) => a - b); +const rules = [...new Set([...clauses.matchAll(/Rule (\d+)/g)].map(m => +m[1]))].sort((a, b) => a - b); +console.log('sections', secs.join(','), '| off range:', secs.filter(n => n < 1 || n > 44).join(',') || 'none'); +console.log('rules', rules.join(','), '| off range:', rules.filter(n => n < 1 || n > 23).join(',') || 'none'); +console.log('GDPR articles in clauses:', [...clauses.matchAll(/Art\.\s*\d+/g)].map(m => m[0]).join(',') || 'none'); + +console.log('--- instruments the run names, against the catalog / fintech guide / key sources'); +const catalog = read('.claude/skills/dpdp-catalog/SKILL.md'), guide = read('examples/sector-guides/fintech-banking.md'); +const sources = key.sources.map(s => `${s.claim} ${s.source}`).join(' '); +const runText = r.findings.map(f => f.cross_reference ?? '').join(' ') + ' ' + sector; +const instruments = { 'Digital Lending Directions': /Digital Lending/, 'KYC Direction': /KYC (Master )?Direction/i, 'CERT-In Directions': /CERT-In/, 'RBI Cybersecurity Directions 2026': /RBI Cybersecurity/, 'Payment System Data circular': /Payment System Data/, 'IT Act s.43A / SPDI Rules': /SPDI/, 'Aadhaar Act': /Aadhaar Act/, 'PML Rules / PMLA': /PML Rules|PMLA/, 'fair practices / recovery-agent rules': /fair practices|recovery-conduct|recovery-agent/i, 'outsourcing rules': /outsourcing/i, 'card-on-file tokenisation': /card-on-file/i, 'account aggregator framework': /account aggregator/i }; +for (const [name, re] of Object.entries(instruments)) if (re.test(runText)) console.log(name, '| catalog', re.test(catalog), '| guide', re.test(guide), '| key sources', re.test(sources)); +console.log('severity', JSON.stringify(r.findings.reduce((a, f) => (a[f.severity] = (a[f.severity] || 0) + 1, a), {}))); diff --git a/examples/tamarind-lend.run-1/score.md b/examples/tamarind-lend.run-1/score.md new file mode 100644 index 0000000..b2652e9 --- /dev/null +++ b/examples/tamarind-lend.run-1/score.md @@ -0,0 +1,172 @@ +# Score: blind audit of tamarind-lend + +Assessment `2026-11-11-tamarind-lend-full`, 23 findings and 6 sector entries. Scored on 11 November 2026 against `examples/tamarind-lend.expected.json` and the run sheet on branch `claude/epic-wiles-e7f118`. The key's sha256 is `81ff4d9384b738f4e652fbd5a6c2b2d696b16acd5a84bc63d594c8fd5e395e1b`. + +The scorer did not write the fixture or the report, and opened the key only for scoring. `report.json` has the same sha256 before and after scoring: `cbd59050e1414976235534aa444dea7298c41229cc4af5d756b4f25e0ad96543`. `summary.md` is `154b74bead5881efafa696517852f11ea7f2bfc9c9dd0bed015ca279f60889d9`. + +This folder holds the run's `report.json` and `summary.md`. There is no `findings.sarif` for this run. `score-check.mjs` reproduces the mechanical checks: + +```bash +node examples/tamarind-lend.run-1/score-check.mjs examples/tamarind-lend examples/tamarind-lend.expected.json +``` + +## The run + +- **Vigil under test.** Commit `d335bd1` on this branch, which adds the fixture on top of `731a3f9`, plus an uncommitted guardrail diff from the main checkout's working tree (base `f8ed9c4`, sha256 `a46e9fbe9974a8451c184d8bf9dfab2d357f8188b15ef308e956a85a18057cff`). The diff applied cleanly. It adds the clause validator, the "Sector obligations" routing in `/audit` step 7 and `AGENTS.md`, and a reviewer note at the top of each sector guide. The run tested guardrails that are not committed anywhere, and this worktree does not contain them. +- **Who audited.** A fresh general-purpose subagent of the session that wrote the fixture (Claude Opus 5.5), not a separate top-level session, because the bundled Claude Code CLI was not logged in. It started with no context from that session. It read Vigil and the app only from an export outside the repo: a `git archive` of `d335bd1` with keys and run sheets removed, then the guardrail diff applied. It read the skills as files, not through the Skill tool, so `/onboard`, `/audit` and `/add-dir` were never invoked. +- **Where it wrote.** The session's write-guard blocks writes outside this worktree, so the subagent wrote to the gitignored `company-profile/tamarind-lend/` and `assessments/2026-11-11-tamarind-lend-full/` here, then copied them into the export and ran the export's stricter validator: `npm run validate -- --strict`, 0 errors; `npm test` passed. 48 tool uses, about 17 minutes. +- **summary.md.** The harness refused the subagent's write ("Subagents should return findings as text, not write report files"). The fixture author saved the returned text verbatim. The scorer cannot check that it is verbatim, only that the sha256 matches the stated one and the copy in the assessment folder. +- **Re-validated here.** `npm run validate -- --strict` in this worktree, which lacks the guardrail's clause validator, checks 38 data files with 0 errors, including the run's outputs. + +## Result + +| Measure | Score | +|---|---| +| Expected items found | 13 of 13 | +| Partly found or missed | 0 | +| Misfiled | 1 of 4 sector-only items (P6) | +| Sector instrument named | 8 of 8 items with a sector rule | +| Sector paragraph named, top level | 4 of 7 items whose rule has one | +| Citation right, run-sheet rule | 9 of 9 report items with a clause | +| Citation anchors, exact or more specific | 17 of 17 | +| Citation anchors, at section or rule level | 17 of 17 | +| Should-not-flag violations, by the key | 5 of 12, 3 of them arguably key defects | +| Other findings: real unplanted gaps / disputed / false positive | 3 / 5 / 2 | +| DPDP findings that read as current violations | 0 of 23 | +| Sector duties described as starting in 2027 | 0 | +| Files containing a credential, fixture or config value | 0 of 12 | +| Findings with a missing path or a line past the end | 0 of 23 | +| Clauses outside DPDP sections 1-44 and rules 1-23 | None | +| Instruments outside the catalog, the fintech guide and the key's sources | 3 | + +## Blindness + +The fixture author checked the subagent's transcript; the scorer did not see it. Every tool call read only the export, apart from one `ls` of this worktree's `company-profile` and `assessments` folders, which listed only sample-co folders. No tool call opened the key or the run sheet. Both were deleted from disk for the run and restored afterwards with the same sha256. + +The harness injected this worktree's git status into the subagent's context. It named the two deleted files and showed the commit subject "Add tamarind-lend, a fintech fixture with sector-rule gaps". By the letter of the run sheet that is a hit: `expected.json`, `run-sheet` and `tamarind-lend.` were in what the session read. The rule exists to keep the key's contents from the auditor, and none of them reached it. File names say nothing about any item. + +The subject line is a real leak, and it bears on what this fixture measures. It tells the auditor that sector-rule gaps exist, though not which, where or under which instrument. The README already says Tamarind is an RBI-registered NBFC, and the catalog and the guardrail under test already send an NBFC audit to RBI and CERT-In duties, so the hint adds little the tools did not. **The leak qualifies the run; it does not void it.** P9 to P13 stand at full weight. Read the landing of P5 to P8 and the sector columns for P1 to P8 as a likely upper bound. + +The leak came from running the audit as a subagent whose working directory was a checkout holding the fixture. The run sheet's own setup, a new session started in the export, would not have shown it. For the next fixture, use a commit subject that says nothing about the planted issues. + +## Expected items + +| ID | Item | Where | Verdict | Sector rule named | Clause check | +|---|---|---|---|---|---| +| P1 | Address book and call log uploaded; contacts texted and sent to the agency | F-0002, F-0003, F-0010 | Found | Digital Lending Directions, para 12, contact and call-log bar, "Applies" (F-0002) | Both exact | +| P2 | Background location every 6 hours, used for field visits | F-0004 | Found. Filed on `PermissionsStep.kt`; names `LocationSyncWorker.kt:28-33` | Directions, para 12, one-time location | Both exact | +| P3 | Hand-off sends every column and five contacts; clause 9 keeps data three years | F-0010, F-0011 | Found | Directions named, hedged "might apply; verify". No lending service provider status, no basic-data rule, no paragraph | Both exact | +| P4 | Aadhaar number left on stored images | F-0005 | Found. Asks for masking, not only encryption | KYC Master Direction with the redaction rule itself, no paragraph | Both exact | +| P5 | CERT-In row: 24 hours from confirmation | Sector entry 1 | Found, right place | CERT-In Directions 2022 | No clause | +| P6 | Daily database copy to Singapore | F-0013 | Found, **misfiled** | Directions, para 13, "applies today" | No clause | +| P7 | Policy does not name SDK partners | Sector entry 3 | Found, right place | Directions, para 14 | No clause | +| P8 | KYC purged after one year, not five | Sector entry 2 | Found, right place | KYC Master Direction, no paragraph | No clause | +| P9 | Rejected-application purge matches no rows | F-0009 | Found. Names the schedule, not the policy | None expected | Exact | +| P10 | Top-up scoring reads every borrower's AA statements | F-0007, F-0008 | Found | None expected | Both exact | +| P11 | Decrypted AA payload logged at WARNING | F-0006 | Found | None expected | Both exact | +| P12 | Live Verilane key in the APK | F-0001 | Found. Key value not copied | None expected | Both exact | +| P13 | Agency console role reads KYC, statements and contacts | F-0012 | Found. Misreads "provisioned by the vendor manager" as vendor-provisioned | None expected | Both exact | + +Every report item points at the key's file except P2, whose finding names the key's file in its text. F-0010 serves both P1 (contacts sent to the agency) and P3. Spot checks of the line references inside the issue texts and sector entries all landed on the cited lines. + +## How the sector-only duties were handled + +The run followed the guardrail on purpose. Line 73 of the summary explains why the contact, call-log and location limits sit in F-0002 and F-0004 and localisation in F-0013: a DPDP clause carries each of them. That is right for P1 and P2 and wrong for P6. + +- **P5, CERT-In.** Entry 1 names both faults, the clock starting at the security lead's confirmation instead of noticing and 24 hours instead of 6, and points the fix at the noticed time the runbook already records (line 7). It does not mention FAQ 30's partial first report. No report finding stands in; F-0014 on the same file is a different point. +- **P6, Singapore backup.** F-0013 cites s.16(2) and says "the RBI requirement applies today". The run reads s.16(2) as carrying the RBI rule into DPDP. As the scorer reads the Act, s.16(2) is a savings clause: it keeps stricter Indian transfer laws in force but places no duty of its own on a Data Fiduciary, so breaking the RBI rule does not break s.16(2). The key cannot settle this by citation, because its sources and the catalog give s.16 without its sub-sections, and the scorer's reading was not checked against the Gazette. It supports the key's "misfiled". The run flagged its own s.16(2) wording as unverified. F-0013 also adds Rs 50 crore to the per-finding penalty under a provision that may carry none. +- **P7, privacy policy.** Entry 3 quotes "trusted technology partners" and names Signalgrid and Pulsemetric among the parties to disclose. It states the rule as naming every third party that receives borrower data; the key's source says third parties allowed to collect personal information through the app. So it over-asks (cloud, bureaus, agency), and the entry does not cite `android/app/build.gradle` or `docs/vendors/register.md`. F-0017 cites both for the SDKs' direct collection. Scored found, as kadamba's P8 was found without one file. +- **P8, KYC retention.** Entry 2 has the mechanism: `config/retention.yaml:2` sets 365 days and `jobs/retention.py:18-28` purges records and images, against the five years the schedule, notice and erasure response promise. The posture note routes it to the summary too. It adds the PML Rules 2005, which none of the three sources name. The KYC Direction alone carries the duty, so the addition is unverified, not wrong. + +Three entries are not in the key: + +- **Entry 4, RBI Cybersecurity Directions 2026 and Recoverline's 24-hour notice.** Disputed. The RBI part is unsettled: the key did not open those Directions. The CERT-In part is wrong by the key's sources. Direction (ii) runs from noticing or being told, so Tamarind's 6 hours start when Recoverline tells it, and FAQ 25 makes Recoverline a body corporate with its own 6-hour duty. Shortening the vendor window is still sensible. +- **Entry 5, card-on-file rules and the expiry columns.** The key cannot settle it: its `unverified` list says the tokenisation circulars were not found. The migration does not say whether `expiry_month` and `expiry_year` hold the card's expiry or the token's. The entry is hedged and does not claim stored card numbers, so it does not violate the should-not-flag note. +- **Entry 6, IT Act s.43A and SPDI Rules until 13 May 2027.** Matches the catalog. It is a framing note, not a gap: it marks F-0001, F-0005, F-0006 and F-0012 as current exposures. The catalog lists the instrument without its definitions, so calling bank data and the liveness selfie sensitive rests on the run's reading. + +Instruments the run names that the catalog, the fintech guide and the key's sources do not: the PML Rules 2005 (F-0005, entry 2), RBI fair practices and recovery-agent rules (F-0003, F-0010) and RBI card-on-file tokenisation rules (entry 5). The KYC Direction is missing from the catalog but is in the guide and the key's sources. + +## Should-not-flag + +Scored by aspect, as for kadamba, because `services/rights.py` carries three findings on different points. + +| File | Verdict | +|---|---| +| `db/migrations/0006_repayment_mandates.sql` | Clean on the key's point: nothing reports stored card numbers. F-0018 names it to show a payment aggregator exists. Entry 5 is covered above | +| `docs/runbooks/incident-response.md` | **Violated** by F-0014, which faults the borrower row. Key defect | +| `deploy/logging.yaml` | Clean. F-0006 names its readers and retention for P11 | +| `jobs/collections_reminders.py` | Clean, never mentioned | +| `jobs/offer_push.py` | Clean. F-0007 credits its opt-in check | +| `config/env.example` | Clean, never mentioned | +| `tests/fixtures/borrowers.json` | **Violated** by F-0023. Run error | +| `.../onboarding/SelfieStep.kt` | Clean, never mentioned | +| `services/rights.py` (erasure) | **Violated** by F-0016's first point, refusal while a loan is open. Key defect in part. F-0020 and F-0021 are other aspects | +| `config/support.yaml` | **Violated** by F-0022, which faults the escalation note. Run error | +| `config/app.yaml` | Clean. No GDPR finding and no s.9 finding. F-0002 says the contact graph may hold children's numbers, which is about contacts, not borrowers | +| `web/privacy-policy.html` | **Violated** by F-0017's claim that the notice leaves out screen analytics. Key defect. The other mentions are evidence for P1, P2 and P10 or credit the notice | + +Three of the five look like key defects: + +- **F-0014.** s.8(6) and Rule 7(1) require intimation to each affected Data Principal. Tamarind also holds rejected applicants (kept indefinitely, P9), every address book contact (P1), references and nominees. "Every borrower affected" leaves them all out. Proposed key change: limit the runbook note to the Board row and the content of the notice, and add the borrower-only row to `known_gaps`. +- **F-0016.** The key's own reason ties refusal to "data that a law or the specified purpose still requires". The notice gives non-reference contacts no purpose beyond picking two references and location none beyond confirming the city at KYC, so an open loan does not need them under s.12(3). Proposed key change: replace "and everything while a loan is still open" with "and data the open loan still needs", and add consent-only data kept while a loan is open to `known_gaps`. F-0016's other two points are real unplanted gaps: `erase()` keeps the nominee, employer, income, AA handle, applications and mandates without listing them in `RETAINED`, and it sends no erasure instruction to processors (s.8(7)(b)). +- **F-0017.** `docs/vendors/register.md:10` says Pulsemetric collects screen events, and `android/app/build.gradle:41` calls it screen analytics. The notice's data row (`web/privacy-policy.html:22`) lists device model, identifiers, IP address and network type; "measure app performance" appears only in the sharing paragraph. The notice does not itemise screen events, so the key's "itemises the data and purposes" is not quite true. Proposed key change: add Pulsemetric's screen events to `known_gaps`, or, in a later fixture, describe Pulsemetric as crash and performance data only. + +Two are run errors: + +- **F-0022.** s.13(3) makes the Data Principal exhaust grievance redressal before going to the Board. It puts no duty on Tamarind to name the Board in the escalation note, and the notice already names it, as F-0022 itself says. +- **F-0023.** README line 5 says every value is synthetic and invalid, and the finding admits the values look like placeholders. A s.8(5) finding on a test fixture with no evidence of real data is a false positive. It also adds Rs 250 crore to the per-finding penalty. + +## Other findings + +Ten findings fall outside the expected items. The five disputed ones are the should-not-flag violations above. + +| Verdict | Findings | +|---|---| +| Real unplanted gap, checked against the code | F-0015, F-0018, F-0021 | +| Disputed | F-0014, F-0016, F-0017 (key defects); F-0022, F-0023 (run errors) | +| False positive | F-0019, F-0020 | + +- **F-0015.** `api/settings.py` withdraws only offers. Permission consents are recorded (`PermissionsStep.kt:24`) but nothing withdraws them, and revoking the Android permission leaves the stored contact graph and locations in use for collections. The notice promises withdrawal of any consent (line 35). +- **F-0018.** The register gives processing terms for every vendor except the SMS gateway (line 11), and `pa_token_ref` shows a payment aggregator the register leaves out. The finding hedges correctly. +- **F-0021.** `EXPORT_TABLES` leaves out `preapproved_offers`, which `ERASE` includes. Minor. +- **F-0019.** A wildcard admin role with a console-audit stream is ordinary. The finding admits it does not know who holds the role: an open question, like kadamba's F-0040. +- **F-0020.** It says name, date of birth and mobile have no correction route. The notice offers correction by email to the privacy address (line 36), and `config/support.yaml:7-9` sets a 30-day rights channel. + +Two mapped findings also carry real unplanted points: F-0008 notes that nothing handles AA consent revocation or expiry (`aa_consents.status` never becomes revoked or expired), and F-0016's points above. + +## Timing and values + +All 23 findings are DPDP findings and every one says it is a readiness gap until 13 May 2027. No sector duty is described as starting in 2027. The three 2027 mentions in sector text (F-0001 and F-0006 cross-references, entry 6) give the date s.43A ends, which matches the catalog. Two cross-references hedge duties the key says apply today: F-0003 and F-0010 say "might apply". + +No file in this folder or in the run's gitignored outputs contains the Verilane key, the `env.example` database URL or any of the 14 fixture strings: 0 of 12 files. F-0001 describes the key without copying it. + +## The audit session's guess points + +| # | Point | Verdict | +|---|---|---| +| 1 | `scope` is `["DPDP"]`; the id keeps "full" | Right. The key agrees GDPR does not reach Tamarind. The id is cosmetic | +| 2 | Registered address from the README's entity line, listed as unknown | Right. The line names the entity and an address, not the registered office. Judgement call | +| 3 | `processes_children_data` removed and listed unknown | Defensible. The key rules out child borrowers, not child contacts, and no s.9 finding was filed. Real schema gap: one boolean cannot say "borrowers no, contacts unknown" | +| 4 | `processes_sensitive_data` true through the SPDI Rules | Right under the catalog. Schema gap: the field does not say whose category, and DPDP has none | +| 5 | Turnover in EUR left out | Right. Real schema gap, minor: there is no INR field | +| 6 | Data-map enums are GDPR-shaped | Real schema gap. `transfer_mechanism` is kadamba's point 11 again; `legal_obligation` and `special_category` as stand-ins are new cases | +| 7 | Posture has no readiness value | Real schema gap. Every DPDP status before 13 May 2027 is readiness, and the enum cannot say so | +| 8 | One penalty number in `posture.json` | Arithmetic right: 7 x Rs 250 crore + Rs 200 crore + 15 x Rs 50 crore is Rs 2,700 crore; one per entry is Rs 500 crore. Format gap: one field. The per-finding figure includes Rs 650 crore from F-0013, F-0019, F-0020, F-0022 and F-0023 | +| 9 | DPIA `is_necessary` left out, `proportionate` false, no sign-off | Right. `is_necessary` is ambiguous between the processing and the DPIA: a wording gap. No DPO, so no sign-off | +| 10 | Legal points from outside the catalog flagged "verify" | Right to flag. The key's sources confirm four: the contact and call-log bar and the one-time location rule (para 12(i)), Aadhaar redaction (KYC Direction para 16) and the paragraph numbers at top level. Card-on-file and s.16(2) stay unsettled. Real catalog gap: its Digital Lending row omits para 12(i)'s limits, and it has no KYC Direction row | +| 11 | Coverage counts are its own | Judgement call, by design. They add up: 11 sections and 10 rules, less Rules 10, 11 and 23, is 18 of 21 | +| 12 | Process slips: one `ls`, and a shell `cp` before an Edit | The `ls` showed only sample-co folders. The copy was harmless: both `data-map.json` copies have the same sha256 and both validators pass. Real guard gap: the write-guard hooks Write, Edit and MultiEdit only, so a shell copy lands a file it never sees | + +## Comparison with kadamba-fit + +Kadamba-fit found 16 of 16 with 45 of 48 exact anchors and 3 of 13 should-not-flag violations, two of them arguably key defects. Tamarind found 13 of 13 with 17 of 17 exact anchors and 5 of 12 violations, three arguably key defects. The new measure is placement: three of four sector-only duties landed in the summary, and the fourth was filed under a DPDP savings clause. + +## Limits + +- One model wrote the fixture, ran the audit as a subagent of the fixture's session, and scored it, so shared blind spots could inflate recall. +- The commit subject leak qualifies the sector results, as set out under Blindness. +- The guardrails tested exist only as an uncommitted diff with a recorded sha256. The run cannot be reproduced from git until that diff is committed. +- The scorer did not see the transcript and relies on the fixture author's account of it, and of `summary.md` being verbatim. +- The s.16(2) reading and the card-on-file point rest on the scorer's and the run's knowledge of the texts. Neither was checked against an official source in this session. +- Tuning skills on this score ends tamarind-lend's blindness. Another blind measure needs a new fixture. +- The scorer did not commit. The fixture author committed this folder after rerunning `score-check.mjs` and checking the hashes above. The run's `posture.json`, `data-map.json`, DPIA draft and company profile stay in the gitignored assessment and profile folders. diff --git a/examples/tamarind-lend.run-1/summary.md b/examples/tamarind-lend.run-1/summary.md new file mode 100644 index 0000000..fe73958 --- /dev/null +++ b/examples/tamarind-lend.run-1/summary.md @@ -0,0 +1,131 @@ +# Privacy audit: Tamarind Finserv (full) + +> Tamarind is a synthetic fixture. Paths are relative to the root of the audited `tamarind-lend` codebase. + +| | | +|---|---| +| Assessment | `2026-11-11-tamarind-lend-full` | +| Scope | DPDP Act 2023 and Rules 2025. GDPR was checked for applicability under Art. 3 and does not apply | +| Target | `tamarind-lend`, a local copy with no git history, so no commit is recorded | +| Run | 11 Nov 2026, 12:26 to 13:15 IST, headless | +| Coverage | 18 of 21 applicable DPDP controls examined. Our own count, not comparable across runs | +| Overall posture | Non-compliant (DPDP readiness) | +| Findings | 4 critical, 9 high, 5 medium, 5 low | + +Most DPDP duties apply from 13 May 2027, so every finding in `report.json` is a readiness gap and says so. The RBI, CERT-In and IT Act duties under "Sector obligations" apply today, and four of the security findings are live exposures under IT Act s.43A now. + +## Why GDPR is out of scope + +Tamarind has no establishment, staff or customers outside India, and all 350,000 applicants and borrowers live in India (README). Nothing in the code offers the service to people in the EU or UK or monitors them there, so Art. 3(1) and 3(2) are not met. The GDPR code audit, Art. 13/14 document review and Art. 44 to 49 transfer review were therefore not run, and every GDPR posture value is `not_applicable`. The address book upload will pick up some foreign numbers, but collecting a contact is not targeting or monitoring that person under Art. 3(2). + +## Posture + +| Dimension | DPDP | Why | +|---|---|---| +| Governance | Partial | Not an SDF, so no DPO duty. Contact and grievance officer published. No DPIA before this audit | +| Lawful processing | Non-compliant | Address book and call log upload, SMS to contacts, background tracking for field visits, bank data reused for offers | +| Rights | Partial | All five routes exist; erasure stalls while a loan is open and never reaches processors | +| Security | Non-compliant | Live KYC vendor key in the APK, unredacted Aadhaar images, bank payloads in logs | +| Third parties | Non-compliant | Agency gets every borrower column plus contacts, may reuse data for three years; database copied abroad daily | +| Retention | Non-compliant | Bank statements never purged; the rejected-application purge deletes nothing | + +## Findings + +| ID | Severity | Clause | Issue | Where | +|---|---|---|---|---| +| F-0001 | Critical | s.8(5), Rule 6(1)(b) | Live Verilane key in the APK reads every past KYC verification | `android/app/build.gradle:19` | +| F-0002 | Critical | s.4(1), s.5(1), s.6(1), Rule 3(b) | Whole address book and 90-day call log uploaded to pick two references | `android/.../onboarding/ReferencesStep.kt:22` | +| F-0003 | Critical | s.4(1), s.6(1) | Daily SMS to up to five contacts naming the overdue borrower | `jobs/collections_escalation.py:34` | +| F-0004 | Critical | s.5(1), s.6(1), Rule 3(b) | Background location every 6 hours, sold as security, used to route field agents | `android/.../onboarding/PermissionsStep.kt:18` | +| F-0005 | High | s.8(5), Rule 6(1)(a) | Full Aadhaar numbers in stored document images | `services/kyc.py:24` | +| F-0006 | High | s.8(5), Rule 6(1)(a) | Decrypted bank payloads logged on parse failure | `services/aa_client.py:53` | +| F-0007 | High | s.5(1), s.6(1), Rule 3(b) | Top-up scoring profiles every borrower from bank data, opt-in or not | `jobs/offer_scoring.py:9` | +| F-0008 | High | s.6(6), s.8(7), Rule 8 | Bank statements kept past the 1-month AA data life; no revocation handling | `jobs/retention.py:14` | +| F-0009 | High | s.8(7), Rule 8 | Rejected-application purge matches no rows | `jobs/retention.py:33` | +| F-0010 | High | s.6(1), s.8(2) | Hand-off CSV sends every borrower column, the nominee and five contacts | `jobs/collections_handoff.py:12` | +| F-0011 | High | s.8(2), s.8(7) | Agency may keep data three years and train its own models on it | `docs/vendors/recoverline-agreement.md:21` | +| F-0012 | High | s.8(5), Rule 6(1)(b) | Agency console role reads KYC, statements and contacts for all overdue accounts | `config/rbac.yaml:17` | +| F-0013 | High | s.16(2) | Daily database copy kept 35 days in ap-southeast-1 | `deploy/backup.yaml:17` | +| F-0014 | Medium | s.8(6), Rule 7(1) | Breach runbook intimates borrowers only | `docs/runbooks/incident-response.md:18` | +| F-0015 | Medium | s.6(4), s.6(6) | No way to withdraw contacts, call log or location consent | `api/settings.py:13` | +| F-0016 | Medium | s.8(7), s.12(3) | Erasure blocked while a loan is open, incomplete, not sent to processors | `services/rights.py:50` | +| F-0017 | Medium | s.5(1), s.6(1), Rule 3(b) | Device SDKs run with no recorded consent; screen analytics not in the notice | `android/app/build.gradle:42` | +| F-0018 | Medium | s.8(2), s.8(5), Rule 6(1)(f) | SMS gateway and payment aggregator without processing terms in the material | `docs/vendors/register.md:11` | +| F-0019 | Low | s.8(5), Rule 6(1)(b) | `risk_admin` holds every grant | `config/rbac.yaml:21` | +| F-0020 | Low | s.12(2), Rule 14(1) | No correction route for name, date of birth or mobile | `services/rights.py:42` | +| F-0021 | Low | s.11(1)(a) | Access export leaves out top-up scores | `services/rights.py:5` | +| F-0022 | Low | s.13(3) | Grievance escalation note omits the Board | `config/support.yaml:5` | +| F-0023 | Low | s.8(5), Rule 6(1)(a) | Fixture with identifier-shaped values, synthetic claim not verified | `tests/fixtures/borrowers.json:2` | + +Detail and recommendations: [`report.json`](report.json). Data flows DF-01 to DF-20: [`data-map.json`](data-map.json). + +## Sector obligations + +These duties come from instruments with no DPDP clause carrying them, so they stay out of `report.json`. `details.json` lists RBI because Tamarind is an RBI-registered NBFC. + +1. **CERT-In Directions 2022, applies now.** Cyber incidents go to CERT-In within 6 hours of noticing them. The runbook allows 24 hours from the security lead's confirmation (`docs/runbooks/incident-response.md:15`), so the clock starts late and runs four times too long. Move it to 6 hours from the time noticed, which the runbook already records (line 7). +2. **RBI KYC Master Direction and PML Rules 2005, applies now.** KYC records must be kept at least five years after the relationship ends. The schedule, the notice and the erasure response all promise five years, but `config/retention.yaml:2` sets `kyc_after_closure_days: 365`, so `jobs/retention.py:18-28` deletes KYC records and images one year after the last loan closes. Set it to five years. +3. **RBI (Digital Lending) Directions 2025, privacy policy (para 14 in the May 2025 text), applies now.** The policy must name the third parties that receive borrower data. `web/privacy-policy.html:28` gives categories only ("our KYC verification partner", "trusted technology partners"). Name Stratacloud, Verilane, Ledgerbridge, Recoverline, Signalgrid, Pulsemetric, the SMS gateway, the credit bureaus and the payment aggregator. +4. **RBI Cybersecurity Directions 2026, might apply.** The runbook meets RBI's 6-hour report (line 16), but Recoverline only has to tell Tamarind within 24 hours (`docs/vendors/recoverline-agreement.md:15`). An incident at the agency could put the 6-hour RBI and CERT-In reports out of reach. Shorten the vendor notice period. +5. **RBI card-on-file tokenisation rules, might apply.** Entities other than card issuers and networks may keep the last four digits and the issuer name. `repayment_mandates` also stores expiry month and year (`db/migrations/0006_repayment_mandates.sql:7-8`). Check against RBI's current tokenisation circulars. +6. **IT Act 2000 s.43A and SPDI Rules 2011, apply until 13 May 2027.** Bank data and the liveness selfie are sensitive personal data under the SPDI Rules, so reasonable security practices and written consent are required today. F-0001, F-0005, F-0006 and F-0012 are current exposures under s.43A, not only DPDP readiness gaps. + +The RBI digital lending limits on contacts, call logs and location sit in the `cross_reference` of F-0002 and F-0004, and data localisation in F-0013, because a DPDP clause carries each of them. + +## Penalty exposure (maximum theoretical, prospective until 13 May 2027) + +| Schedule entry | Findings | Cap each | Subtotal | +|---|---|---|---| +| Security safeguards, s.8(5) | F-0001, F-0005, F-0006, F-0012, F-0018, F-0019, F-0023 | Rs 250 crore | Rs 1,750 crore | +| Breach intimation, s.8(6) | F-0014 | Rs 200 crore | Rs 200 crore | +| Any other provision | the other 15 findings | Rs 50 crore | Rs 750 crore | + +Per finding: **Rs 2,700 crore**. Per provision, each Schedule entry counted once: **Rs 500 crore**, the figure stored in `posture.json`. The per-finding total grows with how finely the audit split its findings. s.33(2) makes the Board weigh gravity, duration and mitigation, and it has no enforcement record yet. No GDPR exposure: GDPR does not apply. + +## DPIA screening + +Tamarind is not an SDF, so s.10(2)(c) does not require a DPIA. The screen still says run one: financial data on 350,000 people, credit and top-up profiling, systematic location monitoring, biometric KYC and third-party contact data at scale. A draft is at `company-profile/tamarind-lend/dpias/dpia-0001.json` with status `draft` and no sign-off, since nobody holds a DPO role. It rates four risks critical before mitigation. + +## Fix first + +1. **F-0001** today: rotate the Verilane key and pull Verilane's `/verifications` access logs. If anyone walked the references, this is a breach and the CERT-In and RBI 6-hour clocks are already running. +2. **F-0002 and F-0003**: stop the address book upload and the contact SMS job, then delete the non-reference rows. Together they reach every applicant's contacts, with no notice to anyone. +3. **F-0004**: ship an app update that drops background location and cancels the worker. +4. Sector items 1 and 2: both are one-line changes that apply today. + +## Coverage + +Applicable (21): s.4, s.5, s.6, s.7, s.8, s.9, s.11, s.12, s.13, s.14, s.16; Rules 3, 6, 7, 8, 9, 10, 11, 14, 15, 23. + +Examined (18): all of the above except Rule 10 (no flow collects children's data on purpose; the contact graph risk is noted under F-0002), Rule 11 (guardian consent for persons with disability) and Rule 23 (calling for information). + +Not applicable: s.10 and Rule 13 (not an SDF); Rule 4 (no registered Consent Manager is used; the account aggregator is an RBI entity, not a DPDP Consent Manager); Rules 5, 12, 16 and 17 to 22 (State processing, children's exemptions, research, Board and appeals); s.15 and s.17. + +## Onboarding follow-up + +From `/onboard` step 5: + +- **DPO.** None appointed and none required (not an SDF). Name the data protection contact as the person answering Data Principals' questions (s.8(9), Rule 9) with a reporting line to the board, as `/dpdp-dpo setup` describes. +- **Privacy policy.** Published, but it misstates location, references and analytics (F-0002, F-0004, F-0017) and names no third parties (sector item 3). Rewrite after the fixes land. +- **Consent.** Signup and offers consents are separate and unticked. Withdrawal exists for offers only (F-0015). +- **DPIA.** Draft written; finish it once the critical fixes are scoped. +- **PII scan.** Done in this audit. +- **Unknowns for a person to fill in** (`details.json`): turnover in euros (not needed while GDPR does not apply), whether the address given is the registered office, PCI DSS scope for card data, and whether children's data is processed (the contact graph makes it likely but unconfirmed). + +## Skipped or limited + +- GDPR steps 8 to 10 reduced to the Art. 3 applicability check above. +- Document review covered the privacy notice, retention schedule, runbook, vendor register and the Recoverline extract. The Stratacloud, Verilane, Signalgrid, Pulsemetric, SMS gateway and Ledgerbridge contracts are not in the material, so their terms were taken from the register's one-line descriptions. +- The `tamarind.*` modules the code imports (`db`, `storage`, `sms`, `vendors.register` and others) are not in the repository. Whether `bank_statements` is encrypted at rest, what `register.recipients()` returns and how the SDKs are initialised could not be checked. +- No live system, database, APK or log content was reviewed. No rights log, breach log or consent records were written; the mechanism audits do not write them. +- Rules 10, 11 and 23 not examined. + +## Points to verify + +- RBI paragraph numbers are from the May 2025 Digital Lending text as summarised in `/dpdp-catalog`. The bar on reading contacts and call logs, the one-time location rule, the Aadhaar redaction rule and the card-on-file limits come from outside the catalog and need checking against RBI's current Master Directions. +- s.16(2) is cited from the Act's text; the catalog summarises s.16 without its sub-sections. +- The report's `scope` lists DPDP only, because GDPR does not apply; the assessment id keeps `full` to match the command that was run. + +--- + +*Compliance tooling, not legal advice. Verify against official sources and consult qualified counsel before relying on any of this for a binding decision.* From 3f2245210989e232987ec1d328bfab0483d85750 Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 13:04:46 +0530 Subject: [PATCH 3/4] Correct the tamarind-lend key after its first scored run Narrow three should-not-flag notes the run showed were too broad (who gets the breach notice, erasure refused while a loan is open, screen events missing from the policy), name Verilane in P7, record eight real gaps the run found in known_gaps, and check the s.2(f), s.12(3) and s.16 wording against the Gazette. The app is unchanged; the run-1 score stands against the key at d335bd1. The run sheet also asks for neutral fixture commit subjects. Co-Authored-By: Claude Opus 5.5 --- examples/tamarind-lend.expected.json | 25 +++++++++++++++++-------- examples/tamarind-lend.run-1/score.md | 2 +- examples/tamarind-lend.run-sheet.md | 4 ++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/examples/tamarind-lend.expected.json b/examples/tamarind-lend.expected.json index 079c4dd..ee8b218 100644 --- a/examples/tamarind-lend.expected.json +++ b/examples/tamarind-lend.expected.json @@ -1,13 +1,13 @@ { - "description": "What an audit of examples/tamarind-lend should find and what it should leave alone. Follow examples/tamarind-lend.run-sheet.md: a cold session audits a copy outside the repo and never opens this file; a separate session scores the result. Tamarind is an RBI-registered NBFC lending through its own app, so the Digital Lending Directions, the KYC Direction and the CERT-In Directions apply to it today, while most DPDP duties apply from 13 May 2027. `lands` says where a correct audit puts each item: `report` is a finding in report.json citing the DPDP clause in `clause` and the sector rule in `cross_reference`; `summary` is a sector-only duty with no DPDP or GDPR clause behind it, which belongs under 'Sector obligations' in summary.md, not in report.json; `either` accepts both. An item that should land in the summary but is filed as a report finding counts as found and misfiled. 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. `sources` and `unverified` record where every legal claim here comes from.", + "description": "What an audit of examples/tamarind-lend should find and what it should leave alone. Follow examples/tamarind-lend.run-sheet.md: a cold session audits a copy outside the repo and never opens this file; a separate session scores the result. Tamarind is an RBI-registered NBFC lending through its own app, so the Digital Lending Directions, the KYC Direction and the CERT-In Directions apply to it today, while most DPDP duties apply from 13 May 2027. `lands` says where a correct audit puts each item: `report` is a finding in report.json citing the DPDP clause in `clause` and the sector rule in `cross_reference`; `summary` is a sector-only duty with no DPDP or GDPR clause behind it, which belongs under 'Sector obligations' in summary.md, not in report.json; `either` accepts both. An item that should land in the summary but is filed as a report finding counts as found and misfiled. 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. `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. `sources` and `unverified` record where every legal claim here comes from.", "expected": [ { "id": "P1", "file": "android/app/src/main/java/example/tamarind/onboarding/ReferencesStep.kt", "issue": "The screen asks for two references, but prepareSuggestions uploads the whole address book with 90-day call counts from the call log (READ_CONTACTS and READ_CALL_LOG in android/app/src/main/AndroidManifest.xml) to contact_graph through api/onboarding.py. jobs/collections_escalation.py texts up to five of those contacts from 15 days past due with the reference_nudge template in config/sms-templates.yaml, which tells them the borrower owes Tamarind, and jobs/collections_handoff.py sends five to the collections agency. The contacts never consented, and web/privacy-policy.html mentions only the two references the borrower chooses. The Directions bar lending apps from contact lists and call logs outright, and they apply today; the DPDP gap is a readiness gap until 13 May 2027.", "clause": "DPDP s.4(1), s.6(1)", "sector": "RBI (Digital Lending) Directions, 2025, para 12(i)", "lands": "report" }, { "id": "P2", "file": "android/app/src/main/java/example/tamarind/device/LocationSyncWorker.kt", "issue": "PermissionsStep.kt asks for background location 'to keep your account secure', then schedules this worker, which uploads the phone's location every 6 hours for as long as the app is installed. services/field_visits.py uses the latest fix to route field collection agents to borrowers 30 or more days past due. web/privacy-policy.html says location is collected once, during KYC. The Directions allow one-time location access for onboarding or KYC only, and the use differs from the purpose the borrower was told.", "clause": "DPDP s.6(1), s.5(1)", "sector": "RBI (Digital Lending) Directions, 2025, para 12(i)", "lands": "report" }, { "id": "P3", "file": "jobs/collections_handoff.py", "issue": "Recoverline recovers loans for Tamarind, which makes it a lending service provider under the Directions. The job sends b.*, every borrower column (email, date of birth, masked PAN, employer, income, AA handle, nominee), plus five alternative contacts from contact_graph, while Schedule A of docs/vendors/recoverline-agreement.md lists six fields. Clause 9 of the agreement lets the agency keep the data three years after the agreement ends and use it to improve collection models across its clients. The Directions let a service provider store only basic data such as name, address and contact details.", "clause": "DPDP s.8(2), s.8(7)", "sector": "RBI (Digital Lending) Directions, 2025, paras 4(v), 13(i)", "lands": "report" }, { "id": "P4", "file": "services/kyc.py", "issue": "accept_ovd keeps the original front and back images of an Aadhaar card uploaded as an identity document (android/app/src/main/java/example/tamarind/onboarding/KycUploadStep.kt asks for all four corners) and masks the number only in kyc_records. Nothing redacts the Aadhaar number on the stored images, which config/rbac.yaml lets the collections partner read. The KYC Direction requires the lender to ensure the customer redacts or blacks out the number when Aadhaar is submitted as proof of possession and authentication is not required. The images are KMS-encrypted, so a finding that asks only for encryption is partial.", "clause": "DPDP s.8(5), Rule 6(1)(a)", "sector": "RBI Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, para 16, Explanation 1", "lands": "either" }, { "id": "P5", "file": "docs/runbooks/incident-response.md", "issue": "CERT-In hears within 24 hours of the security lead confirming a reportable incident. The CERT-In Directions require a report within 6 hours of noticing the incident or being told of it, FAQ 30 allows a partial first report, and FAQ 24 starts the clock at noticing, so waiting for confirmation breaks it twice. No DPDP or GDPR clause carries this duty: the Board and borrower rows already match Rule 7. It applies today.", "clause": "", "sector": "CERT-In Directions under IT Act s.70B(6), 28 Apr 2022, direction (ii); CERT-In FAQs, May 2022, Q24 and Q30", "lands": "summary" }, - { "id": "P6", "file": "deploy/backup.yaml", "issue": "cross_region_copy sends a daily copy of the whole tamarind-core database, kept 35 days, to ap-southeast-1 (Singapore); docs/vendors/register.md lists that region for the cloud provider. The Directions require all data to be stored only on servers in India, and data processed abroad to be deleted there and brought back within 24 hours. DPDP s.16 allows the transfer, because no country is restricted, so a report finding that treats it as a DPDP gap is misfiled. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 13(iv)", "lands": "summary" }, - { "id": "P7", "file": "web/privacy-policy.html", "issue": "The sharing section names 'trusted technology partners whose software in our app helps us detect fraud and measure app performance' without saying who they are. android/app/build.gradle bundles the Signalgrid and Pulsemetric SDKs, and docs/vendors/register.md says both collect device data directly from the phone. The Directions require the privacy policy to disclose the third parties allowed to collect personal information through the app. DPDP s.5(1) and Rule 3 need the data and purposes, which the policy itemises, not the names of recipients. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 14(ii)", "lands": "summary" }, + { "id": "P6", "file": "deploy/backup.yaml", "issue": "cross_region_copy sends a daily copy of the whole tamarind-core database, kept 35 days, to ap-southeast-1 (Singapore); docs/vendors/register.md lists that region for the cloud provider. The Directions require all data to be stored only on servers in India, and data processed abroad to be deleted there and brought back within 24 hours. DPDP s.16 allows the transfer, because no country is restricted, and s.16(2) only keeps stricter laws such as the Directions in force, with no duty of its own, so a report finding that treats it as a DPDP gap is misfiled. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 13(iv)", "lands": "summary" }, + { "id": "P7", "file": "web/privacy-policy.html", "issue": "The sharing section names 'trusted technology partners whose software in our app helps us detect fraud and measure app performance' without saying who they are. android/app/build.gradle bundles the Signalgrid and Pulsemetric SDKs, and docs/vendors/register.md says both collect device data directly from the phone. The app also sends document images and selfie frames straight to Verilane's /ocr and /liveness (docs/vendors/verilane-api.md), so Verilane collects through the app too. The Directions require the privacy policy to disclose the third parties allowed to collect personal information through the app. DPDP s.5(1) and Rule 3 need the data and purposes, which the policy itemises, not the names of recipients. It applies today.", "clause": "", "sector": "RBI (Digital Lending) Directions, 2025, para 14(ii)", "lands": "summary" }, { "id": "P8", "file": "config/retention.yaml", "issue": "kyc_after_closure_days is 365, so jobs/retention.py purges KYC records and document images a year after a borrower's last loan closes. docs/retention-schedule.md, services/rights.py and the privacy policy all say five years, and the KYC Direction requires identification and address records to be kept at least five years after the business relationship ends. DPDP sets no such minimum and its one-year floor is met, so the early deletion breaches only the sector rule. It applies today.", "clause": "", "sector": "RBI Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, para 46(b)", "lands": "summary" }, { "id": "P9", "file": "jobs/retention.py", "issue": "Rejected applicants' data is never deleted. The KYC purge joins loans, and the rejected-application delete uses USING loans with l.application_id = a.id, so it matches only applications that became loans, which are never rejected. An applicant with no loan keeps the application, KYC images, selfie, bank statements and contact_graph rows indefinitely, while docs/retention-schedule.md and the privacy policy promise deletion 90 days after the decision. Readiness gap until 13 May 2027.", "clause": "DPDP s.8(7)", "sector": null, "lands": "report" }, { "id": "P10", "file": "jobs/offer_scoring.py", "issue": "Each month the job reads the stored account aggregator statements of every active and closed borrower, including those with offers_opt_in false, to set top-up limits. The consent in services/aa_client.py is a one-time fetch with a one-month data life for 'Assess your loan application'. Nothing deletes bank_statements, though docs/retention-schedule.md says jobs/retention.py ends them with the data life, and the privacy policy says offers use repayment history. Readiness gap until 13 May 2027.", "clause": "DPDP s.6(1), s.8(7)", "sector": null, "lands": "report" }, @@ -17,24 +17,33 @@ ], "should_not_flag": [ { "file": "db/migrations/0006_repayment_mandates.sql", "why": "It holds the payment aggregator's token reference, the last four digits, network and expiry, and the e-mandate reference, so it must not be reported as stored card numbers." }, - { "file": "docs/runbooks/incident-response.md", "why": "The Board and borrower rows match s.8(6) and Rule 7: intimation without delay, the detailed report within 72 hours, and every affected borrower told what happened, the likely consequences and what to do. Only the CERT-In row is wrong (P5). The RBI row repeats the catalog's 6-hour entry and is not scored either way." }, + { "file": "docs/runbooks/incident-response.md", "why": "The Board row matches s.8(6) and Rule 7 as the catalog states them: intimation without delay, then the detailed report within 72 hours. Who receives the notice to people affected is K1, and the CERT-In row is P5. The RBI row repeats the catalog's 6-hour entry and is not scored either way." }, { "file": "deploy/logging.yaml", "why": "Logs are kept 400 days in ap-south-1 (Mumbai), over the one-year floor in Rule 6(1)(e) and Rule 8(3) and the 180 days in India that CERT-In direction (iv) requires, and the time source follows NPL and NIC as direction (i) requires. Its reader groups matter only for P11." }, { "file": "jobs/collections_reminders.py", "why": "It texts only the borrower, on the borrower's own number, about their own loan, between 08:00 and 19:00. The messages to third parties are in jobs/collections_escalation.py (P1)." }, { "file": "jobs/offer_push.py", "why": "It sends offers only to active borrowers whose offers_opt_in is true at send time, so withdrawal through api/settings.py takes effect on the next run. The gap is offer_scoring.py's use of statements (P10)." }, { "file": "config/env.example", "why": "Placeholders for local development, not a hardcoded credential." }, { "file": "tests/fixtures/borrowers.json", "why": "Addresses on the reserved example.test domain, all-zero phone numbers and X-masked identifiers are plainly synthetic." }, { "file": "android/app/src/main/java/example/tamarind/onboarding/SelfieStep.kt", "why": "One burst of frames at onboarding for liveness, with the camera released afterwards and consent recorded in PermissionsStep.kt. The Directions allow one-time camera access for onboarding or KYC with the borrower's explicit consent (para 12(i))." }, - { "file": "services/rights.py", "why": "Refusing to erase KYC and loan records during their statutory period, and everything while a loan is still open, with the reason given, is allowed: s.8(7) and s.12(3) except data that a law or the specified purpose still requires, and the fintech sector guide says statutory retention overrides erasure. Its five-year statement is right; the purge that breaks it is P8." }, + { "file": "services/rights.py", "why": "Refusing to erase KYC and loan records during their statutory period, with the reason given, is allowed: s.12(3) excepts data whose retention is necessary for the specified purpose or for compliance with any law, and the fintech sector guide says statutory retention overrides erasure. Its five-year statement is right; the purge that breaks it is P8. Refusing data an open loan does not need is K2, and what erase() leaves out is K3." }, { "file": "config/support.yaml", "why": "Grievances are resolved within 30 days, inside the 90-day ceiling in Rule 14(3), and the escalation note matches the 30-day trigger for the RBI Ombudsman in para 11(iv) of the Directions." }, { "file": "config/app.yaml", "why": "Loans go only to residents of India aged 21 to 58 with a PAN and an Indian address, and the README says the company has no establishment, staff or customers outside India. GDPR does not reach it under Art. 3, and no borrower is a child under DPDP s.2(f) and s.9, so GDPR findings and children's findings are out of scope. The required credit_assessment consent covers only what the application needs, and offers are a separate optional consent, off by default." }, - { "file": "web/privacy-policy.html", "why": "The policy itemises the data and purposes and gives withdrawal, access, correction, erasure, nomination, the grievance officer and the route to the Board, as s.5(1) and Rule 3 require, so none of these may be reported as missing. Its unnamed SDK partners are P7, and its statements on references, location, the collections partner and offers are evidence for P1, P2, P3 and P10." } + { "file": "web/privacy-policy.html", "why": "The policy gives withdrawal, access, correction, erasure, nomination, the grievance officer and the route to the Board, as s.5(1) and Rule 3 require, so none of these may be reported as missing. It itemises the data and purposes except the analytics SDK's screen events (K4). Its unnamed SDK partners are P7, and its statements on references, location, the collections partner and offers are evidence for P1, P2, P3 and P10." } + ], + "known_gaps": [ + { "id": "K1", "file": "docs/runbooks/incident-response.md", "issue": "The notice to people affected goes to 'every borrower affected'. Tamarind also holds data of rejected applicants (kept indefinitely, P9), address-book contacts and references (P1) and nominees, and s.8(6) and Rule 7(1) require intimation to each affected Data Principal.", "clause": "DPDP s.8(6), Rule 7(1)", "found_in": "Run 1: F-0014" }, + { "id": "K2", "file": "services/rights.py", "issue": "erase() refuses everything while a loan is open, including the non-reference contacts and location history that the notice ties to choosing two references and confirming the city at KYC. An open loan does not need them, so s.12(3) does not excuse keeping them.", "clause": "DPDP s.12(3), s.8(7)", "found_in": "Run 1: F-0016, first point" }, + { "id": "K3", "file": "services/rights.py", "issue": "When erase() runs, it keeps the nominee, employer, income, AA handle, applications and repayment mandates without listing them in RETAINED, so the borrower is not told they remain, and it sends no erasure instruction to Recoverline, Verilane, Signalgrid or Pulsemetric.", "clause": "DPDP s.8(7), s.12(3)", "found_in": "Run 1: F-0016, other points" }, + { "id": "K4", "file": "web/privacy-policy.html", "issue": "docs/vendors/register.md says Pulsemetric collects screen events and android/app/build.gradle calls it screen analytics, but the policy's data table lists only device model, identifiers, IP address and network type.", "clause": "DPDP s.5(1), Rule 3(b)", "found_in": "Run 1: F-0017" }, + { "id": "K5", "file": "api/settings.py", "issue": "Only the offers consent can be withdrawn. PermissionsStep.kt records a consent for each permission, but nothing withdraws them, and revoking the Android permission leaves the stored contact graph and locations in use, though the policy promises withdrawal of any consent in the app.", "clause": "DPDP s.6(4), s.6(6)", "found_in": "Run 1: F-0015" }, + { "id": "K6", "file": "services/aa_client.py", "issue": "Nothing handles account aggregator consent revocation or expiry. aa_consents.status is set to requested and then active, never to revoked or expired, so a revoked consent does not stop use of the stored statements.", "clause": "DPDP s.6(6)", "found_in": "Run 1: F-0008, revocation point" }, + { "id": "K7", "file": "docs/vendors/register.md", "issue": "The SMS gateway's entry gives a services agreement with no processing terms, unlike every other vendor, and the payment aggregator behind repayment_mandates.pa_token_ref is not in the register. The contracts are not in the material, so this is 'not found in the reviewed material', medium at most.", "clause": "DPDP s.8(2), Rule 6(1)(f)", "found_in": "Run 1: F-0018" }, + { "id": "K8", "file": "services/rights.py", "issue": "export() leaves out preapproved_offers, which erase() deletes.", "clause": "DPDP s.11(1)(a)", "found_in": "Run 1: F-0021" } ], - "known_gaps": [], "sources": [ { "claim": "Digital Lending Directions: para 3 applies to all NBFCs; para 4(v) defines a lending service provider to include recovery; para 11(iv) lets a borrower go to the RBI Ombudsman with no reply in 30 days; para 12(i) requires need-based collection with prior explicit consent and an audit trail, bars access to file and media, contact list, call logs and telephony functions, and allows one-time camera, microphone or location access for onboarding or KYC only; para 13(i) limits service providers to basic data; para 13(iv) requires storage only in India, with data processed abroad deleted there and brought back within 24 hours; para 14(ii) requires the privacy policy to disclose third parties collecting through the app.", "source": "Reserve Bank of India (Digital Lending) Directions, 2025, RBI/2025-26/36, 8 May 2025, https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=12848, read 11 Nov 2026. /dpdp-catalog has the instrument and paras 12-14." }, { "claim": "CERT-In: direction (i) syncs clocks to NIC or NPL; direction (ii) reports incidents within 6 hours of noticing or being told; direction (iv) keeps ICT logs 180 days within Indian jurisdiction. FAQ 24 starts the clock at noticing; FAQ 25 makes any company a body corporate; FAQ 30 allows a partial first report and lists data breaches and leaks; FAQ 35 lets logs sit outside India if produced in reasonable time, so log location is not planted.", "source": "https://www.cert-in.org.in/PDF/CERT-In_Directions_70B_28.04.2022.pdf and https://www.cert-in.org.in/PDF/FAQs_on_CyberSecurityDirections_May2022.pdf, text extracted 11 Nov 2026. /dpdp-catalog has the 6-hour and 180-day rows." }, { "claim": "KYC Direction: para 3(b)(xiv) covers NBFCs; para 16, Explanation 1, makes the lender ensure the customer redacts or blacks out the Aadhaar number where authentication is not required; para 46(b) keeps identification and address records at least five years after the business relationship ends.", "source": "Master Direction - Know Your Customer (KYC) Direction, 2016, updated 14 Aug 2025, https://www.rbi.org.in/Scripts/BS_ViewMasDirections.aspx?id=11566, read 11 Nov 2026. The fintech sector guide's 'KYC records: 5 years after business relationship ends' agrees." }, - { "claim": "DPDP sections and rules, commencement and the absence of a restricted country under s.16.", "source": "/dpdp-catalog, last verified 2 Nov 2026. The wording of s.2(f) (child) and s.12(3) (erasure unless retention is necessary for the purpose or a law) is from the Act and was not rechecked against the Gazette here." }, + { "claim": "DPDP sections and rules, commencement and the absence of a restricted country under s.16.", "source": "/dpdp-catalog, last verified 2 Nov 2026. The wording of s.2(f) (a child has not completed eighteen years), s.12(3) (erase on request unless retention is necessary for the specified purpose or for compliance with any law) and s.16 (the Central Government may restrict transfers by notification; s.16(2) keeps in force any law giving higher protection or restricting transfers) was checked on 11 Nov 2026 against the Gazette of India Extraordinary, Part II Section 1, No. 25, 11 Aug 2023, https://egazette.gov.in/WriteReadData/2023/248045.pdf." }, { "claim": "GDPR territorial scope.", "source": "Regulation (EU) 2016/679, Art. 3, on EUR-Lex. /gdpr-catalog has no Art. 3 row." } ], "unverified": [ diff --git a/examples/tamarind-lend.run-1/score.md b/examples/tamarind-lend.run-1/score.md index b2652e9..96a7957 100644 --- a/examples/tamarind-lend.run-1/score.md +++ b/examples/tamarind-lend.run-1/score.md @@ -1,6 +1,6 @@ # Score: blind audit of tamarind-lend -Assessment `2026-11-11-tamarind-lend-full`, 23 findings and 6 sector entries. Scored on 11 November 2026 against `examples/tamarind-lend.expected.json` and the run sheet on branch `claude/epic-wiles-e7f118`. The key's sha256 is `81ff4d9384b738f4e652fbd5a6c2b2d696b16acd5a84bc63d594c8fd5e395e1b`. +Assessment `2026-11-11-tamarind-lend-full`, 23 findings and 6 sector entries. Scored on 11 November 2026 against `examples/tamarind-lend.expected.json` and the run sheet on branch `claude/epic-wiles-e7f118`. The key's sha256 is `81ff4d9384b738f4e652fbd5a6c2b2d696b16acd5a84bc63d594c8fd5e395e1b`. After this score, a later commit made the key changes proposed under Should-not-flag and added the run's real gaps to `known_gaps` (see "Key changes" in the run sheet); the app is unchanged. These figures use the earlier key. The scorer did not write the fixture or the report, and opened the key only for scoring. `report.json` has the same sha256 before and after scoring: `cbd59050e1414976235534aa444dea7298c41229cc4af5d756b4f25e0ad96543`. `summary.md` is `154b74bead5881efafa696517852f11ea7f2bfc9c9dd0bed015ca279f60889d9`. diff --git a/examples/tamarind-lend.run-sheet.md b/examples/tamarind-lend.run-sheet.md index ca37052..c237cfe 100644 --- a/examples/tamarind-lend.run-sheet.md +++ b/examples/tamarind-lend.run-sheet.md @@ -6,7 +6,7 @@ Four of the planted items are sector-only duties with no DPDP or GDPR clause beh ## Audit session: cold, never opens the key -Export a committed revision, so uncommitted skill text can't help the audit. From the repo root, with `` set to the revision under test: +Export a committed revision, so uncommitted skill text can't help the audit. Commit a fixture under a subject that says nothing about its planted issues: an audit session started inside a checkout sees the recent commit subjects. From the repo root, with `` set to the revision under test: ```bash BLIND="$HOME/vigil-blind-tamarind" @@ -44,4 +44,4 @@ Tuning skills on a scored run ends this fixture's blindness. Say so in any later ## Key changes -None yet. +- **11 November 2026.** Changed after the first scored run (`examples/tamarind-lend.run-1`, scored against the key at `d335bd1`). Three should-not-flag notes were too broad: the runbook note covered who receives the breach notice, the erasure note excused everything while a loan is open, and the policy note said every data item was itemised, though the analytics SDK's screen events are not. Those notes are narrowed. P7 now names Verilane, which the app calls straight from the phone. `known_gaps` records eight real gaps the run found. The key's wording of s.2(f), s.12(3) and s.16 is now checked against the Gazette. The app files are unchanged, and that score stands against the original key. From f5d17360188a96adcf0b8a50a5aa6dbe08b350b5 Mon Sep 17 00:00:00 2001 From: prathikcannotcode Date: Wed, 11 Nov 2026 13:42:35 +0530 Subject: [PATCH 4/4] Add the verified Digital Lending, KYC and CERT-In detail to the catalog The tamarind-lend run showed the catalog's sector rows were too thin to cite: the Digital Lending row left out the bar on contacts, call logs and repeated location access, and there was no KYC Direction row. Each point added here was checked on 11 November 2026 against rbi.org.in, cert-in.org.in and the Gazette text of the Act. This ends tamarind-lend's blindness for any later run. Co-Authored-By: Claude Opus 5.5 --- .claude/skills/dpdp-catalog/SKILL.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/skills/dpdp-catalog/SKILL.md b/.claude/skills/dpdp-catalog/SKILL.md index d464e1c..1e57266 100644 --- a/.claude/skills/dpdp-catalog/SKILL.md +++ b/.claude/skills/dpdp-catalog/SKILL.md @@ -110,14 +110,17 @@ Sub-clause letters were checked on 5 November 2026 against a reproduction of the |---|---|---| | IT Act 2000, s.43A and SPDI Rules 2011 | Parliament / MeitY | Still apply until DPDP s.44(2) commences (13 May 2027), then s.43A is omitted | | IT Act 2000, s.72A | Parliament | Disclosure in breach of lawful contract; not repealed by DPDP | -| CERT-In Directions, 28 Apr 2022 (s.70B(6)) | CERT-In | Report cyber incidents within 6 hours; keep ICT logs for 180 days in India | +| CERT-In Directions, 28 Apr 2022 (s.70B(6)) | CERT-In | Any company is covered (FAQ 25). Report cyber incidents, data breaches and leaks included, within 6 hours of noticing them or being told of them (direction (ii)); a partial first report is allowed (FAQ 30). Keep ICT logs for a rolling 180 days within Indian jurisdiction (direction (iv)), though FAQ 35 lets logs sit outside India if they reach CERT-In in reasonable time. Sync clocks to the NIC or NPL time servers (direction (i)) | | RBI Cybersecurity, Technology: Risk, Resilience and Assurance Framework Directions, 2026 (31 Jul 2026) | RBI | Entity-specific directions for banks and NBFCs; replace the 2016 framework and the 2023 IT Governance Master Direction; 6-hour incident reporting to RBI | | RBI Storage of Payment System Data circular (6 Apr 2018) | RBI | Payment data stored only in India | -| Reserve Bank of India (Digital Lending) Directions, 2025 (RBI/2025-26/36, 8 May 2025) | RBI | Banks, co-operative banks, NBFCs and all-India financial institutions, and through them their lending service providers. Need-based data collection with prior explicit consent and an audit trail (para 12). Service providers keep only basic data; all data on servers in India, with data processed abroad back within 24 hours (para 13). Public privacy policy naming third parties (para 14). Paragraph numbers are from the May 2025 text, so check RBI's current Master Directions before citing one | +| Reserve Bank of India (Digital Lending) Directions, 2025 (RBI/2025-26/36, 8 May 2025) | RBI | Commercial and co-operative banks, all NBFCs and all-India financial institutions (para 3), and through them their lending service providers, which include recovery agents (para 4(v)). Need-based data collection with prior explicit consent and an audit trail; apps must not access files and media, the contact list, call logs or telephony functions, and may take one-time access to the camera, microphone or location only for onboarding or KYC, with explicit consent (para 12(i)). Service providers keep only basic data such as name, address and contact details (para 13(i)). All data on servers in India; data processed abroad is deleted there and brought back within 24 hours (para 13(iv)). A public privacy policy that names the third parties allowed to collect personal information through the app (para 14(ii)). A borrower with no reply in 30 days can go to the RBI Ombudsman (para 11(iv)) | +| RBI Master Direction: Know Your Customer (KYC) Direction, 2016 (updated 14 Aug 2025) | RBI | Every RBI-regulated entity, NBFCs included (para 3(b)(xiv)). Where Aadhaar is submitted as proof of possession and authentication is not required, the entity ensures the customer redacts or blacks out the Aadhaar number (para 16, Explanation 1). Keep transaction records at least five years from the transaction (para 46(a)) and identification and address records at least five years after the business relationship ends (para 46(b)). DPDP s.8(7) and s.12(3) let these records outlast an erasure request | | SEBI Cybersecurity and Cyber Resilience Framework (CSCRF), Aug 2024 | SEBI | Market intermediaries | | IRDAI Information and Cyber Security Guidelines, 2023 | IRDAI | Insurers and intermediaries | | Guidelines for Prevention and Regulation of Dark Patterns, 2023 | Central Consumer Protection Authority | 13 listed dark patterns in consumer-facing interfaces (Consumer Protection Act 2019) | +The CERT-In, Digital Lending and KYC rows were checked on 11 November 2026 against the directions and FAQs on cert-in.org.in and the Master Directions on rbi.org.in. s.8(7) and s.12(3) were checked against the Gazette text of the Act. None of these instruments is a DPDP clause, so none of them belongs in a finding's `clause`. + ### Global frameworks (cross-border context) | Instrument | Jurisdiction | Relevance to DPDP |