Security & data protection pack
Tradelynx Limited · Company No. 17064008 · Contact: security@tradelynx.co.uk
Content current as at 28 July 2026 · page generated 26 August 2026
What we commit to
Source: SECURITY_BRIEFING · reviewed 5 August 2026
- Access limited to what your role needs, and removed the day it stops being needed.
- No blame for reporting a mistake — including your own. The only unrecoverable error here is a delayed one.
- This briefing kept current, and re-read annually.
What personal data we hold
Source: DATA_RETENTION · reviewed 29 July 2026
| Category | Where stored | Examples |
|---|---|---|
| Customer contact | lead_private.customer_*, customer_profiles, auth.users | name, email, phone, postcode |
| Customer property data | properties, property_history_entries, property_access_codes (encrypted) | property address, recorded work history, access codes |
| Customer messages | messages, lead_messages, message attachments (Storage) | free-form text, images |
| Trader contact | traders, auth.users | name, business name, email, phone, trade |
| Trader credentials | trader_credentials, trader-docs Storage bucket | trade-card photos, AI-extracted metadata |
| Identity verification | traders.verification_status, user_identity_verifications, DiDit's own systems | DiDit session id, status |
| Payment metadata | payments_audit, credit_ledger, Stripe's own systems | Stripe payment intent ids, amounts (no card data) |
| Telephony audit | telephony_audit, Telnyx's own systems | call durations, outcomes, recording paths if any |
| Audit logs | audit_events, cron-monitor Sentry events | user_id, event_name, metadata, timestamp |
| DSAR requests | data_subject_requests | request type, deadline, resolution notes |
| Outbound communications | notification_queue, Resend's logs | email contents (transient), status |
| Mobile push tokens | mobile_push_tokens | device tokens (Apple / FCM) |
How long we keep it
Source: DATA_RETENTION · reviewed 29 July 2026
| Category | Retention period | Trigger | Mechanism |
|---|---|---|---|
| Customer contact (active) | Lifetime of account | Account exists | Manual deletion via DSAR or /api/admin/customers/anonymise |
| Customer contact (after deletion) | None — anonymised in place | DSAR erasure request | /api/admin/customers/anonymise scrubs lead_private.customer_* and customer_profiles; preserves minimal audit row |
| Customer property data | Lifetime of property record | Manual deletion via DSAR | Anonymisation on request; access-code rows are AES-256-GCM encrypted at rest |
| Customer messages + attachments | Lifetime of associated lead/job | Lead/job deletion | Manual deletion via DSAR; storage objects removed via admin.storage.remove([path]) |
| Trader contact (active) | Lifetime of account | Account active | Self-service close via /api/trader/close-account (synchronous anonymisation) |
| Trader credentials (active) | Until card expires or replaced | status change | Stays active while in use |
| Trader credentials (expired/rejected raw evidence) | 7 years from last status change | cron/retention-cleanup (monthly) | Storage object deleted from trader-docs/; ai_extracted_* fields nulled on the row; minimal audit fields (credential_type, expiry_date, status, verified_at, admin_notes) preserved |
| Identity verification | Same as trader account | Trader closure | Anonymised with the trader row |
| Identity verification — at DiDit | 6 months | Configured in the DiDit Business Console, 2026-08-05 | Their default is INDEFINITE unless the Client sets a period (DPA §2.1). Nothing in lib/didit/ sets it, so this is a Console setting and it is the only thing standing between us and unlimited retention of identity documents and facial biometrics. Re-check at each quarterly Management Review — a setting made in a vendor console leaves no trace in this repository |
| Payment metadata | 6 years | Tax / accounting requirement | Manual / not subject to DSAR erasure (HMRC requires 6 years for company records) |
| Telephony audit | Lifetime of platform record | Manual cleanup | Currently no auto-purge; row size manageable |
| Audit logs (audit_events) | Lifetime of platform record | Manual cleanup | Currently no auto-purge; designed for retention as a security control |
| Trader books (trader_expenses, trader_manual_income) | While the trader account lives (they are the trader's business records — HMRC expects 5+ years); voids retained in revision history (digital-record integrity) | Trader-initiated; export-then-delete on account deletion per the existing GDPR flow | CHG-2026-0824, DPIA G-A2 |
| Enterprise sensitive-access log (org_sensitive_access_log) | 12 months (founder decision 2026-08-01, CHG-2026-0823) | cron/retention-cleanup | Surface/actor/count metadata only — no PII (CHG-2026-0786); 12 months preserves a full annual audit cycle |
| DSAR records (data_subject_requests) | Lifetime of platform record | Required for compliance audit | Retained as the audit trail of GDPR fulfilment |
| Outbound notification queue | Indefinite — nothing purges it | None | ⚠ See "Storage limitation gap" below. Holds recipient_email, html_body and payload in plaintext. The previous entry claimed ~30 days; that was never implemented (CHG-2026-0780) |
| Mobile push tokens | Lifetime of device install | App-side revoke calls or DSAR | Deleted on app uninstall (best-effort) |
| Sentry events | 90 days (Sentry default plan) | Sentry built-in | Set in Sentry project settings |
| Vercel logs | 7 days (Vercel Pro default) | Vercel built-in | Set in Vercel project settings |
| Stripe data | Stripe's own retention | Stripe built-in | We do not separately retain Stripe customer data |
| Business-development prospects and contacts (sales_prospects, sales_prospect_contacts, sales_outreach_drafts) | 12 months uncontacted or after no reply; 24 months where a conversation happened | Creation date, or last contact / last pipeline activity. An objection deletes contact details at once, leaving a suppression record only | /api/cron/prospect-retention (daily). LIA: compliance/lia/2026-08-26_LIA_business_development.md (CHG-2026-1157) |
Where the data lives
Source: DATA_RETENTION · reviewed 29 July 2026
| Asset | Service | Region |
|---|---|---|
| Database | Supabase Postgres | eu-west-2 (London) |
| Object storage | Supabase Storage | eu-west-2 (London) |
| Application runtime | Vercel | eu-west-2 (multi-region edge) |
| Email delivery | Resend | EU (eu-west-1, hosted by Resend) |
| Telephony | Telnyx | EU PoP |
| Payment processing | Stripe | EU (Stripe's UK / Ireland / NL) |
| Identity verification | DiDit | EU |
| Error monitoring | Sentry | EU |
| AI inference | Anthropic | US (model calls) — only non-PII content sent (moderation prompts redact identifiers); Anthropic does not train on API content |
All personal data of UK data subjects rests in the UK or EEA.
What happens in a breach
Source: BREACH_NOTIFICATION · reviewed 5 August 2026
0. Start the clock and write it down (minute 0)
Open an incident record from governance/templates/incident-template.md immediately — before investigating. Fill in Detected to the minute, and answer the regulatory-notification block at the top. The record is the clock: both deadlines are computed from that timestamp.
Do not wait for certainty to open the record. An incident that turns out to be nothing is closed in a line; an incident opened three hours late has burned three hours of a 72-hour budget.
1. Contain, then preserve (first hour)
Stop the bleeding, then stop destroying evidence. Revoke the credential, disable the route, take the surface down. Do not delete logs, rotate away the affected keys' audit trail, or clean up the data before it has been captured — both regulators will ask what was accessed and by whom, and the audit tables (property_data_access_log, data_disclosure_log, audit_events) are the answer.
2. Establish the four facts both regulators ask for
1. What happened — plain sentences, no speculation about cause. 2. What data, and whose — categories and approximate numbers of people. Never the data itself: the ICO explicitly says not to include personal data in the report form. 3. Likely consequences for the people affected. 4. What you have done and will do — containment already applied, and the remediation planned.
If you do not have all four at 71 hours, report anyway. Both regulators accept a partial report followed by an update; neither accepts silence.
3. Notify HMRC
Log the Developer Hub support ticket. Include: application name (Tradelynx Books), the four facts above, and the breach contact name and telephone number — that is a stated requirement, not a courtesy. Record the ticket reference in the incident record.
4. Notify the ICO
Complete the online form. Allow about 30 minutes. The ICO responds within seven calendar days with next steps. Record the reference in the incident record.
5. Tell the people affected, if the risk is high
Where the breach is likely to result in a high risk to people's rights and freedoms, they must be told directly and without undue delay, in plain language. This is a separate duty from notifying the ICO, and it is the one most often missed.
6. Close it honestly
The incident record's regulatory block must carry both references and both timestamps before the record moves to Resolved. A guard enforces this: tests/unit/breachNotification.spec.ts fails the build if an incident marked as a breach reaches Resolved without them.
Backups
Source: BACKUP_RECOVERY · reviewed 28 July 2026
| Asset | Backup mechanism | Retention | Source of truth |
|---|---|---|---|
| Supabase Postgres database | Daily physical backups via WAL-G; point-in-time recovery (PITR) currently disabled (see "PITR — deliberate deferral" below) | 7 daily snapshots rolling | Supabase project mmwcvinqnacmarnazvve |
| Supabase Storage buckets | Replicated within the storage layer; not separately snapshotted | Lifetime of the bucket; deleted objects are not retained beyond Supabase's internal retention (~30 days) | Buckets: trader-docs, lead-media, support-media, org-property-photos, tool-assets, market-readiness, property-media, branding, telephony-prompts |
| Stripe customer + payment data | Held by Stripe; we do not back it up | Stripe's own retention | Stripe dashboard |
| Source code + migrations | Git, GitHub | Indefinite | github.com/tradelynx/tradelynx |
| Documentation | Git, GitHub | Indefinite | docs/ in this repo |
| Environment variables | Vercel project settings + local .env.local | Vercel retains historical values across deployments; .env.local lives only on developer machines | Vercel dashboard |
Recovery objectives (RTO / RPO)
Source: BACKUP_RECOVERY · reviewed 28 July 2026
| Objective | Target | Justification |
|---|---|---|
| RPO (data loss tolerance) | 24 hours | Daily snapshots are the constraint. PITR would drop this to ~2 minutes; see "PITR — deliberate deferral" below for why it is not enabled today. |
| RTO (time to restore) | 4 hours for full DB restore from snapshot | Includes: incident detection, decision to restore, Supabase restore operation (~5–15 min wall clock), spot-check, reconciliation against Stripe / Telnyx / Resend / audit logs, traffic resumption. Validated end-to-end by the 2026-05-03 restore drill. |
| MTTR for partial-table recovery | 1 hour | Single-table or single-row restore from snapshot via SQL; faster than a full project restore |
PITR — deliberate deferral
Point-in-Time Recovery is a documented and ready follow-up, not an oversight. It is not enabled today because the cost (~£150–250/mo including the required compute upgrade) is not justified by the current data-at-risk profile: pre-revenue, 4 verified traders, no paying enterprise customers, and a daily-snapshot RPO that loses at most a small number of leads and messages in a worst-case 24-hour window. The procedure to enable PITR is a single dashboard action (Settings → Add-ons → Point-in-Time Recovery) that takes effect within minutes.
Trigger conditions for enabling PITR. Any of:
1. First paying enterprise customer signed (any tier — validates the pricing infrastructure and changes the data-at-risk profile) 2. First insurer or lender in active sales conversation (regulated buyers will require shorter RPO commitments in their procurement review) 3. Database size exceeds 4 GB (Supabase's own production checklist recommends PITR above this threshold) 4. Daily Stripe completion-fee volume exceeds £500 (i.e. 24 hours of lost data starts representing material financial reconciliation work)
Compensating controls in the absence of PITR. Daily Supabase snapshots cover the database. External systems (Stripe events, Telnyx call records, Resend email log, Sentry events) provide a secondary recovery surface for the 24-hour worst-case window — the "Reconcile against external sources" step in the restore procedure below documents how. The 2026-05-03 restore drill exercised the clone-and-restore path and confirmed the procedure works.
Penetration test — scope
Source: PENETRATION_TEST_2026-08 · reviewed 5 August 2026
What was tested: the Tradelynx web application, running locally from the current main, exercised over HTTP with automated templates and manual probes. 6,384 requests.
What was NOT tested:
- A third-party engagement has not happened. This is tool-assisted in-house testing. Tools and an independent tester are not the same thing, and the answer to HMRC should say "tooling", not "we passed a penetration test".
- The deployed edge — the WAF, TLS termination and CDN behaviour — was not scanned, for the reason in the incident note below. Production security headers were verified by inspection, not by attack.
- Authenticated attack surface was not driven by the scanner. Gated routes were confirmed to refuse anonymous callers, but post-authentication testing (privilege escalation between real roles) rests on the standing test suite rather than on this scan.
Penetration test — results
Source: PENETRATION_TEST_2026-08 · reviewed 5 August 2026
Automated templates: 2 informational results, both triaged to nothing. No exposures, misconfigurations, default credentials, injection points or known-CVE matches.
| Result | Severity | Verdict |
|---|---|---|
| robots.txt file | info | Expected. We serve one deliberately |
| SNMPv3 Fingerprint - Detect | info | False positive, verified. The template reported the host, not the application. Nothing listens on UDP/161 (lsof -nP -iUDP:161 → zero listeners); the only listener on the tested port is the Node process serving the app. A scanner artefact, not a service |
Triaging both rather than reporting "0 findings" matters: the SNMP line would look alarming in a report and dismissing it silently would be indistinguishable from missing it.
Manual probes
| Probe | Result |
|---|---|
| /.env, /.env.local, /.git/config | No response — not served |
| /api/debug, /server-status | Not present |
| /admin unauthenticated | 307 to sign-in |
| /api/admin/users unauthenticated | 401 |
| /.well-known/security.txt | Present — a disclosure route exists |
| robots.txt, sitemap.xml | Present, as intended |
Production security headers (verified 2026-08-05, live)
| Header | Value |
|---|---|
| strict-transport-security | max-age=63072000; includeSubDomains; preload |
| content-security-policy | default-src 'self' … object-src 'none', base-uri 'self', form-action 'self' |
| x-frame-options | DENY |
| x-content-type-options | nosniff |
| referrer-policy | strict-origin-when-cross-origin |
| permissions-policy | camera, microphone, geolocation denied; payment scoped to Stripe |
Standing security suite
39 checks across 11 files pass: OTP verification policy, org authorisation, tenant access-token validation, invoice-token validation, signup upload guards, Turnstile, property-contribute consent, an IDOR heuristic, an SSRF fetch guard and the API scope registry.
Complementing them nightly against production: a sweep asserting 96 gated routes × 2 verbs refuse anonymous callers, and a check that every admin page refuses an anonymous visitor.
What this pack deliberately leaves out
Named here rather than quietly absent, so you can judge the omission for yourself and ask.
- Database restore procedures. An operational runbook. Publishing the steps to restore our database publishes an attack path; the recovery objectives above are the part a reviewer actually needs.
- Full threat model. It names specific attack paths and their current controls. We share it under NDA on request rather than publishing a map of where to push.
- Individual sub-processor contracts and DPAs. Third-party commercial terms that are not ours to publish. Available under NDA.
- Named staff beyond the single security contact. Personal data of our own people, and not information a procurement decision needs.