Skip to content

feat(legal): finish the legal workstreams + client response - #222

Merged
AutomatosAI merged 11 commits into
mainfrom
feat/finish-legal-workstreams
Jul 28, 2026
Merged

feat(legal): finish the legal workstreams + client response#222
AutomatosAI merged 11 commits into
mainfrom
feat/finish-legal-workstreams

Conversation

@AutomatosAI

@AutomatosAI AutomatosAI commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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, /aup and /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

activateDueSubprocessors existed 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-time CRON_SECRET header, 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.

⚠️ Needs a scheduler pointed at it daily. I could not add the GitHub Actions workflow — pushes touching .github/workflows are rejected over HTTPS on this remote.

Admin surfaces

  • Super-admin register screen. Makes visible what the API enforces: saving tells nobody, announcing emails every operator and opens a window that cannot be closed. Announcing confirms first. The date picker will not offer a date inside the notice period. Open objections surface at the top with the objecting operator named.
  • Operator view + objection form. The objection endpoint existed with nothing reaching it. The announcement email now points at this screen rather than only a mailbox.

Unblocks the evidence gap

pnpm compliance:purge-record prints the ledger. The counts were sitting in compliance_purge_records but 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.md answers (a)–(e), names what is still open, and discloses the Article 9 finding she did not raise.

Still needs Gerard

  • Legal entity name + registered address → also fixes BudStacks' own privacy policy, which currently names no controller at all
  • LHI's engaged role
  • Retire Dr Green from the register through the new screen, so operators get the changelog entry and notice — rather than a migration quietly asserting a legal position

Refs docs/PRDS/prd-data-protection-remediation.md

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added dashboards for managing sub-processors, announcing changes, retiring vendors, and reviewing objections.
    • Operators can view active and upcoming vendors and submit objections directly from the privacy settings area.
    • Added automated activation of sub-processors when their notice period ends.
    • Updated objection notifications to direct recipients to the dashboard.
  • Documentation
    • Added a comprehensive data protection response covering privacy policies, vendor roles, disclosures, data remediation, and open compliance items.
  • Updates
    • Removed draft-status banners from published legal pages.

…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.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@AutomatosAI, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 84f66ce3-656d-4d88-8192-f44f11a44590

📥 Commits

Reviewing files that changed from the base of the PR and between 1b2c1a7 and 0736b09.

📒 Files selected for processing (34)
  • docs/compliance/2026-07-27-article9-purge.md
  • docs/compliance/2026-07-28-data-protection-response.md
  • nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts
  • nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts
  • nextjs_space/app/api/tenant-admin/legal/route.ts
  • nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx
  • nextjs_space/app/store/[slug]/cookies/page.tsx
  • nextjs_space/app/store/[slug]/privacy/page.tsx
  • nextjs_space/app/store/[slug]/regulatory/page.tsx
  • nextjs_space/app/store/[slug]/terms/page.tsx
  • nextjs_space/app/super-admin/legal-templates/page.tsx
  • nextjs_space/app/super-admin/legal-templates/templates-client.tsx
  • nextjs_space/app/tenant-admin/legal/documents/documents-client.tsx
  • nextjs_space/app/tenant-admin/legal/documents/page.tsx
  • nextjs_space/app/tenant-admin/legal/legal-form.tsx
  • nextjs_space/app/tenant-admin/legal/page.tsx
  • nextjs_space/components/admin/SuperAdminSidebar.tsx
  • nextjs_space/components/admin/TenantAdminSidebar.tsx
  • nextjs_space/lib/legal/document-resolution.ts
  • nextjs_space/lib/legal/documents/cookies-template.ts
  • nextjs_space/lib/legal/documents/index.ts
  • nextjs_space/lib/legal/documents/regulatory-template.ts
  • nextjs_space/lib/legal/documents/terms-template.ts
  • nextjs_space/lib/legal/legal-profile-schema.ts
  • nextjs_space/lib/legal/render-policy.ts
  • nextjs_space/lib/legal/tenant-policy.ts
  • nextjs_space/lib/permissions/nav-permissions.ts
  • nextjs_space/prisma/migrations/20260728000000_legal_profile_document_fields/migration.sql
  • nextjs_space/prisma/migrations/20260728010000_tenant_legal_documents/migration.sql
  • nextjs_space/prisma/schema.prisma
  • nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
  • nextjs_space/tests/unit/custom-document-body.test.ts
  • nextjs_space/tests/unit/document-resolution.test.ts
  • nextjs_space/tests/unit/legal-documents.test.ts
