Skip to content

Commit f121d6a

Browse files
committed
fix(V2): guard shared deletes against staged references
Add referential integrity guards for methodology, program, stakeholder, and label deletes so they block when links exist in committed tables or in staged INSERT/UPDATE rows, with explicit force-delete override support. Extend integration coverage for live and staged reference conflicts and update live API delete helpers/docs to reflect and exercise the guard flow.
1 parent c235605 commit f121d6a

16 files changed

Lines changed: 730 additions & 30 deletions

docs/cadt_rpc_api_v2.md

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1890,20 +1890,38 @@ Response
18901890

18911891
**Note**: The ID in the URL path is the `cadTrustMethodologyId`.
18921892

1893+
**Referential integrity**: If any committed or staged (`INSERT`/`UPDATE`) `project_methodology` records reference this methodology, the request returns `409 Conflict`. Pass `?force=true` to bypass the guard and stage the delete anyway.
1894+
18931895
Request
18941896
```shell
18951897
curl --location --request DELETE 'localhost:31310/v2/methodology/9b9bb857-c71b-4649-b805-a289db27dc1c' \
18961898
--header 'Content-Type: application/json'
18971899
```
18981900

1899-
Response
1901+
Response (success — no references)
19001902
```json
19011903
{
1902-
"message": "Methodology deletion staged successfully",
1904+
"message": "Methodology delete staged successfully",
19031905
"success": true
19041906
}
19051907
```
19061908

1909+
Response (409 — references exist)
1910+
```json
1911+
{
1912+
"success": false,
1913+
"message": "Cannot delete methodology: referenced by 2 project-methodology links",
1914+
"references": [{ "table": "project_methodology", "count": 2 }],
1915+
"hint": "Remove all references first, or use ?force=true to delete anyway"
1916+
}
1917+
```
1918+
1919+
Force delete (bypass guard)
1920+
```shell
1921+
curl --location --request DELETE 'localhost:31310/v2/methodology/9b9bb857-c71b-4649-b805-a289db27dc1c?force=true' \
1922+
--header 'Content-Type: application/json'
1923+
```
1924+
19071925
---
19081926

19091927
## `program`
@@ -2058,20 +2076,38 @@ Response
20582076

20592077
**Note**: The ID in the URL path is the `cadTrustProgramId`.
20602078

2079+
**Referential integrity**: If any committed or staged (`INSERT`/`UPDATE`) `project` records reference this program via `cadTrustProgramId`, the request returns `409 Conflict`. Pass `?force=true` to bypass the guard and stage the delete anyway.
2080+
20612081
Request
20622082
```shell
20632083
curl --location --request DELETE 'localhost:31310/v2/program/51ca9638-22b0-4e14-ae7a-c09d23b37b58' \
20642084
--header 'Content-Type: application/json'
20652085
```
20662086

2067-
Response
2087+
Response (success — no references)
20682088
```json
20692089
{
2070-
"message": "Program deletion staged successfully",
2090+
"message": "Program delete staged successfully",
20712091
"success": true
20722092
}
20732093
```
20742094

2095+
Response (409 — references exist)
2096+
```json
2097+
{
2098+
"success": false,
2099+
"message": "Cannot delete program: referenced by 3 projects",
2100+
"references": [{ "table": "project", "count": 3 }],
2101+
"hint": "Remove all references first, or use ?force=true to delete anyway"
2102+
}
2103+
```
2104+
2105+
Force delete (bypass guard)
2106+
```shell
2107+
curl --location --request DELETE 'localhost:31310/v2/program/51ca9638-22b0-4e14-ae7a-c09d23b37b58?force=true' \
2108+
--header 'Content-Type: application/json'
2109+
```
2110+
20752111
---
20762112

20772113
## `project`
@@ -4527,20 +4563,38 @@ Response
45274563

45284564
**Note**: The ID in the URL path is the `cadTrustStakeholderId`.
45294565

4566+
**Referential integrity**: If any committed or staged (`INSERT`/`UPDATE`) `stakeholder_projects` records reference this stakeholder, the request returns `409 Conflict`. Pass `?force=true` to bypass the guard and stage the delete anyway.
4567+
45304568
Request
45314569
```shell
45324570
curl --location --request DELETE 'localhost:31310/v2/stakeholder/e880047e-cdf4-45bb-a9df-e706fa427713' \
45334571
--header 'Content-Type: application/json'
45344572
```
45354573

4536-
Response
4574+
Response (success — no references)
45374575
```json
45384576
{
4539-
"message": "Stakeholder deletion staged successfully",
4577+
"message": "Stakeholder delete staged successfully",
45404578
"success": true
45414579
}
45424580
```
45434581

4582+
Response (409 — references exist)
4583+
```json
4584+
{
4585+
"success": false,
4586+
"message": "Cannot delete stakeholder: referenced by 1 stakeholder-project links",
4587+
"references": [{ "table": "stakeholder_projects", "count": 1 }],
4588+
"hint": "Remove all references first, or use ?force=true to delete anyway"
4589+
}
4590+
```
4591+
4592+
Force delete (bypass guard)
4593+
```shell
4594+
curl --location --request DELETE 'localhost:31310/v2/stakeholder/e880047e-cdf4-45bb-a9df-e706fa427713?force=true' \
4595+
--header 'Content-Type: application/json'
4596+
```
4597+
45444598
---
45454599

