Migrating from 5.7.x to 6.0.0

A production-boundary hardening release. Frontend-only users get a one-line bump; anyone calling the breach assessor or running @tantainnovative/ndpr-recipes has real work to do — including a reviewed database migration.

TL;DR — find your upgrade path

6.0.0 is not a deprecation-window release. Nothing was renamed and nothing was removed, so your build will very likely keep compiling. What changed is what the toolkit is willing to conclude: compliance duties now stay switched on until complete, correlated evidence establishes otherwise, and backend identity comes from the server instead of the request. That means a green build does not mean you are done.

If you…EffortRead
Use components, hooks, presets, or policy generation onlyOne-line bump§ 5 (re-run audits), then done
Call assessBreachNotification or the JSON audit with breach inputCode review§ 1
Ran create-ndpr and copied the generated API routesWire two seams§ 2, § 4
Have a populated @tantainnovative/ndpr-recipes 0.1.x / 0.2.0 databaseReviewed DB migrationAll of it — start at § 3
pnpm up @tantainnovative/ndpr-toolkit@^6.0.0
# backend users also:
pnpm up @tantainnovative/ndpr-recipes@^0.3.0

Do not run prisma db push or drizzle-kit push against an existing recipes database. The 0.3.0 schema is tenant-scoped and those commands cannot infer tenant ownership, verified DSR subjects, consent deduplication, or lossless ROPA facts. Use the reviewed migrations/0.3.0 procedure in § 3.

1. Breach duties now fail closed

In 5.x you could tell the assessor that a breach did not require notification by passing a bare boolean. A missing, malformed, or mismatched assessment also quietly resolved to “no duty”. Under NDPA 2023 S. 40 that is the wrong default — absence of evidence is not evidence of low risk. In 6.0 the two override options are force-on only:

export interface BreachNotificationOptions {
  assessment?: RiskAssessment;
  notification?: RegulatoryNotification;
  asOf?: number;
  deadlineHours?: number;
  /** Force the high-risk data-subject duty on; a complete assessment is required to establish false. */
  highRisk?: true;
  /** Force Commission notification on; a complete assessment is required to establish false. */
  notificationRequired?: true;
}

The literal type true means TypeScript rejects false at compile time. If the value reaches the assessor at runtime anyway — from JSON, a database column, or an any cast — it is recorded in validationErrors, which sets valid: false and therefore ready: false. It does not silently waive the duty.

// Before (5.x) — this waived both duties
const assessment = assessBreachNotification(report, {
  notificationRequired: false,
  highRisk: false,
});

// After (6.0) — compile error; supply the evidence instead
const assessment = assessBreachNotification(report, {
  assessment: riskAssessment, // must correlate to report.id and validate completely
});

How each duty now resolves

Both duties default to true and can only be lowered by a correlated assessment — one that is present, whose breachId matches the report's id, and which passes every field check:

notificationRequired            = options.notificationRequired === true
                               || (correlatedAssessment?.risksToRightsAndFreedoms     ?? true)

dataSubjectCommunicationRequired = options.highRisk === true
                               || (correlatedAssessment?.highRisksToRightsAndFreedoms ?? true)

An assessment only counts as correlated when all of the following hold. Any single failure drops it, and the duty reverts to on:

  • id and breachId are non-empty strings, and breachId equals report.id.
  • assessedAt is a finite epoch timestamp between report.discoveredAt and asOf.
  • assessor is an object with non-empty name, role, and email.
  • All five scores — confidentialityImpact, integrityImpact, availabilityImpact, harmLikelihood, harmSeverity — are numbers from 1 to 5.
  • overallRiskScore is non-negative and riskLevel is one of low, medium, high, critical.
  • risksToRightsAndFreedoms and highRisksToRightsAndFreedoms are real booleans — and high risk requires plain risk to also be true, because “high risk but no risk” is impossible.
  • justification is a non-empty string.

Regulatory notification evidence is checked the same way: matching breachId, a sentAt between discovery and asOf, a method of email/portal/letter/other, and non-empty content. Incident chronology is enforced too: occurredAt ≤ discoveredAt ≤ reportedAt ≤ asOf.

What to do

  1. Grep for notificationRequired: and highRisk: at your assessor callsites. Any false is now a type error — delete it.
  2. Wherever you relied on that false, supply a complete RiskAssessment whose breachId matches the report.
  3. Re-run every stored breach-readiness assessment. Reports that read “ready” under 5.x on thin evidence will now read not-ready — that is the correction, not a regression.
  4. The same force-on-only options apply to the breach input of the JSON audit surface and the ndpr audit CLI.

Full field-by-field reference: Breach Notification Completeness.

2. Tenant and actor authority moved to the server

This applies to generated create-ndpr routes and @tantainnovative/ndpr-recipes. In 0.2.x, tenant, subject, actor, reporter, assessor, and role values could arrive from request bodies, query strings, or client headers. A caller could name themselves the approver of their own DPIA. In 0.3.0 every one of those facts comes from server configuration or a verified session.

Two seams to wire:

a. Set NDPR_TENANT_ID

A server-only environment variable. It is never read from the request. If it is missing, every recipe route returns 503 rather than defaulting to a shared or empty tenant.

# .env (server-only — never NEXT_PUBLIC_)
NDPR_TENANT_ID=acme-production

b. Implement resolveVerifiedNDPRActor

Shipped as a fail-closed stub that returns null. Until you replace it, staff routes return 401. That is deliberate: an unwired deployment denies access rather than granting it.

// app/api/ndpr/request-context.ts
import type { NextRequest } from 'next/server';
import { auth } from '@/lib/auth'; // your server-side session provider

export async function resolveVerifiedNDPRActor(
  request: NextRequest,
): Promise<NDPRVerifiedActor | null> {
  const session = await auth(request);
  if (!session?.user) return null;

  return {
    id: session.user.id,
    displayName: session.user.name,
    email: session.user.email,
    department: session.user.department,
    // Stable data-subject id for the signed-in account, when applicable
    subjectId: session.user.subjectId,
    // Map YOUR roles onto the two NDPR roles
    roles: session.user.isPrivacyTeam ? ['ndpr:staff'] : [],
  };
}

Only ndpr:staff and ndpr:admin grant staff authorization. Populate every attribute from the verified account — never from request input, or you reintroduce exactly the hole this release closes.

The route guard contract

const context = await resolveNDPRRequestContext(request);
const problem = getNDPRContextProblem(context, 'staff'); // 'tenant' | 'subject' | 'staff'
if (problem) {
  return NextResponse.json({ error: problem.error }, { status: problem.status });
}
// context.tenantId, context.actorId, context.subjectId, context.subjectSource, context.roles
StatusMeaning
503NDPR_TENANT_ID is not configured on the server
401No verified subject, or resolveVerifiedNDPRActor is still the stub
403Authenticated, but lacks ndpr:staff / ndpr:admin

The one identity a client may still assert is an anonymous capability in the X-NDPR-Subject-Id header, and only when it matches anon_<UUID>exactly. It scopes access to that subject's own consent and DSR data and never confers staff rights. Anything else in that header is ignored. Values resolved this way are marked subjectSource: 'anonymous-uuid-capability' in the accountability record, so audit output distinguishes them from 'verified-account-subject'.

3. Tenant-scoped recipes schema (database migration)

Consent, DSR, breach, ROPA, DPIA, lawful-basis, cross-border, and audit persistence are all tenant-scoped in 0.3.0. Global primary keys became tenant-scoped composite keys. If your recipes database is empty, generate fresh and skip to § 4. If it holds data, follow the reviewed procedure — it ships inside the package:

node_modules/@tantainnovative/ndpr-recipes/migrations/0.3.0/
  README.md         # the authoritative procedure — read it in full
  postgresql.sql    # standalone psql + Prisma (owns its BEGIN;/COMMIT;)
  drizzle.sql       # Drizzle-managed migration BODY (no transaction control)

Pick exactly one artifact for your runner. Never concatenate them and never add BEGIN;/COMMIT; to the Drizzle body — drizzle-kit migrate supplies the outer transaction so the migration and its ledger entry commit or roll back together.

Each artifact carries one guarded placeholder. The migration aborts if it is left in place or set empty:

'__REPLACE_WITH_TENANT_ID__'  -- must equal the deployed NDPR_TENANT_ID

Prisma

mkdir -p prisma/migrations/202607180001_ndpr_recipes_0_3_hardening
cp node_modules/@tantainnovative/ndpr-recipes/migrations/0.3.0/postgresql.sql \
  prisma/migrations/202607180001_ndpr_recipes_0_3_hardening/migration.sql
# Edit and review this exact migration.sql — especially the tenant ID.
npx prisma migrate deploy
npx prisma generate

Drizzle

