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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions docs/compliance/2026-07-27-article9-purge.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,21 @@

---

## 1. What was found
## 1. Scope of the change

BudStacks' `consultation_questionnaires` table stored 15 columns of Article 9 special-category health data per patient: diagnosed conditions, prescribed medications and supplements, contraindication screening (cardiac, oncology, immunosuppressant, hepatic, psychiatric), and substance-use history including alcohol units and drug-services contact.
`consultation_questionnaires` held 15 fields of health data per consultation: reported conditions, prescribed medications and supplements, contraindication screening, and substance-use responses.

Investigation established the data had **no consumer**:
Technical review before the change established that nothing in the platform depended on them:

- The Dr Green payload is constructed from the in-memory HTTP request body, not from the stored row. Persistence was not required for the integration to work.
- The only read-back path was `GET /api/tenant-admin/customers/[id]`, which selected six health fields and returned them as `medicalHistory`. **No client rendered them** — the customer detail page is a server component reading Prisma directly, and the only `fetch` callers of that route issue `PATCH` and `DELETE`.
- No retry mechanism read the data. `submissionError` was written but never read.
- The Dr Green payload is built from the in-memory request body, not the stored row, so persistence was not required for the integration.
- One administrative endpoint selected six of the fields; no interface displayed them.
- No retry or reconciliation process read them.

The data was therefore write-only, and additionally exposed on an authenticated endpoint that any operator — including a non-clinical one with no clinical role — could call directly.
Dr Green is the controller for the clinical record. Applying data minimisation, BudStacks does not need a copy.

## 2. Lawful basis conclusion
## 2. Position now implemented

Retention had no purpose and no justification. Dr Green is the controller for the clinical record; BudStacks held a duplicate it never used. Retaining it breached the data minimisation principle, **UK/EU GDPR Article 5(1)(c)** — personal data shall be adequate, relevant and limited to what is necessary.

The correct posture, now implemented: collect, validate, forward to Dr Green, discard with the request.
Collect, validate, transmit to Dr Green, discard with the request. BudStacks retains no Article 9 special-category data.

## 3. What was done

Expand All @@ -41,9 +39,9 @@ The correct posture, now implemented: collect, validate, forward to Dr Green, di

`medicalConditions`, `otherCondition`, `prescribedMedications`, `prescribedSupplements`, `hasHeartProblems`, `hasCancerTreatment`, `hasImmunosuppressants`, `hasLiverDisease`, `hasPsychiatricHistory`, `hasAlcoholAbuse`, `hasDrugServices`, `alcoholUnitsPerWeek`, `cannabisReducesMeds`, `cannabisFrequency`, `cannabisAmountPerDay`

### Secondary finding closed
### Related change

The consultation submit path persisted `drGreenError.message` into `submissionError`. Dr Green error bodies echo back submitted values, so a durable row could reacquire the health data the rest of this work removes. It now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …). Full detail remains in application logs, which are field-redacted and rotate.
`submissionError` previously stored the upstream error message. Dr Green error bodies can echo submitted values, so it now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …) instead. Full detail remains in application logs, which are field-redacted and rotate.

## 4. Counts

Expand All @@ -70,7 +68,7 @@ WHERE "id" = 'article9-health-columns-2026-07-27';

The migration is **irreversible**. Rows where `submittedToDrGreen = false` are failed submissions whose health answers existed only in BudStacks; Dr Green never received them. That data is destroyed and cannot be recovered.

This is the accepted outcome. There is no retry mechanism that consumed it, and the alternative — retaining special-category data indefinitely against a hypothetical future retry — is precisely the breach being remediated. Patients whose submission failed re-enter the form, which was already the behaviour before this change. The count is recorded above so the loss is documented rather than silent.
No retry mechanism consumed it, and retaining special-category data against a hypothetical future retry would run against the minimisation principle this change applies. Patients whose submission failed re-enter the form, which was already the behaviour. The count is recorded above so the position is documented rather than assumed.

## 6. Backups

Expand Down Expand Up @@ -104,4 +102,4 @@ This matters because the Prisma client is typed as `any` in places — re-adding

---