📝 Walkthrough

Walkthrough

Adds documented data-protection responses, removes draft legal notices, and introduces subprocessor registration, announcement, retirement, objection, scheduled activation, and purge-ledger reporting workflows.

Changes

Compliance operations

Layer / File(s) Summary
Compliance responses and legal document surfaces
docs/compliance/..., nextjs_space/app/{aup,dpa,privacy,terms}/page.tsx, nextjs_space/app/legal/subprocessors/page.tsx
Adds the 28 July 2026 data-protection response and removes draft-review notices from legal pages.
Subprocessor management and objections
nextjs_space/app/super-admin/subprocessors/..., nextjs_space/app/tenant-admin/legal/subprocessors/..., nextjs_space/components/admin/SuperAdminSidebar.tsx, nextjs_space/lib/legal/subprocessor-announce.ts
Adds server and client interfaces for subprocessor records, announcements, retirements, objections, and dashboard-directed announcement emails.
Scheduled subprocessor activation
nextjs_space/app/api/cron/subprocessors/route.ts, nextjs_space/lib/auth-public-routes.ts, nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
Adds a timing-safe, fail-closed cron endpoint and tests its narrowly scoped public-route exemption.
Purge ledger reporting
nextjs_space/package.json, nextjs_space/scripts/compliance-purge-record.ts
Adds a command for displaying compliance purge records in formatted or JSON form.

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
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is broadly aligned with the PR, covering the legal workstreams and the client response.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/finish-legal-workstreams

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (2)
nextjs_space/tests/unit/cron-subprocessors-auth.test.ts (1)

11-29: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add handler-level authentication tests.

This suite verifies only the allow-list. Add POST tests for unset CRON_SECRET503, 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 win

Use the project-managed tsx binary.

The neighboring scripts invoke tsx directly, while this command uses npx, which can fall back to installing an undeclared package outside pnpm’s lockfile. Declare tsx and use tsx scripts/... or pnpm 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

📥 Commits

Reviewing files that changed from the base of the PR and between bccfe37 and 1b2c1a7.

📒 Files selected for processing (18)
  • docs/compliance/2026-07-28-data-protection-response.md
  • nextjs_space/app/api/cron/subprocessors/route.ts
  • nextjs_space/app/aup/page.tsx
  • nextjs_space/app/dpa/page.tsx
  • nextjs_space/app/legal/subprocessors/page.tsx
  • nextjs_space/app/privacy/page.tsx
  • nextjs_space/app/super-admin/subprocessors/page.tsx
  • nextjs_space/app/super-admin/subprocessors/register-client.tsx
  • nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx
  • nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx
  • nextjs_space/app/terms/page.tsx
  • nextjs_space/components/admin/SuperAdminSidebar.tsx
  • nextjs_space/components/legal/LegalDraftNotice.tsx
  • nextjs_space/lib/auth-public-routes.ts
  • nextjs_space/lib/legal/subprocessor-announce.ts
  • nextjs_space/package.json
  • nextjs_space/scripts/compliance-purge-record.ts
  • nextjs_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

Comment on lines +164 to +210
{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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +74 to +79
If you object, do it by <strong>${formatDate(objectionDeadline)}</strong>.
The quickest way is in your dashboard under
<strong>Privacy Policy &rsaquo; 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>.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +28 to +32
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.

Suggested change
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.

Comment on lines +46 to +54
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

Comment thread nextjs_space/tests/unit/cron-subprocessors-auth.test.ts Outdated
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
@AutomatosAI
AutomatosAI merged commit 105d683 into main Jul 28, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants