005_insight_decision_action_idempotency.sql 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. CREATE TABLE IF NOT EXISTS voc.insight_decision (
  2. id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  3. public_id text NOT NULL UNIQUE,
  4. workspace_id bigint NOT NULL REFERENCES voc.workspace(id) ON DELETE CASCADE,
  5. source_analysis_id bigint NOT NULL REFERENCES voc.analysis_run(id) ON DELETE RESTRICT,
  6. source_insight_id text NOT NULL,
  7. decision text NOT NULL CHECK (decision IN ('confirmed', 'rejected', 'needs_more_evidence')),
  8. reviewed_evidence_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
  9. comment text NOT NULL DEFAULT '',
  10. decided_by_external_id text NOT NULL,
  11. decided_at timestamptz NOT NULL DEFAULT now(),
  12. version integer NOT NULL CHECK (version > 0),
  13. supersedes_id bigint REFERENCES voc.insight_decision(id) ON DELETE RESTRICT,
  14. is_current boolean NOT NULL DEFAULT true,
  15. created_at timestamptz NOT NULL DEFAULT now(),
  16. updated_at timestamptz NOT NULL DEFAULT now(),
  17. CONSTRAINT insight_decision_reviewed_evidence_ids_check CHECK (
  18. jsonb_typeof(reviewed_evidence_ids) = 'array'
  19. AND jsonb_array_length(reviewed_evidence_ids) <= 100
  20. ),
  21. CONSTRAINT insight_decision_comment_check CHECK (
  22. decision = 'confirmed' OR btrim(comment) <> ''
  23. ),
  24. CONSTRAINT insight_decision_source_version_unique UNIQUE (
  25. workspace_id,
  26. source_analysis_id,
  27. source_insight_id,
  28. version
  29. )
  30. );
  31. CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_source_current_unique
  32. ON voc.insight_decision (workspace_id, source_analysis_id, source_insight_id)
  33. WHERE is_current;
  34. CREATE UNIQUE INDEX IF NOT EXISTS insight_decision_supersedes_unique
  35. ON voc.insight_decision (supersedes_id)
  36. WHERE supersedes_id IS NOT NULL;
  37. CREATE INDEX IF NOT EXISTS insight_decision_workspace_decided_idx
  38. ON voc.insight_decision (workspace_id, decided_at DESC, id DESC);
  39. CREATE INDEX IF NOT EXISTS insight_decision_analysis_insight_version_idx
  40. ON voc.insight_decision (source_analysis_id, source_insight_id, version DESC, id DESC);
  41. CREATE OR REPLACE FUNCTION voc.validate_insight_decision_insert()
  42. RETURNS trigger
  43. LANGUAGE plpgsql
  44. AS $$
  45. DECLARE
  46. source_result jsonb;
  47. matched_insight jsonb;
  48. previous_decision voc.insight_decision%ROWTYPE;
  49. BEGIN
  50. SELECT analysis.result
  51. INTO source_result
  52. FROM voc.analysis_run analysis
  53. WHERE analysis.id = NEW.source_analysis_id
  54. AND analysis.workspace_id = NEW.workspace_id
  55. AND analysis.analysis_type = 'voc_insight'
  56. AND analysis.status IN ('completed', 'partial');
  57. IF NOT FOUND THEN
  58. RAISE EXCEPTION USING
  59. ERRCODE = '23514',
  60. MESSAGE = 'insight decision source must be a completed or partial voc_insight run in the same workspace';
  61. END IF;
  62. SELECT insight.value
  63. INTO matched_insight
  64. FROM jsonb_array_elements(
  65. CASE
  66. WHEN jsonb_typeof(source_result -> 'insights') = 'array' THEN source_result -> 'insights'
  67. ELSE '[]'::jsonb
  68. END
  69. ) AS insight(value)
  70. WHERE insight.value ->> 'id' = NEW.source_insight_id
  71. LIMIT 1;
  72. IF matched_insight IS NULL THEN
  73. RAISE EXCEPTION USING
  74. ERRCODE = '23514',
  75. MESSAGE = 'insight decision source insight does not exist in the source analysis result';
  76. END IF;
  77. IF jsonb_array_length(NEW.reviewed_evidence_ids) = 0 THEN
  78. RAISE EXCEPTION USING
  79. ERRCODE = '23514',
  80. MESSAGE = 'at least one reviewed evidence ID is required';
  81. END IF;
  82. IF source_result ->> 'mode' = 'deterministic'
  83. AND NEW.decision <> 'needs_more_evidence'
  84. THEN
  85. RAISE EXCEPTION USING
  86. ERRCODE = '23514',
  87. MESSAGE = 'deterministic insight results require a needs_more_evidence decision';
  88. END IF;
  89. IF EXISTS (
  90. SELECT 1
  91. FROM jsonb_array_elements(NEW.reviewed_evidence_ids) AS reviewed(value)
  92. WHERE jsonb_typeof(reviewed.value) <> 'string'
  93. ) THEN
  94. RAISE EXCEPTION USING
  95. ERRCODE = '23514',
  96. MESSAGE = 'reviewed evidence IDs must be strings';
  97. END IF;
  98. IF EXISTS (
  99. SELECT 1
  100. FROM jsonb_array_elements_text(NEW.reviewed_evidence_ids) AS reviewed(id)
  101. WHERE NOT EXISTS (
  102. SELECT 1
  103. FROM jsonb_array_elements(
  104. CASE
  105. WHEN jsonb_typeof(matched_insight -> 'evidenceIds') = 'array' THEN matched_insight -> 'evidenceIds'
  106. ELSE '[]'::jsonb
  107. END
  108. ) AS allowed(value)
  109. WHERE jsonb_typeof(allowed.value) = 'string'
  110. AND allowed.value = to_jsonb(reviewed.id)
  111. )
  112. ) THEN
  113. RAISE EXCEPTION USING
  114. ERRCODE = '23514',
  115. MESSAGE = 'reviewed evidence must belong to the selected insight';
  116. END IF;
  117. IF NEW.supersedes_id IS NULL THEN
  118. IF NEW.version <> 1 THEN
  119. RAISE EXCEPTION USING
  120. ERRCODE = '23514',
  121. MESSAGE = 'the first insight decision version must be 1';
  122. END IF;
  123. ELSE
  124. SELECT * INTO previous_decision
  125. FROM voc.insight_decision
  126. WHERE id = NEW.supersedes_id;
  127. IF NOT FOUND
  128. OR previous_decision.workspace_id <> NEW.workspace_id
  129. OR previous_decision.source_analysis_id <> NEW.source_analysis_id
  130. OR previous_decision.source_insight_id <> NEW.source_insight_id
  131. OR previous_decision.version + 1 <> NEW.version
  132. THEN
  133. RAISE EXCEPTION USING
  134. ERRCODE = '23514',
  135. MESSAGE = 'superseded insight decision must be the preceding version for the same source';
  136. END IF;
  137. END IF;
  138. RETURN NEW;
  139. END;
  140. $$;
  141. DROP TRIGGER IF EXISTS insight_decision_insert_guard ON voc.insight_decision;
  142. CREATE TRIGGER insight_decision_insert_guard
  143. BEFORE INSERT ON voc.insight_decision
  144. FOR EACH ROW
  145. EXECUTE FUNCTION voc.validate_insight_decision_insert();
  146. CREATE OR REPLACE FUNCTION voc.enforce_insight_decision_append_only()
  147. RETURNS trigger
  148. LANGUAGE plpgsql
  149. AS $$
  150. BEGIN
  151. IF OLD.workspace_id IS DISTINCT FROM NEW.workspace_id
  152. OR OLD.source_analysis_id IS DISTINCT FROM NEW.source_analysis_id
  153. OR OLD.source_insight_id IS DISTINCT FROM NEW.source_insight_id
  154. OR OLD.decision IS DISTINCT FROM NEW.decision
  155. OR OLD.reviewed_evidence_ids IS DISTINCT FROM NEW.reviewed_evidence_ids
  156. OR OLD.comment IS DISTINCT FROM NEW.comment
  157. OR OLD.decided_by_external_id IS DISTINCT FROM NEW.decided_by_external_id
  158. OR OLD.decided_at IS DISTINCT FROM NEW.decided_at
  159. OR OLD.version IS DISTINCT FROM NEW.version
  160. OR OLD.supersedes_id IS DISTINCT FROM NEW.supersedes_id
  161. OR OLD.created_at IS DISTINCT FROM NEW.created_at
  162. OR NOT OLD.is_current
  163. OR NEW.is_current
  164. THEN
  165. RAISE EXCEPTION USING
  166. ERRCODE = '23514',
  167. MESSAGE = 'insight decisions are append-only; only the current flag may be retired';
  168. END IF;
  169. RETURN NEW;
  170. END;
  171. $$;
  172. DROP TRIGGER IF EXISTS insight_decision_append_only_guard ON voc.insight_decision;
  173. CREATE TRIGGER insight_decision_append_only_guard
  174. BEFORE UPDATE ON voc.insight_decision
  175. FOR EACH ROW
  176. EXECUTE FUNCTION voc.enforce_insight_decision_append_only();
  177. ALTER TABLE voc.action_item
  178. ADD COLUMN IF NOT EXISTS source_decision_id bigint,
  179. ADD COLUMN IF NOT EXISTS source_kind text,
  180. ADD COLUMN IF NOT EXISTS creation_key text;
  181. DO $$
  182. BEGIN
  183. IF NOT EXISTS (
  184. SELECT 1
  185. FROM pg_constraint
  186. WHERE conname = 'action_item_source_decision_fkey'
  187. AND conrelid = 'voc.action_item'::regclass
  188. ) THEN
  189. ALTER TABLE voc.action_item
  190. ADD CONSTRAINT action_item_source_decision_fkey
  191. FOREIGN KEY (source_decision_id)
  192. REFERENCES voc.insight_decision(id)
  193. ON DELETE RESTRICT;
  194. END IF;
  195. END;
  196. $$;
  197. UPDATE voc.action_item
  198. SET source_kind = CASE
  199. WHEN source_analysis_id IS NOT NULL THEN 'insight'
  200. ELSE 'rule_action'
  201. END
  202. WHERE source_kind IS NULL;
  203. UPDATE voc.action_item
  204. SET creation_key = public_id
  205. WHERE creation_key IS NULL OR btrim(creation_key) = '';
  206. ALTER TABLE voc.action_item
  207. ALTER COLUMN source_kind SET DEFAULT 'rule_action',
  208. ALTER COLUMN source_kind SET NOT NULL,
  209. ALTER COLUMN creation_key SET NOT NULL;
  210. ALTER TABLE voc.action_item
  211. DROP CONSTRAINT IF EXISTS action_item_source_kind_check;
  212. ALTER TABLE voc.action_item
  213. ADD CONSTRAINT action_item_source_kind_check
  214. CHECK (source_kind IN ('insight', 'raw_feedback', 'rule_action'));
  215. ALTER TABLE voc.action_item
  216. DROP CONSTRAINT IF EXISTS action_item_source_decision_kind_check;
  217. ALTER TABLE voc.action_item
  218. ADD CONSTRAINT action_item_source_decision_kind_check
  219. CHECK (source_decision_id IS NULL OR source_kind = 'insight');
  220. CREATE UNIQUE INDEX IF NOT EXISTS action_item_workspace_creation_key_unique
  221. ON voc.action_item (workspace_id, creation_key);
  222. CREATE INDEX IF NOT EXISTS action_item_workspace_source_kind_idx
  223. ON voc.action_item (workspace_id, source_kind, id DESC);
  224. CREATE INDEX IF NOT EXISTS action_item_source_decision_idx
  225. ON voc.action_item (source_decision_id, id DESC)
  226. WHERE source_decision_id IS NOT NULL;
  227. CREATE OR REPLACE FUNCTION voc.validate_action_item_source_decision()
  228. RETURNS trigger
  229. LANGUAGE plpgsql
  230. AS $$
  231. DECLARE
  232. source_decision voc.insight_decision%ROWTYPE;
  233. BEGIN
  234. IF NEW.source_decision_id IS NULL THEN
  235. RETURN NEW;
  236. END IF;
  237. SELECT decision.*
  238. INTO source_decision
  239. FROM voc.insight_decision decision
  240. WHERE decision.id = NEW.source_decision_id
  241. AND decision.workspace_id = NEW.workspace_id
  242. AND decision.source_analysis_id = NEW.source_analysis_id
  243. AND decision.source_insight_id = NEW.source_insight_id;
  244. IF NOT FOUND THEN
  245. RAISE EXCEPTION USING
  246. ERRCODE = '23514',
  247. MESSAGE = 'action source decision must match the action workspace, analysis, and insight';
  248. END IF;
  249. IF NOT source_decision.is_current THEN
  250. RAISE EXCEPTION USING
  251. ERRCODE = '23514',
  252. MESSAGE = 'action source decision must be the current decision version';
  253. END IF;
  254. IF source_decision.decision = 'rejected' THEN
  255. RAISE EXCEPTION USING
  256. ERRCODE = '23514',
  257. MESSAGE = 'rejected insight decisions cannot create actions';
  258. END IF;
  259. IF source_decision.decision = 'needs_more_evidence' THEN
  260. IF NEW.action_type <> 'data_quality' OR btrim(NEW.validation_metric) = '' THEN
  261. RAISE EXCEPTION USING
  262. ERRCODE = '23514',
  263. MESSAGE = 'needs_more_evidence decisions require a data_quality action and validation metric';
  264. END IF;
  265. ELSIF NEW.action_type = 'data_quality' THEN
  266. RAISE EXCEPTION USING
  267. ERRCODE = '23514',
  268. MESSAGE = 'confirmed insight decisions require a formal action type';
  269. END IF;
  270. IF EXISTS (
  271. SELECT 1
  272. FROM jsonb_array_elements_text(NEW.evidence_ids) AS evidence(id)
  273. WHERE NOT (source_decision.reviewed_evidence_ids ? evidence.id)
  274. ) THEN
  275. RAISE EXCEPTION USING
  276. ERRCODE = '23514',
  277. MESSAGE = 'action evidence must be included in the reviewed decision evidence';
  278. END IF;
  279. RETURN NEW;
  280. END;
  281. $$;
  282. DROP TRIGGER IF EXISTS action_item_source_decision_guard ON voc.action_item;
  283. CREATE TRIGGER action_item_source_decision_guard
  284. BEFORE INSERT OR UPDATE OF source_decision_id, source_analysis_id, source_insight_id, workspace_id
  285. ON voc.action_item
  286. FOR EACH ROW
  287. EXECUTE FUNCTION voc.validate_action_item_source_decision();