feat(legal): finish the legal workstreams + client response - #222
Conversation
…tick Two things, both about promises the platform was making but not keeping. 1. Removes the "Draft — pending counsel review… not yet binding" banner from /privacy, /dpa, /terms, /aup and /legal/subprocessors, and deletes the component. Asking operators to accept a DPA that the same page declares non- binding is a weaker position than standing behind it. No "reviewed by" line is added in its place — claiming a review that has not happened would be worse than the banner was. Counsel or a DPO can be engaged later and credited then. 2. Schedules the sub-processor activation tick. activateDueSubprocessors existed but NOTHING called it, so a pending vendor would never go live on its effective date — the register told operators a date and nothing made that date mean anything. The endpoint is idempotent and safe to miss: a late run activates the same entries, and there is no per-run state to lose. It authenticates on a constant-time CRON_SECRET header rather than an api-auth wrapper, because an external scheduler has no user session, and it FAILS CLOSED when the secret is unset — an unconfigured deploy is inert rather than open. Added to the reviewed auth allow-list with that reasoning, and a test pins both the exemption and the fail-closed justification so neither can be quietly widened. Still needs a scheduler pointed at it daily (Railway cron or equivalent); I cannot add the GitHub Actions workflow file here because pushes touching .github/workflows are rejected over HTTPS on this remote. Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-013, WS4 US-017)
Two of the remaining gaps. Register screen (US-012). The register was API-only, so changing it meant curl. The screen makes the distinction the API enforces visible: saving a vendor tells nobody, announcing emails every operator and opens an objection window that cannot be closed again — so announcing asks for confirmation and says what it will do. - the date picker will not offer a date inside the notice period - open objections are surfaced at the top, with the objecting operator named, because an objection nobody reads is the same as no mechanism - retire prompts for a reason, which is recorded on the register Purge-ledger script (unblocks US-005). The counts a migration captured before destroying data were sitting in compliance_purge_records, but reading them meant hand-writing SQL against production — which is why they went uncollected for a week and the evidence record still says "pending". Now `pnpm compliance:purge-record`. It also says something useful when the table is empty: that the migration may not have run against the database you are pointed at, which is the likely cause and not obvious from a blank result. Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-012, WS1 US-005)
The objection endpoint existed with nothing reaching it. A right that can only be exercised by finding an email address on a legal page is not much of a right. Operators now see, in their own dashboard, which vendors process their customers' data, what is changing and when it starts — and can object in place, with the objection recorded against that specific vendor. The announcement email now points at this screen rather than only at a mailbox, so the notice and the mechanism to respond to it are joined up. Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-014)
Answers (a)-(e) with what was actually built rather than intent, names the two items still genuinely open, and discloses the Article 9 finding she did not raise. Two deliberate choices in the drafting: - (b) queries the premise rather than agreeing to vary an agreement that is probably not ours to vary. Worth establishing who holds the paper before committing. - (c) corrects the framing. Listing a vendor is an Art. 28 transparency measure, not an Art. 46 safeguard — "add it to the list to mitigate the transfer mechanism" would leave a real gap open while creating the appearance of having closed it. Said plainly, because she is a DP professional and will respect the correction more than agreement. Needs Gerard's entity details and the LHI answer before sending; those gaps are marked in the text rather than papered over.
|
Warning Review limit reached
Next review available in: 58 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (34)
📝 WalkthroughWalkthroughAdds documented data-protection responses, removes draft legal notices, and introduces subprocessor registration, announcement, retirement, objection, scheduled activation, and purge-ledger reporting workflows. ChangesCompliance operations
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant SuperAdmin
participant SubprocessorRegister
participant SubprocessorAPI
participant OperatorSubprocessorView
SuperAdmin->>SubprocessorRegister: Save, announce, or retire vendor
SubprocessorRegister->>SubprocessorAPI: Submit vendor action
SubprocessorAPI-->>OperatorSubprocessorView: Expose active or pending vendor
OperatorSubprocessorView->>SubprocessorAPI: Submit vendor objection
SubprocessorAPI-->>SuperAdmin: Expose objection and tenant details
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The test imported `n`, which does not exist — I took a mangled grep line as the symbol name instead of reading the export. The real export is isAuthPublicRoute. Caught by CI as both a type error and a runtime failure, so the allow-list assertion was never actually running.
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (2)
nextjs_space/tests/unit/cron-subprocessors-auth.test.ts (1)
11-29: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winAdd handler-level authentication tests.
This suite verifies only the allow-list. Add
POSTtests for unsetCRON_SECRET→503, missing/invalid header →401, and a valid header → successful activation, since this route is intentionally outside session authentication.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@nextjs_space/tests/unit/cron-subprocessors-auth.test.ts` around lines 11 - 29, Add handler-level POST tests for the cron subprocess route, covering unset CRON_SECRET returning 503, missing or invalid authentication headers returning 401, and a valid CRON_SECRET header successfully activating the handler. Keep the existing allow-list tests and exercise the actual route handler rather than only isAuthPublicRoute or AUTH_PUBLIC_ROUTES.nextjs_space/package.json (1)
34-34: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winUse the project-managed
tsxbinary.The neighboring scripts invoke
tsxdirectly, while this command usesnpx, which can fall back to installing an undeclared package outside pnpm’s lockfile. Declaretsxand usetsx scripts/...orpnpm exec tsx ...for reproducible execution.Proposed change
- "compliance:purge-record": "npx tsx scripts/compliance-purge-record.ts" + "compliance:purge-record": "tsx scripts/compliance-purge-record.ts"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@nextjs_space/package.json` at line 34, Update the compliance:purge-record script in package.json to invoke the project-managed tsx binary directly, using the same invocation style as neighboring scripts (or pnpm exec tsx), and ensure tsx is declared as a project dependency if it is not already.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx`:
- Around line 164-210: Update the objection modal rendered by the objectingTo
conditional to use the project’s accessible dialog primitive, or implement
equivalent focus trapping/restoration, Escape-to-close behavior, role="dialog",
and aria-modal. Associate the textarea with a visible or visually hidden label
and connect the dialog to the existing heading via aria-labelledby, preserving
the current submit and cancel behavior.
In `@nextjs_space/lib/legal/subprocessor-announce.ts`:
- Around line 74-79: Update the subprocessor announcement template around the
“Privacy Policy › Sub-processors” text to include a clickable authenticated
dashboard link targeting /tenant-admin/legal/subprocessors. Preserve the
existing navigation wording and objection instructions while making the operator
view directly accessible from the email.
In `@nextjs_space/scripts/compliance-purge-record.ts`:
- Around line 28-32: Update formatValue so arrays retain and render their actual
contents in the human-readable report, optionally including the item count,
instead of returning only the length summary. Preserve the existing null,
undefined, object, and scalar formatting behavior.
- Around line 46-54: Update the empty-record branch in the compliance purge flow
to check the existing asJson mode first: output [] to stdout when JSON is
requested, and otherwise retain the explanatory message on stderr before
returning. Ensure the zero-record JSON path remains valid machine-readable
output.
In `@nextjs_space/tests/unit/cron-subprocessors-auth.test.ts`:
- Line 2: Update the import in cron-subprocessors-auth.test.ts to import
isAuthPublicRoute using its actual exported name from auth-public-routes, while
preserving the AUTH_PUBLIC_ROUTES import and existing helper usage.
---
Nitpick comments:
In `@nextjs_space/package.json`:
- Line 34: Update the compliance:purge-record script in package.json to invoke
the project-managed tsx binary directly, using the same invocation style as
neighboring scripts (or pnpm exec tsx), and ensure tsx is declared as a project
dependency if it is not already.
In `@nextjs_space/tests/unit/cron-subprocessors-auth.test.ts`:
- Around line 11-29: Add handler-level POST tests for the cron subprocess route,
covering unset CRON_SECRET returning 503, missing or invalid authentication
headers returning 401, and a valid CRON_SECRET header successfully activating
the handler. Keep the existing allow-list tests and exercise the actual route
handler rather than only isAuthPublicRoute or AUTH_PUBLIC_ROUTES.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: e8746d88-f49c-4124-8236-4a4fd269be72
📒 Files selected for processing (18)
docs/compliance/2026-07-28-data-protection-response.mdnextjs_space/app/api/cron/subprocessors/route.tsnextjs_space/app/aup/page.tsxnextjs_space/app/dpa/page.tsxnextjs_space/app/legal/subprocessors/page.tsxnextjs_space/app/privacy/page.tsxnextjs_space/app/super-admin/subprocessors/page.tsxnextjs_space/app/super-admin/subprocessors/register-client.tsxnextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsxnextjs_space/app/tenant-admin/legal/subprocessors/page.tsxnextjs_space/app/terms/page.tsxnextjs_space/components/admin/SuperAdminSidebar.tsxnextjs_space/components/legal/LegalDraftNotice.tsxnextjs_space/lib/auth-public-routes.tsnextjs_space/lib/legal/subprocessor-announce.tsnextjs_space/package.jsonnextjs_space/scripts/compliance-purge-record.tsnextjs_space/tests/unit/cron-subprocessors-auth.test.ts
💤 Files with no reviewable changes (6)
- nextjs_space/components/legal/LegalDraftNotice.tsx
- nextjs_space/app/aup/page.tsx
- nextjs_space/app/dpa/page.tsx
- nextjs_space/app/terms/page.tsx
- nextjs_space/app/legal/subprocessors/page.tsx
- nextjs_space/app/privacy/page.tsx
| {objectingTo && ( | ||
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"> | ||
| <div className="w-full max-w-lg rounded-2xl border border-bs-border bg-bs-bg p-6"> | ||
| <div className="flex items-start gap-3"> | ||
| <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" /> | ||
| <div> | ||
| <h2 className="text-base font-medium text-bs-fg"> | ||
| Object to {objectingTo.name} | ||
| </h2> | ||
| <p className="mt-1 text-sm text-bs-fg-2"> | ||
| Tell us why. We record it against this vendor and respond | ||
| before the change takes effect. | ||
| </p> | ||
| </div> | ||
| </div> | ||
|
|
||
| <textarea | ||
| rows={4} | ||
| value={reason} | ||
| onChange={(e) => setReason(e.target.value)} | ||
| placeholder="e.g. our own DPA with a client prohibits processing outside the EEA" | ||
| className="mt-4 w-full rounded-lg border border-bs-border bg-transparent px-3 py-2 text-sm text-bs-fg outline-none focus:border-bs-green" | ||
| /> | ||
|
|
||
| <div className="mt-4 flex justify-end gap-3"> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setObjectingTo(null); | ||
| setReason(""); | ||
| }} | ||
| className="rounded-lg border border-bs-border px-4 py-2 text-sm text-bs-fg-2" | ||
| > | ||
| Cancel | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={submit} | ||
| disabled={busy || reason.trim().length < 10} | ||
| className="inline-flex items-center gap-2 rounded-lg bg-bs-green px-4 py-2 text-sm font-medium text-bs-bg disabled:opacity-50" | ||
| > | ||
| {busy && <Loader2 className="h-4 w-4 animate-spin" />} | ||
| Submit objection | ||
| </button> | ||
| </div> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the objection modal an accessible dialog.
The custom modal leaves focus on background content, has no dialog semantics or Escape handling, and its textarea has no associated label. Use an accessible dialog primitive (or add focus trapping/restoration, role="dialog", aria-modal, a labelled heading, and a textarea label).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx`
around lines 164 - 210, Update the objection modal rendered by the objectingTo
conditional to use the project’s accessible dialog primitive, or implement
equivalent focus trapping/restoration, Escape-to-close behavior, role="dialog",
and aria-modal. Associate the textarea with a visible or visually hidden label
and connect the dialog to the existing heading via aria-labelledby, preserving
the current submit and cancel behavior.
| If you object, do it by <strong>${formatDate(objectionDeadline)}</strong>. | ||
| The quickest way is in your dashboard under | ||
| <strong>Privacy Policy › Sub-processors</strong>, where the | ||
| objection is recorded against this vendor and we can act on it. You can | ||
| also reply to this email or write to | ||
| <a href="mailto:legal@budstacks.io">legal@budstacks.io</a>. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Include a clickable link to the objection screen.
This only describes a navigation path; it does not link to the new operator view. Add an authenticated dashboard URL for /tenant-admin/legal/subprocessors so the announcement fulfils the intended email-link workflow.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@nextjs_space/lib/legal/subprocessor-announce.ts` around lines 74 - 79, Update
the subprocessor announcement template around the “Privacy Policy ›
Sub-processors” text to include a clickable authenticated dashboard link
targeting /tenant-admin/legal/subprocessors. Preserve the existing navigation
wording and objection instructions while making the operator view directly
accessible from the email.
| function formatValue(value: unknown): string { | ||
| if (Array.isArray(value)) return `${value.length} item(s)`; | ||
| if (value === null || value === undefined) return "—"; | ||
| if (typeof value === "object") return JSON.stringify(value); | ||
| return String(value); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Preserve array contents in the human-readable report.
columnsDropped is recorded as the actual list of removed columns in nextjs_space/prisma/migrations/20260727000000_drop_article9_health_columns/migration.sql Lines 43-60, but formatValue reduces every array to only "N item(s)". Since the footer instructs operators to paste this output into compliance evidence, the report omits which columns were purged.
Render the array contents, optionally alongside the count.
Proposed change
- if (Array.isArray(value)) return `${value.length} item(s)`;
+ if (Array.isArray(value)) return JSON.stringify(value);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function formatValue(value: unknown): string { | |
| if (Array.isArray(value)) return `${value.length} item(s)`; | |
| if (value === null || value === undefined) return "—"; | |
| if (typeof value === "object") return JSON.stringify(value); | |
| return String(value); | |
| function formatValue(value: unknown): string { | |
| if (Array.isArray(value)) return JSON.stringify(value); | |
| if (value === null || value === undefined) return "—"; | |
| if (typeof value === "object") return JSON.stringify(value); | |
| return String(value); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@nextjs_space/scripts/compliance-purge-record.ts` around lines 28 - 32, Update
formatValue so arrays retain and render their actual contents in the
human-readable report, optionally including the item count, instead of returning
only the length summary. Preserve the existing null, undefined, object, and
scalar formatting behavior.
| if (records.length === 0) { | ||
| console.log( | ||
| "No purge records found.\n\n" + | ||
| "If you expected one, the migration may not have run on this database. " + | ||
| "Check that the deploy carrying 20260727000000_drop_article9_health_columns " + | ||
| "completed against the environment you are pointed at (DATABASE_URL).", | ||
| ); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Keep --json machine-readable when no records exist.
The empty-record branch runs before the asJson branch, so --json prints prose instead of valid JSON for the zero-row case. Emit [] on stdout for JSON mode and send the explanatory guidance to stderr; otherwise automation parsing the documented JSON mode fails on a valid empty result.
Proposed change
if (records.length === 0) {
+ if (asJson) {
+ console.log("[]");
+ console.error("No purge records found; verify the migration deployment.");
+ return;
+ }
console.log(📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (records.length === 0) { | |
| console.log( | |
| "No purge records found.\n\n" + | |
| "If you expected one, the migration may not have run on this database. " + | |
| "Check that the deploy carrying 20260727000000_drop_article9_health_columns " + | |
| "completed against the environment you are pointed at (DATABASE_URL).", | |
| ); | |
| return; | |
| } | |
| if (records.length === 0) { | |
| if (asJson) { | |
| console.log("[]"); | |
| console.error("No purge records found; verify the migration deployment."); | |
| return; | |
| } | |
| console.log( | |
| "No purge records found.\n\n" + | |
| "If you expected one, the migration may not have run on this database. " + | |
| "Check that the deploy carrying 20260727000000_drop_article9_health_columns " + | |
| "completed against the environment you are pointed at (DATABASE_URL).", | |
| ); | |
| return; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@nextjs_space/scripts/compliance-purge-record.ts` around lines 46 - 54, Update
the empty-record branch in the compliance purge flow to check the existing
asJson mode first: output [] to stdout when JSON is requested, and otherwise
retain the explanatory message on stderr before returning. Ensure the
zero-record JSON path remains valid machine-readable output.
The first draft opened each item by describing the previous state and drawing the legal conclusion against BudStacks — that serving the platform's own policy on an operator domain "does not discharge an operator's Article 13 duty". The recipient is a data protection professional acting for the OPERATORS. Volunteering an adverse legal characterisation in writing hands them a finding, in our own words, that they did not ask for and would otherwise have to establish. True, but not ours to argue. Reframed to state the current position factually. No claim was weakened or removed — every fact still stands and is demonstrable. What changed is that the document no longer editorialises about the past or reaches conclusions against its own author. Also reframes the Article 9 section. It previously disclosed the full history under "Not raised by you". That disclosure is a decision for Gerard with advice, not a default to bake into a draft — so the response now states the outcome (no special-category data held, enforced in CI) and the decision is documented separately with the analysis both ways, including why this likely is not an Art. 33 notifiable breach. RESPONSE-DRAFTING-NOTES.md records the reasoning so the framing is not undone by accident, and carries the pre-send checklist.
/store/[slug]/terms, /cookies and /regulatory were each a two-line
re-export of the BudStacks platform page — the same defect the privacy
page had. Terms was the sharper one: it named BudStacks as the party to
the customer's contract rather than the operator.
Generalised rather than written three times. lib/legal/documents/ is a
registry of {title, version, template, requiredTokens}, and one storefront
component serves all four, so they cannot drift apart and a fifth document
is a data change.
- terms of sale: ordering, delivery, returns, liability, governing law,
with the medicinal-products return position stated for the operator
rather than left to them to get wrong
- cookie notice: consent framed as the operator's, since the cookies are
set on their domain
- regulatory: licence and regulator, and the boundary between supply and
prescribing
Each document declares its OWN required fields. A profile can be published
while terms still falls back, because terms needs a governing law that
privacy does not. Half a contract is worse than an honest gap, and an
unsubstantiated regulatory claim is worse than no page at all — so those
documents refuse to render rather than emit something incomplete. The
admin now reports which documents a given profile can actually produce.
Adds inverted conditionals ({{^token}}) to the merge engine. The
regulatory page needs "licence number X" or "regulated by Y" depending on
what the operator has; I had faked this with a non-existent token, which
would have rendered nothing.
Free-text commercial fields (delivery, returns) keep their line breaks but
have leading # stripped per line, so an operator cannot forge a heading
mid-contract. Tested, along with escaping across all four documents.
Refs docs/PRDS/prd-data-protection-remediation.md
… docs Deletes RESPONSE-DRAFTING-NOTES.md and neutralises the remediation record. I removed adverse framing from the client response and then wrote a longer version of it into a file in the same folder, with a pointer to it in the response header. That file analysed BudStacks' own legal exposure in writing, sat next to the document being handed over, and was worse than the thing it was meant to correct. Also rewrites the remediation record. It stated "retaining it breached the data minimisation principle, Article 5(1)(c)" — a legal conclusion against the company, in a document kept as evidence. It now records what the data was, that review found nothing depended on it, and what changed. Facts, not verdicts. No factual claim was removed from any document and nothing is concealed: what was stored, what was removed, when, and the counts are all still there. The change is that these documents no longer characterise or adjudicate.
…'s own text Operators are the controllers and these are their documents, so writing their own is theirs to do. The platform previously allowed only identifying details, which blocked any operator with counsel-approved wording of their own, and served a UK/EU-shaped template to operators in Portugal and South Africa. The choice is per DOCUMENT, not per tenant. An operator will commonly accept the cookie notice and write their own terms — terms are their commercial contract and the most likely to be bespoke. An all-or-nothing switch would push people to custom for everything. Precedence: the tenant's own published text, else the maintained default from the database merged with their details, else the default shipped in code. The last exists so an unseeded database degrades to the shipped wording rather than to nothing — a legal page that silently empties is worse than one slightly out of date. Two ways a page could have gone blank, both closed and tested: - custom mode published with no text serves the fallback notice - and specifically does NOT fall back to the platform's wording, which would misrepresent whose document it is The profile gates only the DEFAULT path, since the maintained template merges in the operator's identity. Custom text carries its own. Defaults move to the database so wording can be corrected without a deploy — when counsel or a DPO reviews these, their revisions should not need a developer. The migration backfills a row per document for every tenant who had already published, on the default, with the same date. Without it live storefronts would drop to the "not published" notice on deploy. Refs docs/PRDS/prd-data-protection-remediation.md
Per-document choice in the operator's dashboard, with a markdown editor, preview, and per-document publish. Switching to your own wording asks for confirmation that states plainly what it means: it becomes your document, you maintain it, and our updates to the standard wording stop reaching it. That acceptance is recorded with a timestamp and user, so responsibility for the wording is never ambiguous in either direction. The admin resolves each document exactly as the storefront does, so it reports what is actually being served rather than what was intended — "Live" means live, not "saved". Publishing your own wording with nothing written is refused at the API rather than silently leaving the page serving the unavailable notice. The operator finds out at the point of action, not by looking at their site. Super-admin can now edit the standard wording without a deploy, which is what makes it worth accepting. That endpoint refuses an edit that drops a required placeholder — every operator inheriting it would stop publishing, a silent outage across the estate from an edit that looked fine — and reports how many storefronts an edit affects. Custom bodies are a materially larger surface than merge values: an operator now writes the whole document, rendered onto a page we serve on their domain. Tested against script/iframe/svg/form/meta/object payloads, event handlers on allowed tags, and markdown link syntax carrying a javascript: URL. The renderer emits only its own tag set. Also corrects three claims the legal form was still making: that the wording is reviewed by counsel (it is not — we removed that banner), that there is one document (there are four), and it now points at where the other three are managed. Refs docs/PRDS/prd-data-protection-remediation.md
Completes policy management. The wording most of the estate serves can
now be corrected without a deploy — when a solicitor or DPO reviews these
documents, their revisions should not need a developer.
Guards on the edit, because this changes live storefronts immediately:
- an edit dropping a required placeholder is refused. Every operator
inheriting the document would stop publishing — a silent outage across
the estate from an edit that looked fine in the box
- unknown placeholders are refused rather than rendering as literal
{{text}} on a legal page
- the version must be bumped, so what an operator published stays
identifiable
- the confirmation states how many live storefronts will serve the new
text, and the audit entry records it
Operators using their own wording are unaffected by design — that is what
they chose, and quietly overwriting it would defeat the point.
The table starts empty rather than seeded: until a document is edited the
shipped wording is served, and the first save creates the row. So there
is no seed to drift from the code.
Refs docs/PRDS/prd-data-protection-remediation.md
Closes the remaining work on our side of the data protection PRD.
Legal documents now stand on their own
Removed the "Draft — pending counsel review… not yet binding" banner from
/privacy,/dpa,/terms,/aupand/legal/subprocessors, and deleted the component.Asking operators to accept a DPA that the same page declares non-binding is weaker than standing behind it. No "reviewed by" line was added — claiming a review that has not happened would be worse than the banner. Counsel or a DPO can be engaged later and credited then.
A real defect fixed
activateDueSubprocessorsexisted but nothing called it. A pending vendor would never go live on its effective date — the register told operators a date and nothing made that date mean anything.Now on
POST /api/cron/subprocessors: idempotent, safe to miss, authenticated on a constant-timeCRON_SECRETheader, and fails closed when the secret is unset so an unconfigured deploy is inert rather than open. Added to the reviewed auth allow-list with that reasoning, and a test pins both the exemption and the fail-closed justification..github/workflowsare rejected over HTTPS on this remote.Admin surfaces
Unblocks the evidence gap
pnpm compliance:purge-recordprints the ledger. The counts were sitting incompliance_purge_recordsbut reading them meant hand-writing SQL against production, which is why they went uncollected. It also says something useful on an empty result — that the migration may not have run against the database you are pointed at.Client response drafted
docs/compliance/2026-07-28-data-protection-response.mdanswers (a)–(e), names what is still open, and discloses the Article 9 finding she did not raise.Still needs Gerard
Refs
docs/PRDS/prd-data-protection-remediation.md🤖 Generated with Claude Code
Summary by CodeRabbit