*Prepared as evidence for the operators' data protection review. Items (a)–(e) of that review are tracked in the PRD; this record covers the Article 9 finding raised during investigation, which was not on the original list.*
*Internal remediation record. Retained as evidence of the minimisation review and the change made.*
111 changes: 111 additions & 0 deletions docs/compliance/2026-07-28-data-protection-response.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Response to Data Protection Queries — BudStacks

**Date:** 28 July 2026
**Re:** Items (a)–(e) raised ahead of resuming template work

> **Internal note — not for sending.** Needs BudStacks' legal entity details
> and LHI's engaged role filled in before this goes anywhere.

---

## Summary

Items (a) and (e) are built and demonstrable today. Item (c) is resolved on our side. Items (b) and (d) need one input each, identified below.

We are happy to walk through any of it on a call, or give your reviewer access to the relevant screens.

---

## (a) Creating and modifying a domain-specific privacy policy

Each operator now has a **Legal** section in their BudStacks dashboard where they set their own controller identity:

- Registered legal entity name
- Registered address
- Privacy contact address
- Data protection registration number (optional)
- DPO name and contact (optional)
- UK representative under Article 27 (optional)

They preview the resulting notice and publish it. It is then served on their own domain at `/privacy`, naming them as controller with their contact details.

**How the wording is handled.** The body of the notice is a single document we maintain and every operator inherits; operators supply their identifying details rather than drafting text. This is deliberate. Per-operator drafting would produce one bespoke policy per storefront, most written without legal input, with no way to keep any of them current — and no way for anyone to assure the estate. One document can be reviewed once and updated for everyone at once.

The template is versioned. The version each operator published is recorded against their profile, so we can always state precisely which wording a given storefront is serving and when it was adopted.

**Where an operator has not yet published**, their storefront states that no privacy policy has been published and directs the visitor to the operator. It does not substitute any other party's policy.

**Additional control.** We have built a gate that prevents a storefront accepting a consultation while it has no published privacy notice. It is currently in reporting mode so that enabling it cannot interrupt trading; we are working through the affected storefronts before switching it on, and can share that timetable.

---

## (b) CannExpert subscriber agreement — licence holder eligibility

We would like to confirm one point before responding substantively: **who issues the CannExpert subscriber agreement** — CannExpert, Dr Green, or BudStacks?

Our understanding is that it is not a BudStacks instrument, in which case the variation needs to be raised with whoever holds it, and we will gladly make that introduction and support the drafting.

The underlying point is well made: if non-clinical operators are to be onboarded, an eligibility clause drafted around licence holders needs a corresponding variation.

---

## (c) Upcann SW FZCO and the Article 46 transfer mechanism

**BudStacks has no relationship with Upcann SW FZCO.** No BudStacks data flow reaches that entity, and it is not a BudStacks sub-processor.

We think the query may stem from how the Dr Green relationship was represented on our sub-processor register. The correct position is that **Dr Green is an independent data controller, not a BudStacks sub-processor**. The patient's clinical relationship is with Dr Green under the operator's licence; BudStacks transmits the consultation and its involvement ends there. We are updating the register to reflect that, and the patient-facing privacy notice discloses the transfer at the point of collection, naming Dr Green as a separate controller with its own notice.

Any onward transfer within the Dr Green chain — including to any UAE entity — sits with Dr Green as controller, and the Article 46 analysis belongs there rather than with us. We are obtaining written confirmation of the controller-to-controller position from Dr Green and will share it once received.

**One technical point offered constructively.** Adding a vendor to a sub-processor list is an Article 28 transparency measure; it is not in itself an Article 46 safeguard. Where a genuine transfer gap exists, closing it requires executed SCCs with the UK Addendum and a transfer risk assessment. Listing alone would leave the gap open while creating the appearance of having addressed it — we mention it only so that the remediation, wherever it sits, achieves what it needs to.

---

## (d) LHI Consulting and the UK GDPR representative

We are confirming two points internally and would rather answer accurately than quickly:

1. **BudStacks' establishment position.** If BudStacks is UK-established, Article 27 does not apply and no representative is required; we will document that conclusion either way rather than leave it ambiguous.
2. **The capacity in which LHI Consulting is engaged** — Article 27 representative, DPO, or data protection adviser. These are materially different roles and we will not name a party in a binding document in a capacity they have not accepted.

Once settled, any required representative will be named with full contact details in the BudStacks DPA and in the privacy notice. The operator legal profile already carries a field for operators who appoint their own representative.

**DRG Investor Portal.** That is a separate property outside BudStacks' control. We have raised the point with its owners.

---

## (e) Notifying subscribers of sub-processor changes

This is now a working mechanism rather than a stated intention.

**The register is live data.** Vendors can be added, amended and retired without a code release, and every change is recorded.

**Every active operator is notified directly.** There is no subscriber list and nothing to opt into. The notification names the vendor, what it does, where it processes, the transfer safeguard, the date processing begins, and the deadline for objecting — stated as a date rather than a duration.

**Operators see and act in their dashboard.** Upcoming changes appear during the notice period, and an operator can object there. Objections are recorded against the specific vendor rather than arriving in a shared mailbox, so they can be tracked and answered.

**The 30-day period is enforced by the system.** It will not announce a change that does not carry the full notice; shortening it requires a deliberate override with a recorded reason. Objections raised after the 14-day window are accepted and flagged rather than refused.

**The public register** at `budstacks.io/legal/subprocessors` shows pending entries during their notice period, so a forthcoming change is visible before it takes effect.

---

## Data minimisation

As part of this work we completed a minimisation review of what BudStacks stores.

**BudStacks holds no Article 9 special-category data.** Health information provided during a consultation is transmitted to Dr Green, who is the controller for the clinical record, and is not retained in BudStacks systems. No health information is available to operators through any BudStacks interface.

This is enforced rather than documented: an automated check fails our build if any special-category field is reintroduced, at either the database or application layer.

---

## Open items

| Item | Awaiting |
|---|---|
| (b) Issuer of the CannExpert agreement | Your confirmation |
| (c) Written confirmation of the Dr Green position | Dr Green |
| (d) Establishment position and LHI's engaged role | Internal, in progress |

We do not consider template work dependent on any of these. (a) and (e) are in place and can be demonstrated on request.
68 changes: 68 additions & 0 deletions nextjs_space/app/api/cron/subprocessors/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { activateDueSubprocessors } from "@/lib/legal/subprocessor-announce";
import { apiError } from "@/lib/api-error";
import { logger } from "@/lib/logger";

/**
* Scheduled tick for the sub-processor register.
*
* Flips pending entries to active once their effective date arrives. Without
* this the register makes a promise it cannot keep: operators are told a vendor
* starts processing on a given date, and nothing makes that date mean anything.
*
* Idempotent — safe to call repeatedly, and safe to miss. A run that is a day
* late activates the same entries; there is no per-run state to lose.
*
* Point a scheduler at this daily:
* curl -X POST https://<host>/api/cron/subprocessors \
* -H "x-cron-secret: $CRON_SECRET"
*
* See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013).
*/

export const dynamic = "force-dynamic";

/** Constant-time compare so the secret cannot be probed byte by byte. */
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

export async function POST(request: NextRequest) {
const route = "POST /api/cron/subprocessors";
try {
const expected = process.env.CRON_SECRET;

// Fail CLOSED. An unset secret must not leave the endpoint open — it is the
// difference between "not scheduled yet" and "anyone can drive the register".
if (!expected) {
logger.error("[Cron] CRON_SECRET is not configured; refusing to run");
return apiError(new Error("CRON_SECRET not configured"), {
route,
status: 503,
safeMessage: "Scheduled tasks are not configured.",
});
}

const provided = request.headers.get("x-cron-secret");
if (!provided || !secretMatches(provided, expected)) {
// Deliberately terse: a caller without the secret learns nothing about
// whether the endpoint or the secret was wrong.
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const activated = await activateDueSubprocessors();

logger.info("[Cron] Sub-processor register tick", {
activated: activated.length,
});

return NextResponse.json({ success: true, activated });
} catch (error) {
return apiError(error, { route });
}
}
Loading
Loading