006_insight_action_guard_hardening.sql 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. CREATE OR REPLACE FUNCTION voc.validate_insight_decision_insert()
  2. RETURNS trigger
  3. LANGUAGE plpgsql
  4. AS $$
  5. DECLARE
  6. source_result jsonb;
  7. matched_insight jsonb;
  8. allowed_evidence_ids jsonb;
  9. previous_decision voc.insight_decision%ROWTYPE;
  10. BEGIN
  11. SELECT analysis.result
  12. INTO source_result
  13. FROM voc.analysis_run analysis
  14. WHERE analysis.id = NEW.source_analysis_id
  15. AND analysis.workspace_id = NEW.workspace_id
  16. AND analysis.analysis_type = 'voc_insight'
  17. AND analysis.status IN ('completed', 'partial');
  18. IF NOT FOUND THEN
  19. RAISE EXCEPTION USING
  20. ERRCODE = '23514',
  21. MESSAGE = 'insight decision source must be a completed or partial voc_insight run in the same workspace';
  22. END IF;
  23. SELECT insight.value
  24. INTO matched_insight
  25. FROM jsonb_array_elements(
  26. CASE
  27. WHEN jsonb_typeof(source_result -> 'insights') = 'array' THEN source_result -> 'insights'
  28. ELSE '[]'::jsonb
  29. END
  30. ) AS insight(value)
  31. WHERE insight.value ->> 'id' = NEW.source_insight_id
  32. LIMIT 1;
  33. IF matched_insight IS NULL THEN
  34. RAISE EXCEPTION USING
  35. ERRCODE = '23514',
  36. MESSAGE = 'insight decision source insight does not exist in the source analysis result';
  37. END IF;
  38. IF jsonb_array_length(NEW.reviewed_evidence_ids) = 0 THEN
  39. RAISE EXCEPTION USING
  40. ERRCODE = '23514',
  41. MESSAGE = 'at least one reviewed evidence ID is required';
  42. END IF;
  43. IF source_result ->> 'mode' = 'deterministic'
  44. AND NEW.decision <> 'needs_more_evidence'
  45. THEN
  46. RAISE EXCEPTION USING
  47. ERRCODE = '23514',
  48. MESSAGE = 'deterministic insight results require a needs_more_evidence decision';
  49. END IF;
  50. IF EXISTS (
  51. SELECT 1
  52. FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
  53. WHERE jsonb_typeof(reviewed.value) <> 'string'
  54. ) THEN
  55. RAISE EXCEPTION USING
  56. ERRCODE = '23514',
  57. MESSAGE = 'reviewed evidence IDs must be strings';
  58. END IF;
  59. allowed_evidence_ids := CASE
  60. WHEN jsonb_typeof(matched_insight -> 'evidenceIds') = 'array' THEN matched_insight -> 'evidenceIds'
  61. ELSE '[]'::jsonb
  62. END;
  63. IF EXISTS (
  64. SELECT 1
  65. FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
  66. WHERE NOT EXISTS (
  67. SELECT 1
  68. FROM jsonb_array_elements(allowed_evidence_ids) AS allowed(value)
  69. WHERE jsonb_typeof(allowed.value) = 'string'
  70. AND allowed.value = reviewed.value
  71. )
  72. ) THEN
  73. RAISE EXCEPTION USING
  74. ERRCODE = '23514',
  75. MESSAGE = 'reviewed evidence must belong to the selected insight';
  76. END IF;
  77. IF EXISTS (
  78. SELECT 1
  79. FROM jsonb_array_elements(allowed_evidence_ids) AS allowed(value)
  80. WHERE jsonb_typeof(allowed.value) <> 'string'
  81. OR NOT EXISTS (
  82. SELECT 1
  83. FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
  84. WHERE reviewed.value = allowed.value
  85. )
  86. ) THEN
  87. RAISE EXCEPTION USING
  88. ERRCODE = '23514',
  89. MESSAGE = 'reviewed evidence must cover every evidence ID in the selected insight';
  90. END IF;
  91. IF NEW.supersedes_id IS NULL THEN
  92. IF NEW.version <> 1 THEN
  93. RAISE EXCEPTION USING
  94. ERRCODE = '23514',
  95. MESSAGE = 'the first insight decision version must be 1';
  96. END IF;
  97. ELSE
  98. SELECT * INTO previous_decision
  99. FROM voc.insight_decision
  100. WHERE id = NEW.supersedes_id;
  101. IF NOT FOUND
  102. OR previous_decision.workspace_id <> NEW.workspace_id
  103. OR previous_decision.source_analysis_id <> NEW.source_analysis_id
  104. OR previous_decision.source_insight_id <> NEW.source_insight_id
  105. OR previous_decision.version + 1 <> NEW.version
  106. THEN
  107. RAISE EXCEPTION USING
  108. ERRCODE = '23514',
  109. MESSAGE = 'superseded insight decision must be the preceding version for the same source';
  110. END IF;
  111. END IF;
  112. RETURN NEW;
  113. END;
  114. $$;
  115. ALTER TABLE voc.action_item
  116. DROP CONSTRAINT IF EXISTS action_item_source_decision_kind_check;
  117. -- NOT VALID preserves legacy rows while enforcing the source combination on new writes.
  118. ALTER TABLE voc.action_item
  119. ADD CONSTRAINT action_item_source_decision_kind_check
  120. CHECK (
  121. source_kind = 'insight'
  122. OR (
  123. source_analysis_id IS NULL
  124. AND source_insight_id IS NULL
  125. AND source_decision_id IS NULL
  126. )
  127. ) NOT VALID;
  128. CREATE OR REPLACE FUNCTION voc.validate_action_item_source_decision()
  129. RETURNS trigger
  130. LANGUAGE plpgsql
  131. AS $$
  132. DECLARE
  133. source_decision voc.insight_decision%ROWTYPE;
  134. BEGIN
  135. IF NEW.source_kind IS DISTINCT FROM 'insight' THEN
  136. IF NEW.source_analysis_id IS NOT NULL
  137. OR NEW.source_insight_id IS NOT NULL
  138. OR NEW.source_decision_id IS NOT NULL
  139. THEN
  140. RAISE EXCEPTION USING
  141. ERRCODE = '23514',
  142. MESSAGE = 'non-insight actions cannot carry insight analysis, insight, or decision sources';
  143. END IF;
  144. RETURN NEW;
  145. END IF;
  146. IF NEW.source_analysis_id IS NULL
  147. OR NEW.source_insight_id IS NULL
  148. OR btrim(NEW.source_insight_id) = ''
  149. OR NEW.source_decision_id IS NULL
  150. THEN
  151. RAISE EXCEPTION USING
  152. ERRCODE = '23514',
  153. MESSAGE = 'insight actions require analysis, insight, and decision sources';
  154. END IF;
  155. SELECT decision.*
  156. INTO source_decision
  157. FROM voc.insight_decision decision
  158. WHERE decision.id = NEW.source_decision_id
  159. AND decision.workspace_id = NEW.workspace_id
  160. AND decision.source_analysis_id = NEW.source_analysis_id
  161. AND decision.source_insight_id = NEW.source_insight_id;
  162. IF NOT FOUND THEN
  163. RAISE EXCEPTION USING
  164. ERRCODE = '23514',
  165. MESSAGE = 'action source decision must match the action workspace, analysis, and insight';
  166. END IF;
  167. IF NOT source_decision.is_current THEN
  168. RAISE EXCEPTION USING
  169. ERRCODE = '23514',
  170. MESSAGE = 'action source decision must be the current decision version';
  171. END IF;
  172. IF source_decision.decision = 'rejected' THEN
  173. RAISE EXCEPTION USING
  174. ERRCODE = '23514',
  175. MESSAGE = 'rejected insight decisions cannot create actions';
  176. END IF;
  177. IF source_decision.decision = 'needs_more_evidence' THEN
  178. IF NEW.action_type <> 'data_quality' OR btrim(NEW.validation_metric) = '' THEN
  179. RAISE EXCEPTION USING
  180. ERRCODE = '23514',
  181. MESSAGE = 'needs_more_evidence decisions require a data_quality action and validation metric';
  182. END IF;
  183. ELSIF NEW.action_type = 'data_quality' THEN
  184. RAISE EXCEPTION USING
  185. ERRCODE = '23514',
  186. MESSAGE = 'confirmed insight decisions require a formal action type';
  187. END IF;
  188. IF jsonb_typeof(NEW.evidence_ids) IS DISTINCT FROM 'array'
  189. OR jsonb_array_length(NEW.evidence_ids) = 0
  190. THEN
  191. RAISE EXCEPTION USING
  192. ERRCODE = '23514',
  193. MESSAGE = 'insight action evidence must be a non-empty array';
  194. END IF;
  195. IF EXISTS (
  196. SELECT 1
  197. FROM jsonb_array_elements(NEW.evidence_ids) AS evidence(value)
  198. WHERE jsonb_typeof(evidence.value) <> 'string'
  199. ) THEN
  200. RAISE EXCEPTION USING
  201. ERRCODE = '23514',
  202. MESSAGE = 'insight action evidence IDs must be strings';
  203. END IF;
  204. IF EXISTS (
  205. SELECT 1
  206. FROM jsonb_array_elements(NEW.evidence_ids) AS evidence(value)
  207. WHERE NOT EXISTS (
  208. SELECT 1
  209. FROM jsonb_array_elements(source_decision.reviewed_evidence_ids) AS reviewed(value)
  210. WHERE reviewed.value = evidence.value
  211. )
  212. ) THEN
  213. RAISE EXCEPTION USING
  214. ERRCODE = '23514',
  215. MESSAGE = 'action evidence must be included in the reviewed decision evidence';
  216. END IF;
  217. RETURN NEW;
  218. END;
  219. $$;
  220. DROP TRIGGER IF EXISTS action_item_source_decision_guard ON voc.action_item;
  221. CREATE TRIGGER action_item_source_decision_guard
  222. BEFORE INSERT OR UPDATE OF
  223. workspace_id,
  224. source_kind,
  225. source_analysis_id,
  226. source_insight_id,
  227. source_decision_id,
  228. action_type,
  229. validation_metric,
  230. evidence_ids
  231. ON voc.action_item
  232. FOR EACH ROW
  233. EXECUTE FUNCTION voc.validate_action_item_source_decision();