npx drizzle-kit generate --custom --name ndpr-recipes-0-3-hardening
DRIZZLE_MIGRATION_SQL="drizzle/0007_ndpr-recipes-0-3-hardening.sql" # exact generated path
test -f "$DRIZZLE_MIGRATION_SQL"
cp node_modules/@tantainnovative/ndpr-recipes/migrations/0.3.0/drizzle.sql \
  "$DRIZZLE_MIGRATION_SQL"
# Edit and review this exact generated SQL file — especially the tenant ID.
npx drizzle-kit migrate

What the backfill will not guess

The script assigns the tenant, normalizes column names, dedupes active consent, converts JSON to JSONB, and runs hard-invariant checks. Three things it deliberately refuses to fabricate, because inventing compliance evidence is worse than having none:

ModuleLeft fail-closedYour remediation
DSRsubject_id = legacy_dsr_<id> placeholders, which grant no account accessLoad a reviewed request-id → account-subject-id map before enabling subject-facing DSR routes
ROPArecord_data stays NULL; routes return 409Stage snapshots and validate each with validateProcessingRecord before backfilling
BreachNo RegulatoryNotification is invented; readiness stays not-readyAttach the original filing content and reference, with sentAt between discovery and asOf

Lawful-basis activity_data and cross-border transfer_data stay nullable for the same reason. Legacy columns (internal_notes_legacy, acknowledged_at, safeguards_legacy) are retained through your rollback window — drop them only in a later reviewed migration.

Rollback reality. Before the transaction commits, any failed check rolls everything back safely. After commit but before app cutover, restore your tested snapshot — do not run 0.2.x code against renamed 0.3.0 columns. Once 0.3.0 writes begin there is no lossy down migration: freeze writes, export new audit and consent rows, and forward-reconcile under an incident plan. Take and restore-test a backup first.

5. Compliance rule changes — re-run your audits

Two changes alter output for unchanged input. Everyone upgrading is affected, including frontend-only users, and stored results computed under 5.x are stale:

  • 2026 CAR filing deadline extended to 30 May, bundled with explicit ruleset provenance. Non-canonical, inherited, accessor-backed, out-of-range, or cross-year deadline overrides are now rejected. Re-run CAR and aggregate audit output, and confirm current NDPC guidance before you file.
  • Breach duties are treated as applicable until complete correlated evidence establishes otherwise (§ 1). Re-run every stored breach-readiness assessment.
npx ndpr audit --json > audit-after-6.0.json
# diff against your pre-upgrade output and explain every change before filing

Scores dropping after this upgrade is the expected outcome where evidence was incomplete. Treat the delta as a work list, not a bug. See Legal Sources & Update Governance for how regulatory changes are reviewed and released.

What hasn't changed

  • No component, hook, or preset was renamed or removed.
  • All 22 subpath exports still resolve in ESM, CJS, and TypeScript.
  • Peer range is still react ^18 || ^19.
  • Same seven locales (en, yo, ig, ha, pcm, ar, fr).
  • NDPRProvider, NDPRThemeProvider, and the adapter contracts are unchanged.
  • The 5.x @deprecated aliases still work — including nitdaNotificationRequired and the storageKey/useLocalStorage hook options. They were not removed in 6.0.
  • The unscoped create-ndpr@1.0.0 alias is unchanged and delegates to the latest scoped CLI.

Upgrade checklist

  1. Bump to ^6.0.0 and run tsc. Fix any highRisk: false / notificationRequired: false type errors by supplying real assessments.
  2. Re-run stored breach-readiness assessments and CAR/aggregate audits. Diff against pre-upgrade output.
  3. Backend only: set server-side NDPR_TENANT_ID.
  4. Backend only: replace the resolveVerifiedNDPRActor stub and map your roles to ndpr:staff / ndpr:admin.
  5. Populated database only: back up, restore-test, freeze writes, then run the single correct migrations/0.3.0 artifact.
  6. Apply reviewed DSR subject mappings and ROPA/breach evidence backfills. Drive the review-queue counts in the migration README to zero for every workflow you intend to enable.
  7. Update clients that read raw consent rows to the canonical ConsentSettings shape, and handle 400/409.
  8. Update tests asserting hard deletion to assert revoked/archived state.
  9. Smoke-test under two tenant fixtures (or a deliberately foreign tenant id) and verify subject vs staff authorization, consent create/replay/replace/revoke, and atomic business+audit commits.

Stuck? Open an issue at github.com/mr-tanta/ndpr-toolkit/issues. Related reading: Production Recipes, CLI Scaffolder, and Migrating 4.1 → 5.0.