45464600
## `stakeholder-projects`
@@ -4837,20 +4891,38 @@ Response
48374891

48384892
**Note**: The ID in the URL path is the `cadTrustLabelId`.
48394893

4894+
**Referential integrity**: If any committed or staged (`INSERT`/`UPDATE`) `unit_label` records reference this label, the request returns `409 Conflict`. Pass `?force=true` to bypass the guard and stage the delete anyway.
4895+
48404896
Request
48414897
```shell
48424898
curl --location --request DELETE 'localhost:31310/v2/label/dcacd68e-1cfb-4f06-9798-efa0aacda42c' \
48434899
--header 'Content-Type: application/json'
48444900
```
48454901

4846-
Response
4902+
Response (success — no references)
48474903
```json
48484904
{
4849-
"message": "Label deletion staged successfully",
4905+
"message": "Label delete staged successfully",
48504906
"success": true
48514907
}
48524908
```
48534909

4910+
Response (409 — references exist)
4911+
```json
4912+
{
4913+
"success": false,
4914+
"message": "Cannot delete label: referenced by 4 unit-label links",
4915+
"references": [{ "table": "unit_label", "count": 4 }],
4916+
"hint": "Remove all references first, or use ?force=true to delete anyway"
4917+
}
4918+
```
4919+
4920+
Force delete (bypass guard)
4921+
```shell
4922+
curl --location --request DELETE 'localhost:31310/v2/label/dcacd68e-1cfb-4f06-9798-efa0aacda42c?force=true' \
4923+
--header 'Content-Type: application/json'
4924+
```
4925+
48544926
---
48554927

48564928
## `unit-label`

src/controllers/v2/label-v2.controller.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import _ from 'lodash';
44
import { v4 as uuidv4 } from 'uuid';
55

66
import { StagingV2, LabelV2, OrganizationsV2 } from '../../models/v2/index.js';
7+
import { checkReferences, buildReferenceConflictBody } from '../../utils/v2-reference-guards.js';
78
import { labelV2Schema } from '../../validations/v2/label-v2.validations.js';
89
import {
910
assertV2IfReadOnlyMode,
@@ -263,7 +264,6 @@ export const deleteLabelV2 = async (req, res) => {
263264

264265
const { id } = req.params;
265266

266-
// Verify record exists
267267
const existingRecord = await LabelV2.findByPk(id);
268268
if (!existingRecord) {
269269
return res.status(404).json({
@@ -272,12 +272,19 @@ export const deleteLabelV2 = async (req, res) => {
272272
});
273273
}
274274

275-
// Stage the delete
275+
const force = req.query.force === 'true';
276+
if (!force) {
277+
const refResult = await checkReferences('label', id);
278+
if (refResult.hasReferences) {
279+
return res.status(409).json(buildReferenceConflictBody('label', refResult));
280+
}
281+
}
282+
276283
await StagingV2.create({
277284
uuid: uuidv4(),
278285
table: 'label',
279286
action: 'DELETE',
280-
data: JSON.stringify([{ cad_trust_label_id: id }]), // Use UUID string directly
287+
data: JSON.stringify([{ cad_trust_label_id: id }]),
281288
committed: false,
282289
failed_commit: false,
283290
is_transfer: false,

src/controllers/v2/methodology-v2.controller.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
55
import { Sequelize } from 'sequelize';
66

77
import { StagingV2, MethodologyV2, OrganizationsV2 } from '../../models/v2/index.js';
8+
import { checkReferences, buildReferenceConflictBody } from '../../utils/v2-reference-guards.js';
89

910
import {
1011
optionallyPaginatedResponse,
@@ -267,7 +268,6 @@ export const destroy = async (req, res) => {
267268

268269
const { id } = req.params;
269270

270-
// Verify record exists
271271
const existingRecord = await MethodologyV2.findByPk(id);
272272
if (!existingRecord) {
273273
return res.status(404).json({
@@ -276,7 +276,14 @@ export const destroy = async (req, res) => {
276276
});
277277
}
278278

279-
// Stage the delete
279+
const force = req.query.force === 'true';
280+
if (!force) {
281+
const refResult = await checkReferences('methodology', id);
282+
if (refResult.hasReferences) {
283+
return res.status(409).json(buildReferenceConflictBody('methodology', refResult));
284+
}
285+
}
286+
280287
await StagingV2.create({
281288
uuid: uuidv4(),
282289
table: 'methodology',

src/controllers/v2/program-v2.controller.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
55
import { Sequelize } from 'sequelize';
66

77
import { StagingV2, ProgramV2, OrganizationsV2 } from '../../models/v2/index.js';
8+
import { checkReferences, buildReferenceConflictBody } from '../../utils/v2-reference-guards.js';
89

910
import {
1011
optionallyPaginatedResponse,
@@ -256,7 +257,6 @@ export const destroy = async (req, res) => {
256257

257258
const { id } = req.params;
258259

259-
// Verify record exists
260260
const existingRecord = await ProgramV2.findByPk(id);
261261
if (!existingRecord) {
262262
return res.status(404).json({
@@ -265,12 +265,19 @@ export const destroy = async (req, res) => {
265265
});
266266
}
267267

268-
// Stage the delete
268+
const force = req.query.force === 'true';
269+
if (!force) {
270+
const refResult = await checkReferences('program', id);
271+
if (refResult.hasReferences) {
272+
return res.status(409).json(buildReferenceConflictBody('program', refResult));
273+
}
274+
}
275+
269276
await StagingV2.create({
270277
uuid: uuidv4(),
271278
table: 'program',
272279
action: 'DELETE',
273-
data: JSON.stringify([{ cad_trust_program_id: id }]), // Use UUID string directly
280+
data: JSON.stringify([{ cad_trust_program_id: id }]),
274281
committed: false,
275282
failed_commit: false,
276283
is_transfer: false,

src/controllers/v2/stakeholder-v2.controller.js

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import _ from 'lodash';
44
import { v4 as uuidv4 } from 'uuid';
55

66
import { StagingV2, StakeholderV2, OrganizationsV2 } from '../../models/v2/index.js';
7+
import { checkReferences, buildReferenceConflictBody } from '../../utils/v2-reference-guards.js';
78
import { stakeholderV2Schema } from '../../validations/v2/stakeholder-v2.validations.js';
89
import {
910
assertV2IfReadOnlyMode,
@@ -254,7 +255,6 @@ export const deleteStakeholderV2 = async (req, res) => {
254255

255256
const { id } = req.params;
256257

257-
// Verify record exists
258258
const existingRecord = await StakeholderV2.findByPk(id);
259259
if (!existingRecord) {
260260
return res.status(404).json({
@@ -263,12 +263,19 @@ export const deleteStakeholderV2 = async (req, res) => {
263263
});
264264
}
265265

266-
// Stage the delete
266+
const force = req.query.force === 'true';
267+
if (!force) {
268+
const refResult = await checkReferences('stakeholder', id);
269+
if (refResult.hasReferences) {
270+
return res.status(409).json(buildReferenceConflictBody('stakeholder', refResult));
271+
}
272+
}
273+
267274
await StagingV2.create({
268275
uuid: uuidv4(),
269276
table: 'stakeholder',
270277
action: 'DELETE',
271-
data: JSON.stringify([{ cad_trust_stakeholder_id: id }]), // Use UUID string directly
278+
data: JSON.stringify([{ cad_trust_stakeholder_id: id }]),
272279
committed: false,
273280
failed_commit: false,
274281
is_transfer: false,

src/utils/v2-cascade-delete.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
'use strict';
22

3+
/**
4+
* V2 Cascade Delete — Design Rationale
5+
*
6+
* CADT records fall into two categories with fundamentally different deletion
7+
* strategies. Understanding this distinction is critical before modifying any
8+
* delete logic.
9+
*
10+
* ## 1. Project-scoped records — CASCADE on delete
11+
*
12+
* These are exclusively owned by a single project (or by a single unit in the
13+
* case of unit_label). No other project or registry can reference them, so they
14+
* are safe to cascade-delete when their parent is removed.
15+
*
16+
* project → location, estimation, rating, co_benefit, validation,
17+
* verification, project_methodology (link), stakeholder_projects (link)
18+
* verification → issuance
19+
* issuance → unit
20+
* unit → unit_label
21+
*
22+
* When a project is deleted, the full chain above is traversed and every child
23+
* gets a staged DELETE entry. When a unit is deleted, its unit_label rows are
24+
* cascade-staged. The same applies to mid-chain deletes of verification or
25+
* issuance — their downstream children must also be staged.
26+
*
27+
* ## 2. Shared / standalone records — DO NOT cascade
28+
*
29+
* These exist independently with their own org_uid and can be cross-referenced
30+
* by any registry on the network:
31+
*
32+
* methodology — referenced via project_methodology from any project
33+
* stakeholder — referenced via stakeholder_projects from any project
34+
* program — referenced via cad_trust_program_id from any project
35+
* label — referenced via unit_label from any unit
36+
*
37+
* Hard-deleting a shared record propagates through DataLayer sync to every
38+
* subscriber, silently breaking referential integrity for any registry that
39+
* still references it. There is no mechanism to notify affected registries.
40+
*
41+
* These records intentionally do NOT cascade. The correct approaches (in order
42+
* of priority) are:
43+
* - Tier 2: Block delete with 409 if local references exist (or allow with
44+
* ?force=true after showing impact)
45+
* - Tier 3: Soft delete / deprecation (the only truly safe distributed
46+
* systems answer — tombstones over hard deletes)
47+
* - Tier 4: Orphan cleanup endpoint for records with zero local references
48+
*
49+
* See: docs/orphaned-records-analysis.md for the full analysis.
50+
*/
51+
352
import { v4 as uuidv4 } from 'uuid';
453
import {
554
StagingV2,

0 commit comments

Comments
 (0)