@team-semicolon/semicolony-cli 4.18.126 → 4.18.127

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ -- 226_action_items_work_loop.sql
2
+ -- Baseline: aa007fb57fea7758d3a1bee7ecfecbe3f2ff8d94. Runner owns transaction.
3
+ -- Additive ledger evolution. Existing execution state/history is never rewritten.
4
+ -- CHECK NOT VALID preserves legacy anomalies while guarding every new write.
5
+ -- Rollback: restore application version first; retain additive tables/history.
6
+ -- Canary: packages/cli/scripts/verify-action-items-work-loop.sql (ROLLBACK).
7
+
8
+ CREATE TABLE semicolony.action_external_contacts (
9
+ external_contact_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
10
+ display_name text NOT NULL CHECK (length(btrim(display_name)) > 0),
11
+ source_ref text UNIQUE,
12
+ metadata jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(metadata) = 'object'),
13
+ created_at timestamptz NOT NULL DEFAULT now()
14
+ );
15
+ ALTER TABLE semicolony.action_items
16
+ ADD COLUMN version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
17
+ ADD COLUMN external_contact_id uuid REFERENCES semicolony.action_external_contacts,
18
+ ADD COLUMN sponsor_member_id uuid REFERENCES semicolony.team_members,
19
+ ADD COLUMN waiting_on_external boolean NOT NULL DEFAULT false,
20
+ ADD COLUMN next_follow_up_at timestamptz,
21
+ ADD COLUMN last_confirmed_at timestamptz,
22
+ ADD CONSTRAINT action_items_metadata_object CHECK (jsonb_typeof(metadata) = 'object') NOT VALID,
23
+ ADD CONSTRAINT action_items_assignee_xor CHECK (
24
+ (assignee_kind = 'team-member' AND assignee_member_id IS NOT NULL AND assignee_agent_id IS NULL)
25
+ OR (assignee_kind = 'agent' AND assignee_agent_id IS NOT NULL AND assignee_member_id IS NULL)
26
+ OR (assignee_kind = 'unassigned' AND assignee_agent_id IS NULL AND assignee_member_id IS NULL)
27
+ ) NOT VALID,
28
+ ADD CONSTRAINT action_items_external_sponsor CHECK (
29
+ external_contact_id IS NULL OR (sponsor_member_id IS NOT NULL AND assignee_kind = 'unassigned')
30
+ ) NOT VALID;
31
+ CREATE INDEX action_items_page_idx ON semicolony.action_items(deadline, action_item_id);
32
+ CREATE INDEX action_items_sponsor_idx ON semicolony.action_items(sponsor_member_id, status);
33
+ CREATE FUNCTION semicolony.bump_action_item_version() RETURNS trigger
34
+ LANGUAGE plpgsql SET search_path = pg_catalog AS $$
35
+ BEGIN NEW.version := OLD.version + 1; NEW.updated_at := now(); RETURN NEW; END $$;
36
+ CREATE TRIGGER action_item_version BEFORE UPDATE ON semicolony.action_items
37
+ FOR EACH ROW EXECUTE FUNCTION semicolony.bump_action_item_version();
38
+
39
+ CREATE TABLE semicolony.action_item_dependencies (
40
+ action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
41
+ depends_on_action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
42
+ created_at timestamptz NOT NULL DEFAULT now(),
43
+ PRIMARY KEY(action_item_id, depends_on_action_item_id),
44
+ CHECK(action_item_id <> depends_on_action_item_id)
45
+ );
46
+ CREATE TABLE semicolony.action_item_candidates (
47
+ candidate_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
48
+ source_kind text NOT NULL CHECK (source_kind IN ('meeting','feedback','incident','review','decision','manual','cron','slack','kb')),
49
+ source_ref text NOT NULL CHECK (length(btrim(source_ref)) > 0),
50
+ source_identity text NOT NULL CHECK (length(btrim(source_identity)) > 0),
51
+ proposal jsonb NOT NULL CHECK (jsonb_typeof(proposal) = 'object'),
52
+ reasons jsonb NOT NULL CHECK (jsonb_typeof(reasons) = 'array'),
53
+ status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','rejected')),
54
+ meeting_id uuid REFERENCES semicolony.meetings,
55
+ assignee_member_id uuid REFERENCES semicolony.team_members,
56
+ sponsor_member_id uuid REFERENCES semicolony.team_members,
57
+ created_by_member_id uuid REFERENCES semicolony.team_members,
58
+ created_by_agent_id text REFERENCES semicolony.bot_status,
59
+ resolved_by_member_id uuid REFERENCES semicolony.team_members,
60
+ resolved_by_agent_id text REFERENCES semicolony.bot_status,
61
+ resolution_note text,
62
+ resolution_source_ref text,
63
+ action_item_id uuid REFERENCES semicolony.action_items,
64
+ version bigint NOT NULL DEFAULT 1,
65
+ created_at timestamptz NOT NULL DEFAULT now(),
66
+ updated_at timestamptz NOT NULL DEFAULT now(),
67
+ UNIQUE(source_kind, source_ref, source_identity),
68
+ CHECK ((status = 'accepted') = (action_item_id IS NOT NULL))
69
+ );
70
+ CREATE INDEX action_item_candidates_page_idx ON semicolony.action_item_candidates(created_at,candidate_id);
71
+ CREATE TABLE semicolony.action_delegations (
72
+ delegation_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
73
+ token_hash text NOT NULL UNIQUE CHECK (token_hash ~ '^[a-f0-9]{64}$'),
74
+ allowed_actor_id text NOT NULL REFERENCES semicolony.bot_status,
75
+ requester_member_id uuid NOT NULL REFERENCES semicolony.team_members,
76
+ action_item_id uuid NOT NULL REFERENCES semicolony.action_items,
77
+ expected_version bigint NOT NULL CHECK(expected_version > 0),
78
+ patch jsonb NOT NULL CHECK(jsonb_typeof(patch) = 'object'),
79
+ source_ref text NOT NULL CHECK(length(btrim(source_ref)) > 0),
80
+ expires_at timestamptz NOT NULL,
81
+ consumed_at timestamptz,
82
+ created_at timestamptz NOT NULL DEFAULT now(),
83
+ UNIQUE(source_ref, allowed_actor_id, action_item_id)
84
+ );
85
+
86
+ -- Worker lanes have no restricted-read capability. A named meeting source
87
+ -- without a resolvable record is hidden, matching KB meeting projection policy.
88
+ CREATE FUNCTION semicolony.action_item_worker_visible(p_id uuid) RETURNS boolean
89
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
90
+ SELECT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.action_item_id=p_id
91
+ AND coalesce(a.metadata #>> '{classification,sensitivity}','internal') <> 'restricted'
92
+ AND NOT EXISTS(SELECT 1 FROM semicolony.meetings m WHERE
93
+ (m.meeting_id=a.meeting_id OR (a.source_kind='meeting' AND m.meeting_id::text=a.source_ref)) AND m.visibility IS DISTINCT FROM 'team')
94
+ AND (a.source_kind<>'meeting' OR EXISTS(SELECT 1 FROM semicolony.meetings m WHERE m.meeting_id=a.meeting_id OR m.meeting_id::text=a.source_ref)));
95
+ $$;
96
+ CREATE FUNCTION semicolony.action_candidate_source_visible(p_id uuid) RETURNS boolean
97
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
98
+ SELECT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE c.candidate_id=p_id
99
+ AND coalesce(c.proposal #>> '{metadata,classification,sensitivity}','internal') <> 'restricted'
100
+ AND NOT EXISTS(SELECT 1 FROM semicolony.meetings m WHERE
101
+ (m.meeting_id=c.meeting_id OR (c.source_kind='meeting' AND m.meeting_id::text=c.source_ref)) AND m.visibility IS DISTINCT FROM 'team')
102
+ AND (c.source_kind<>'meeting' OR EXISTS(SELECT 1 FROM semicolony.meetings m WHERE m.meeting_id=c.meeting_id OR m.meeting_id::text=c.source_ref)));
103
+ $$;
104
+ CREATE FUNCTION semicolony.action_contact_worker_visible(p_id text) RETURNS boolean
105
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
106
+ SELECT EXISTS(SELECT 1 FROM semicolony.action_external_contacts e WHERE e.external_contact_id::text=lower(btrim(p_id))
107
+ AND coalesce(e.metadata #>> '{classification,sensitivity}','internal') <> 'restricted'
108
+ AND NOT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.external_contact_id=e.external_contact_id AND NOT semicolony.action_item_worker_visible(a.action_item_id))
109
+ AND NOT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE lower(btrim(c.proposal->>'external_contact_id'))=lower(btrim(p_id)) AND NOT semicolony.action_candidate_source_visible(c.candidate_id)));
110
+ $$;
111
+
112
+ -- Separate source classification from linked-contact classification: contact visibility
113
+ -- reads only source visibility, so candidate -> contact never recurses back to itself.
114
+ CREATE FUNCTION semicolony.action_candidate_worker_visible(p_id uuid) RETURNS boolean
115
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
116
+ SELECT EXISTS(SELECT 1 FROM semicolony.action_item_candidates c WHERE c.candidate_id=p_id
117
+ AND semicolony.action_candidate_source_visible(c.candidate_id)
118
+ -- Invalid or unknown contact facts remain visible pending proposals for repair.
119
+ AND NOT EXISTS(SELECT 1 FROM semicolony.action_external_contacts e WHERE e.external_contact_id::text=lower(btrim(c.proposal->>'external_contact_id')) AND NOT semicolony.action_contact_worker_visible(e.external_contact_id::text))
120
+ AND NOT EXISTS(SELECT 1 FROM semicolony.action_items a WHERE a.action_item_id=c.action_item_id AND a.external_contact_id IS NOT NULL AND NOT semicolony.action_contact_worker_visible(a.external_contact_id::text)));
121
+ $$;
122
+ REVOKE ALL ON FUNCTION semicolony.action_candidate_source_visible(uuid) FROM PUBLIC;
123
+
124
+ -- Shared admission checks return missing/invalid facts instead of manufacturing them.
125
+ CREATE FUNCTION semicolony.action_candidate_reasons(p jsonb) RETURNS jsonb
126
+ LANGUAGE plpgsql STABLE SET search_path = pg_catalog AS $$
127
+ DECLARE r jsonb := '[]'; d date; owners integer;
128
+ BEGIN
129
+ IF jsonb_typeof(p) IS DISTINCT FROM 'object' THEN RETURN '["invalid_proposal"]'; END IF;
130
+ IF jsonb_typeof(p->'description') IS DISTINCT FROM 'string' THEN r := r || '"description_must_be_string"'::jsonb; END IF;
131
+ IF length(btrim(coalesce(p->>'description',''))) < 5 THEN r := r || '"description_required"'::jsonb; END IF;
132
+ IF NOT EXISTS(SELECT 1 FROM semicolony.ontology WHERE domain = p->>'project_domain') THEN r := r || '"project_required_or_unknown"'::jsonb; END IF;
133
+ BEGIN
134
+ IF coalesce(p->>'deadline','') !~ '^\d{4}-\d{2}-\d{2}$' THEN RAISE invalid_datetime_format; END IF;
135
+ d := (p->>'deadline')::date;
136
+ EXCEPTION WHEN invalid_datetime_format OR datetime_field_overflow THEN r := r || '"deadline_required_or_invalid"'::jsonb; END;
137
+ owners := (nullif(p->>'assignee_member','') IS NOT NULL)::integer
138
+ + (nullif(p->>'assignee_agent','') IS NOT NULL)::integer
139
+ + (coalesce(nullif(p->>'external_contact_id',''),nullif(p->>'external_contact','')) IS NOT NULL)::integer;
140
+ IF owners <> 1 THEN r := r || '"exactly_one_assignee_required"'::jsonb; END IF;
141
+ IF p->>'assignee_member' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=p->>'assignee_member' AND status='active') THEN r := r || '"assignee_member_unknown_or_inactive"'::jsonb; END IF;
142
+ IF p->>'assignee_agent' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p->>'assignee_agent' AND status IS DISTINCT FROM 'retired') THEN r := r || '"assignee_agent_unknown_or_retired"'::jsonb; END IF;
143
+ IF coalesce(p->>'external_contact_id',p->>'external_contact') IS NOT NULL THEN
144
+ IF p->>'external_contact_id' IS NOT NULL AND NOT semicolony.action_contact_worker_visible(p->>'external_contact_id') THEN r := r || '"external_contact_unknown"'::jsonb; END IF;
145
+ IF nullif(p->>'sponsor_member','') IS NULL THEN r := r || '"internal_sponsor_required"'::jsonb; END IF;
146
+ END IF;
147
+ IF p->>'sponsor_member' IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=p->>'sponsor_member' AND status='active') THEN r := r || '"sponsor_unknown_or_inactive"'::jsonb; END IF;
148
+ IF p ? 'metadata' AND jsonb_typeof(p->'metadata') IS DISTINCT FROM 'object' THEN r := r || '"metadata_must_be_object"'::jsonb; END IF;
149
+ IF p ? 'priority' AND coalesce(p->>'priority','') NOT IN ('low','normal','high','urgent') THEN r := r || '"priority_invalid"'::jsonb; END IF;
150
+ RETURN r;
151
+ END $$;
152
+
153
+ CREATE FUNCTION semicolony.create_action_candidate(p_proposal jsonb, p_source_kind text,
154
+ p_source_ref text,p_source_identity text,p_caller_kind text,p_caller_member text,p_caller_agent text,
155
+ p_meeting_id uuid DEFAULT NULL) RETURNS uuid
156
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
157
+ DECLARE v_id uuid; v_member uuid;
158
+ BEGIN
159
+ IF jsonb_typeof(p_proposal) IS DISTINCT FROM 'object' THEN RAISE EXCEPTION 'invalid proposal' USING ERRCODE='22023'; END IF;
160
+ IF p_caller_kind='team-member' THEN
161
+ SELECT member_id INTO v_member FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
162
+ IF v_member IS NULL THEN RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001'; END IF;
163
+ ELSIF p_caller_kind='agent' THEN
164
+ IF NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_caller_agent AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001'; END IF;
165
+ ELSIF p_caller_kind IS DISTINCT FROM 'system' OR p_meeting_id IS NULL THEN
166
+ RAISE EXCEPTION 'invalid candidate caller' USING ERRCODE='AI001';
167
+ END IF;
168
+ INSERT INTO semicolony.action_item_candidates(source_kind,source_ref,source_identity,proposal,reasons,meeting_id,assignee_member_id,sponsor_member_id,created_by_member_id,created_by_agent_id)
169
+ VALUES(p_source_kind,p_source_ref,p_source_identity,p_proposal,semicolony.action_candidate_reasons(p_proposal),p_meeting_id,(SELECT member_id FROM semicolony.team_members WHERE domain=p_proposal->>'assignee_member'),(SELECT member_id FROM semicolony.team_members WHERE domain=p_proposal->>'sponsor_member'),v_member,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END)
170
+ ON CONFLICT(source_kind,source_ref,source_identity) DO NOTHING RETURNING candidate_id INTO v_id;
171
+ IF v_id IS NULL THEN SELECT candidate_id INTO v_id FROM semicolony.action_item_candidates WHERE source_kind=p_source_kind AND source_ref=p_source_ref AND source_identity=p_source_identity; END IF;
172
+ RETURN v_id;
173
+ END $$;
174
+
175
+ CREATE FUNCTION semicolony.resolve_action_candidate(p_candidate_id uuid,p_decision text,
176
+ p_expected_version bigint,p_fields jsonb,p_note text,p_caller_kind text,p_caller_member text,p_caller_agent text)
177
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $$
178
+ DECLARE c semicolony.action_item_candidates%ROWTYPE; p jsonb; a uuid; m uuid; sponsor uuid; k text; contact uuid;
179
+ BEGIN
180
+ SELECT * INTO c FROM semicolony.action_item_candidates WHERE candidate_id=p_candidate_id FOR UPDATE;
181
+ IF NOT FOUND OR (p_caller_kind IS DISTINCT FROM 'system' AND NOT semicolony.action_candidate_worker_visible(p_candidate_id)) THEN RAISE EXCEPTION 'candidate_not_found' USING ERRCODE='AI002'; END IF;
182
+ SELECT member_id INTO m FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
183
+ -- Review belongs to a submitting human, proposed assignee/sponsor, or submitting/assigned agent.
184
+ IF NOT coalesce((p_caller_kind='system' AND c.meeting_id IS NOT NULL) OR (p_caller_kind='team-member' AND m IS NOT NULL AND
185
+ (c.created_by_member_id=m OR c.proposal->>'assignee_member'=p_caller_member OR c.proposal->>'sponsor_member'=p_caller_member OR
186
+ (c.created_by_member_id IS NULL AND c.created_by_agent_id IS NULL))) OR
187
+ (p_caller_kind='agent' AND p_caller_agent IS NOT NULL AND
188
+ (c.created_by_agent_id=p_caller_agent OR c.proposal->>'assignee_agent'=p_caller_agent)),false) THEN
189
+ RAISE EXCEPTION 'candidate_not_owned' USING ERRCODE='AI001'; END IF;
190
+ IF p_decision NOT IN ('accept','reject') OR p_decision IS NULL THEN RAISE EXCEPTION 'invalid decision' USING ERRCODE='22023'; END IF;
191
+ -- Retry of an already applied decision returns the original immutable result.
192
+ IF c.status <> 'pending' THEN
193
+ IF c.status = (CASE p_decision WHEN 'accept' THEN 'accepted' ELSE 'rejected' END) THEN RETURN c.action_item_id; END IF;
194
+ RAISE EXCEPTION 'candidate_already_resolved' USING ERRCODE='AI003';
195
+ END IF;
196
+ IF p_expected_version IS NOT NULL AND c.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
197
+ IF p_decision='reject' THEN
198
+ IF length(btrim(coalesce(p_note,'')))=0 THEN RAISE EXCEPTION 'rejection note required' USING ERRCODE='22023'; END IF;
199
+ ELSE
200
+ IF jsonb_typeof(p_fields) IS DISTINCT FROM 'object' OR NOT (p_fields ? 'project_domain' AND p_fields ? 'deadline') THEN RAISE EXCEPTION 'explicit project and deadline required' USING ERRCODE='22023'; END IF;
201
+ -- Assignee must be explicitly confirmed; a partial patch cannot inherit an old owner.
202
+ p := (c.proposal - ARRAY['assignee_member','assignee_agent','external_contact_id','external_contact','sponsor_member']) || p_fields;
203
+ IF semicolony.action_candidate_reasons(p) <> '[]'::jsonb THEN RAISE EXCEPTION 'candidate execution facts incomplete' USING ERRCODE='22023'; END IF;
204
+ k := CASE WHEN p->>'assignee_member' IS NOT NULL THEN 'team-member' WHEN p->>'assignee_agent' IS NOT NULL THEN 'agent' ELSE 'unassigned' END;
205
+ SELECT member_id INTO sponsor FROM semicolony.team_members WHERE domain=p->>'sponsor_member' AND status='active';
206
+ contact := (p->>'external_contact_id')::uuid;
207
+ IF contact IS NULL AND p->>'external_contact' IS NOT NULL THEN
208
+ INSERT INTO semicolony.action_external_contacts(display_name,source_ref,metadata) VALUES(p->>'external_contact','candidate:' || c.candidate_id::text,jsonb_build_object('classification',coalesce(p #> '{metadata,classification}','{}'::jsonb)))
209
+ ON CONFLICT(source_ref) DO UPDATE SET source_ref=EXCLUDED.source_ref RETURNING external_contact_id INTO contact;
210
+ END IF;
211
+ a := semicolony.create_action_item(p->>'description',p->>'project_domain',(p->>'deadline')::date,
212
+ k,p->>'assignee_member',p->>'assignee_agent',NULL,coalesce(p->>'priority','normal'),
213
+ c.source_kind,c.source_ref,p->>'related_url',p->>'external_contact',coalesce(p->'metadata','{}') || jsonb_build_object('candidate_id',c.candidate_id),
214
+ 'system',NULL,NULL,NULL,NULL,NULL,NULL);
215
+ UPDATE semicolony.action_items SET external_contact_id=contact,
216
+ sponsor_member_id=sponsor,waiting_on_external=(contact IS NOT NULL),meeting_id=c.meeting_id WHERE action_item_id=a;
217
+ INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,actor_agent_id,payload)
218
+ VALUES(a,'noted',CASE p_caller_kind WHEN 'team-member' THEN 'human' WHEN 'agent' THEN 'agent' ELSE 'system' END,
219
+ m,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,
220
+ jsonb_build_object('candidate_id',c.candidate_id,'operation','candidate_accepted','note',p_note,'source_ref',c.source_ref));
221
+ END IF;
222
+ UPDATE semicolony.action_item_candidates SET status=CASE p_decision WHEN 'accept' THEN 'accepted' ELSE 'rejected' END,
223
+ action_item_id=a,resolution_note=p_note,resolved_by_member_id=m,
224
+ resolved_by_agent_id=CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,version=version+1,updated_at=now() WHERE candidate_id=c.candidate_id;
225
+ RETURN a;
226
+ END $$;
227
+
228
+ CREATE FUNCTION semicolony.validate_action_patch(p jsonb) RETURNS void
229
+ LANGUAGE plpgsql SET search_path=pg_catalog AS $$
230
+ BEGIN
231
+ IF jsonb_typeof(p) IS DISTINCT FROM 'object' OR p='{}'::jsonb OR EXISTS(
232
+ SELECT 1 FROM jsonb_object_keys(p) k WHERE k NOT IN ('status','deadline','note','metadata','description','priority','waiting_on_external','next_follow_up_at','last_confirmed_at','dependency_ids')) THEN
233
+ RAISE EXCEPTION 'invalid action patch' USING ERRCODE='22023'; END IF;
234
+ IF (p ? 'metadata' AND jsonb_typeof(p->'metadata') IS DISTINCT FROM 'object') OR
235
+ (p ? 'waiting_on_external' AND jsonb_typeof(p->'waiting_on_external') IS DISTINCT FROM 'boolean') OR
236
+ (p ? 'dependency_ids' AND jsonb_typeof(p->'dependency_ids') IS DISTINCT FROM 'array') OR
237
+ (p ? 'description' AND (jsonb_typeof(p->'description') IS DISTINCT FROM 'string' OR length(btrim(p->>'description'))<5)) OR
238
+ (p ? 'status' AND coalesce(p->>'status','') NOT IN ('open','in_progress','blocked','done','cancelled')) OR
239
+ (p ? 'priority' AND coalesce(p->>'priority','') NOT IN ('low','normal','high','urgent')) OR
240
+ (p ? 'deadline' AND coalesce(p->>'deadline','') !~ '^\d{4}-\d{2}-\d{2}$') THEN
241
+ RAISE EXCEPTION 'invalid action patch value' USING ERRCODE='22023'; END IF;
242
+ END $$;
243
+
244
+ CREATE FUNCTION semicolony.edit_action_item(p_action_item_id uuid,p_caller_kind text,
245
+ p_caller_member text,p_caller_agent text,p_expected_version bigint,p_patch jsonb,p_delegation_token_hash text DEFAULT NULL)
246
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
247
+ DECLARE a semicolony.action_items%ROWTYPE; m uuid; d semicolony.action_delegations%ROWTYPE; v_source text; dep uuid;
248
+ BEGIN
249
+ PERFORM semicolony.validate_action_patch(p_patch);
250
+ -- Delegation lock first consistently, then action lock. Any later failure rolls consumption back.
251
+ IF p_delegation_token_hash IS NOT NULL THEN
252
+ SELECT * INTO d FROM semicolony.action_delegations WHERE token_hash=p_delegation_token_hash FOR UPDATE;
253
+ IF NOT FOUND OR p_caller_kind IS DISTINCT FROM 'agent' OR d.allowed_actor_id IS DISTINCT FROM p_caller_agent
254
+ OR d.action_item_id IS DISTINCT FROM p_action_item_id OR d.expected_version IS DISTINCT FROM p_expected_version
255
+ OR d.patch IS DISTINCT FROM p_patch OR d.expires_at<=clock_timestamp() OR d.consumed_at IS NOT NULL THEN
256
+ RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
257
+ m := d.requester_member_id; v_source := d.source_ref;
258
+ ELSE
259
+ SELECT member_id INTO m FROM semicolony.team_members WHERE domain=p_caller_member AND status='active';
260
+ END IF;
261
+ IF p_patch ? 'dependency_ids' THEN PERFORM pg_advisory_xact_lock(hashtextextended('action-dependencies',0)); END IF;
262
+ SELECT * INTO a FROM semicolony.action_items WHERE action_item_id=p_action_item_id FOR UPDATE;
263
+ IF NOT FOUND OR NOT semicolony.action_item_worker_visible(p_action_item_id) THEN RAISE EXCEPTION 'action_item_not_found' USING ERRCODE='AI002'; END IF;
264
+ IF NOT coalesce(((p_caller_kind='team-member' OR d.delegation_id IS NOT NULL) AND m IS NOT NULL AND
265
+ (a.assignee_member_id=m OR a.sponsor_member_id=m)) OR
266
+ (d.delegation_id IS NULL AND p_caller_kind='agent' AND p_caller_agent IS NOT NULL AND a.assignee_agent_id=p_caller_agent),false) THEN
267
+ RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
268
+ IF d.delegation_id IS NOT NULL AND NOT EXISTS(SELECT 1 FROM semicolony.team_members WHERE member_id=m AND status='active') THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
269
+ IF p_caller_kind='agent' AND NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_caller_agent AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
270
+ IF p_expected_version IS NOT NULL AND a.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
271
+ IF p_patch ? 'dependency_ids' THEN
272
+ -- Serialize graph mutations to prevent concurrent edge insertions forming a cycle.
273
+ DELETE FROM semicolony.action_item_dependencies WHERE action_item_id=p_action_item_id;
274
+ FOR dep IN SELECT value::uuid FROM jsonb_array_elements_text(p_patch->'dependency_ids') LOOP
275
+ IF NOT semicolony.action_item_worker_visible(dep) THEN RAISE EXCEPTION 'dependency_not_found' USING ERRCODE='AI002'; END IF;
276
+ IF dep=p_action_item_id OR EXISTS(WITH RECURSIVE chain(id) AS (
277
+ SELECT dep UNION SELECT e.depends_on_action_item_id FROM semicolony.action_item_dependencies e JOIN chain c ON e.action_item_id=c.id
278
+ ) SELECT 1 FROM chain WHERE id=p_action_item_id) THEN RAISE EXCEPTION 'dependency_cycle' USING ERRCODE='22023'; END IF;
279
+ INSERT INTO semicolony.action_item_dependencies VALUES(p_action_item_id,dep,now()) ON CONFLICT DO NOTHING;
280
+ END LOOP;
281
+ END IF;
282
+ UPDATE semicolony.action_items SET
283
+ description=coalesce(p_patch->>'description',description),priority=coalesce(p_patch->>'priority',priority),
284
+ status=coalesce(p_patch->>'status',status),deadline=coalesce((p_patch->>'deadline')::date,deadline),
285
+ completed_at=CASE WHEN p_patch->>'status'='done' AND status<>'done' THEN now()
286
+ WHEN p_patch ? 'status' AND p_patch->>'status'<>'done' THEN NULL ELSE completed_at END,
287
+ metadata=CASE WHEN p_patch ? 'metadata' THEN metadata || (p_patch->'metadata') ELSE metadata END,
288
+ waiting_on_external=coalesce((p_patch->>'waiting_on_external')::boolean,waiting_on_external),
289
+ next_follow_up_at=CASE WHEN p_patch ? 'next_follow_up_at' THEN (p_patch->>'next_follow_up_at')::timestamptz ELSE next_follow_up_at END,
290
+ last_confirmed_at=CASE WHEN p_patch ? 'last_confirmed_at' THEN (p_patch->>'last_confirmed_at')::timestamptz ELSE last_confirmed_at END
291
+ WHERE action_item_id=p_action_item_id;
292
+ INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,actor_agent_id,payload)
293
+ VALUES(p_action_item_id,CASE WHEN p_patch->>'status'='done' THEN 'closed' WHEN p_patch ? 'status' THEN 'status_changed' WHEN p_patch ? 'deadline' THEN 'deadline_changed' ELSE 'noted' END,
294
+ CASE WHEN p_caller_kind='agent' THEN 'agent' ELSE 'human' END,m,CASE WHEN p_caller_kind='agent' THEN p_caller_agent END,
295
+ jsonb_build_object('patch',p_patch,'previous_version',a.version,'version',a.version+1,'delegation_id',d.delegation_id,'source_ref',v_source));
296
+ IF d.delegation_id IS NOT NULL THEN
297
+ IF d.expires_at<=clock_timestamp() THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
298
+ UPDATE semicolony.action_delegations SET consumed_at=clock_timestamp() WHERE delegation_id=d.delegation_id;
299
+ END IF;
300
+ RETURN p_action_item_id;
301
+ END $$;
302
+
303
+ -- Legacy worker callers keep their positional signature and absence semantics.
304
+ CREATE OR REPLACE FUNCTION semicolony.update_action_item(p_action_item_id uuid,p_caller_kind text,p_caller_member text,p_caller_agent text,p_status text,p_deadline date,p_note text,p_metadata jsonb)
305
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
306
+ BEGIN RETURN semicolony.edit_action_item(p_action_item_id,p_caller_kind,p_caller_member,p_caller_agent,NULL,
307
+ coalesce((SELECT jsonb_object_agg(key,value) FROM jsonb_each(jsonb_build_object('status',p_status,'deadline',p_deadline,'note',p_note,'metadata',p_metadata)) WHERE value<>'null'::jsonb),'{}'::jsonb) || CASE WHEN p_status IS NULL AND p_deadline IS NULL AND p_note IS NULL AND p_metadata IS NULL THEN '{"note":null}'::jsonb ELSE '{}'::jsonb END,NULL); END $$;
308
+
309
+ CREATE FUNCTION semicolony.action_slack_member(p_slack_user_id text) RETURNS text
310
+ LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path=pg_catalog AS $$
311
+ DECLARE d text;
312
+ BEGIN
313
+ IF (SELECT count(*) FROM semicolony.team_members WHERE slack_user_id=p_slack_user_id AND status='active')<>1 THEN RAISE EXCEPTION 'slack_member_not_found' USING ERRCODE='AI001'; END IF;
314
+ SELECT domain INTO d FROM semicolony.team_members WHERE slack_user_id=p_slack_user_id AND status='active'; RETURN d;
315
+ END $$;
316
+ CREATE FUNCTION semicolony.issue_action_delegation(p_requester_slack_user_id text,p_actor_id text,
317
+ p_action_item_id uuid,p_expected_version bigint,p_patch jsonb,p_source_ref text,p_token_hash text,p_expires_at timestamptz)
318
+ RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
319
+ DECLARE m uuid; a semicolony.action_items%ROWTYPE; id uuid;
320
+ BEGIN
321
+ PERFORM semicolony.validate_action_patch(p_patch);
322
+ SELECT member_id INTO m FROM semicolony.team_members WHERE domain=semicolony.action_slack_member(p_requester_slack_user_id);
323
+ SELECT * INTO a FROM semicolony.action_items WHERE action_item_id=p_action_item_id FOR UPDATE;
324
+ IF NOT FOUND OR NOT semicolony.action_item_worker_visible(p_action_item_id) THEN RAISE EXCEPTION 'action_item_not_found' USING ERRCODE='AI002'; END IF;
325
+ IF NOT coalesce(a.assignee_member_id=m OR a.sponsor_member_id=m,false) THEN RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE='AI001'; END IF;
326
+ IF p_expected_version IS NULL OR a.version<>p_expected_version THEN RAISE EXCEPTION 'version_conflict' USING ERRCODE='AI003'; END IF;
327
+ IF p_expires_at IS NULL OR p_expires_at<=clock_timestamp() OR p_expires_at>clock_timestamp()+interval '15 minutes'
328
+ OR NOT EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=p_actor_id AND status IS DISTINCT FROM 'retired') THEN RAISE EXCEPTION 'invalid_delegation' USING ERRCODE='AI004'; END IF;
329
+ INSERT INTO semicolony.action_delegations(token_hash,allowed_actor_id,requester_member_id,action_item_id,expected_version,patch,source_ref,expires_at)
330
+ VALUES(p_token_hash,p_actor_id,m,p_action_item_id,p_expected_version,p_patch,p_source_ref,p_expires_at) RETURNING delegation_id INTO id;
331
+ RETURN id;
332
+ END $$;
333
+ CREATE FUNCTION semicolony.apply_slack_action_update(p_requester_slack_user_id text,p_action_item_id uuid,
334
+ p_expected_version bigint,p_patch jsonb,p_source_ref text) RETURNS uuid
335
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
336
+ DECLARE id uuid;
337
+ BEGIN
338
+ IF p_expected_version IS NULL OR length(btrim(coalesce(p_source_ref,'')))=0 THEN RAISE EXCEPTION 'version and Slack source required' USING ERRCODE='22023'; END IF;
339
+ id:=semicolony.edit_action_item(p_action_item_id,'team-member',semicolony.action_slack_member(p_requester_slack_user_id),NULL,p_expected_version,p_patch);
340
+ INSERT INTO semicolony.action_item_events(action_item_id,event_type,actor_kind,actor_member_id,payload)
341
+ SELECT id,'noted','human',member_id,jsonb_build_object('source_ref',p_source_ref,'source_kind','slack') FROM semicolony.team_members WHERE domain=semicolony.action_slack_member(p_requester_slack_user_id);
342
+ RETURN id;
343
+ END $$;
344
+
345
+ -- Preserve migration 225 meeting identity and KB/provenance behavior. Each item
346
+ -- has its own admission savepoint; a malformed sibling becomes a durable candidate.
347
+ CREATE OR REPLACE FUNCTION semicolony.record_meeting(
348
+ p_title text,
349
+ p_meeting_date date,
350
+ p_meeting_type text,
351
+ p_adhoc_subtype text,
352
+ p_visibility text,
353
+ p_target_domain text,
354
+ p_attendees jsonb,
355
+ p_record_body text,
356
+ p_transcript text,
357
+ p_action_items jsonb,
358
+ p_record_source text,
359
+ p_worker_id text,
360
+ p_device_id text,
361
+ p_discussion_url text DEFAULT NULL,
362
+ p_discussion_number integer DEFAULT NULL
363
+ )
364
+ RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
365
+ LANGUAGE plpgsql
366
+ SECURITY DEFINER
367
+ SET search_path = pg_catalog
368
+ AS $function$
369
+ DECLARE
370
+ v_meeting_id uuid;
371
+ v_kb_id bigint;
372
+ v_existing uuid;
373
+ v_norm text;
374
+ v_sensitivity text;
375
+ v_sub_key text;
376
+ v_item jsonb;
377
+ v_item_id uuid;
378
+ v_preserved jsonb;
379
+ v_description text;
380
+ v_priority text;
381
+ v_project text;
382
+ v_candidate text;
383
+ v_assignee_kind text;
384
+ v_assignee_member text;
385
+ v_assignee_agent text;
386
+ v_external_contact text;
387
+ v_deadline date;
388
+ v_proposal jsonb;
389
+ v_reasons jsonb;
390
+ v_candidate_id uuid;
391
+ v_identity text;
392
+ v_candidate_ids uuid[] := '{}';
393
+ v_existing_candidate semicolony.action_item_candidates%ROWTYPE;
394
+ BEGIN
395
+ IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
396
+ RAISE EXCEPTION 'title must be at least 3 characters';
397
+ END IF;
398
+ IF p_meeting_date IS NULL THEN
399
+ RAISE EXCEPTION 'meeting_date is required';
400
+ END IF;
401
+ IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
402
+ RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
403
+ END IF;
404
+ IF p_record_source IS NULL
405
+ OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
406
+ RAISE EXCEPTION 'invalid record_source %', p_record_source;
407
+ END IF;
408
+ IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
409
+ RAISE EXCEPTION 'record_body is required';
410
+ END IF;
411
+
412
+ v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
413
+ v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
414
+
415
+ -- Serialize all writers for one normalized meeting, including first insert.
416
+ PERFORM pg_advisory_xact_lock(hashtextextended(
417
+ 'meeting:' || p_meeting_date::text || ':' || v_norm, 0));
418
+
419
+ SELECT m.meeting_id INTO v_existing
420
+ FROM semicolony.meetings m
421
+ WHERE m.meeting_date = p_meeting_date
422
+ AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
423
+
424
+ -- Discussion provenance from 202_meeting_record_discussion_provenance,
425
+ -- unchanged: COALESCE on update so a re-record that omits the link does not
426
+ -- erase one an earlier submission established, and both columns written on
427
+ -- insert.
428
+ IF v_existing IS NOT NULL THEN
429
+ UPDATE semicolony.meetings m
430
+ SET record_body = COALESCE(p_record_body, m.record_body),
431
+ mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
432
+ visibility = p_visibility,
433
+ record_source = p_record_source,
434
+ target_domain = COALESCE(p_target_domain, m.target_domain),
435
+ attendees = COALESCE(p_attendees, m.attendees),
436
+ discussion_url = COALESCE(p_discussion_url, m.discussion_url),
437
+ discussion_number = COALESCE(p_discussion_number, m.discussion_number),
438
+ updated_at = now()
439
+ WHERE m.meeting_id = v_existing;
440
+ v_meeting_id := v_existing;
441
+ ELSE
442
+ INSERT INTO semicolony.meetings (
443
+ meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
444
+ visibility, record_body, mapped_transcript, record_source, target_domain,
445
+ transcription_status, speaker_map, discussion_url, discussion_number,
446
+ created_at, updated_at
447
+ ) VALUES (
448
+ gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
449
+ COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
450
+ p_transcript, p_record_source, p_target_domain,
451
+ 'completed', '{}'::jsonb, p_discussion_url, p_discussion_number, now(), now()
452
+ ) RETURNING meetings.meeting_id INTO v_meeting_id;
453
+ END IF;
454
+
455
+ v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
456
+
457
+ INSERT INTO semicolony.knowledge_base (
458
+ domain, key, sub_key, content, metadata, created_by, created_at, updated_at
459
+ ) VALUES (
460
+ COALESCE(p_target_domain, 'semicolon'),
461
+ 'meeting',
462
+ v_sub_key,
463
+ p_record_body,
464
+ jsonb_build_object(
465
+ 'classification', jsonb_build_object('sensitivity', v_sensitivity),
466
+ 'meeting_id', v_meeting_id,
467
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
468
+ 'record_source', p_record_source
469
+ ),
470
+ COALESCE(p_worker_id, 'meeting-worker'),
471
+ now(), now()
472
+ )
473
+ ON CONFLICT (domain, key, sub_key) DO UPDATE
474
+ SET content = EXCLUDED.content,
475
+ metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
476
+ updated_at = now()
477
+ RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
478
+
479
+
480
+ v_preserved := '[]';
481
+ IF jsonb_typeof(p_action_items)='array' THEN
482
+ FOR v_item IN SELECT value FROM jsonb_array_elements(p_action_items) LOOP
483
+ v_description := CASE WHEN jsonb_typeof(v_item->'description')='string' THEN btrim(v_item->>'description') ELSE '' END;
484
+ v_identity := coalesce(nullif(v_item->>'source_identity',''),encode(sha256(convert_to(
485
+ CASE WHEN v_description<>'' THEN lower(regexp_replace(v_description,'\s+',' ','g')) ELSE v_item::text END,'UTF8')),'hex'));
486
+ v_item_id := NULL;
487
+ SELECT a.action_item_id INTO v_item_id FROM semicolony.action_items a
488
+ WHERE a.meeting_id=v_meeting_id AND lower(regexp_replace(btrim(a.description),'\s+',' ','g'))=
489
+ lower(regexp_replace(v_description,'\s+',' ','g')) ORDER BY a.created_at,a.action_item_id LIMIT 1;
490
+ IF v_item_id IS NOT NULL THEN
491
+ v_preserved := v_preserved || jsonb_build_array(jsonb_build_object('action_item_id',v_item_id,'submitted',v_item,'reason','existing_followup_preserved'));
492
+ CONTINUE;
493
+ END IF;
494
+ v_project := coalesce(nullif(v_item->>'project_domain',''),nullif(v_item->>'target',''),nullif(p_target_domain,''));
495
+ v_assignee_member := nullif(v_item->>'assignee_member','');
496
+ v_assignee_agent := nullif(v_item->>'assignee_agent','');
497
+ v_candidate := coalesce(nullif(v_item->>'assignee',''),nullif(v_item->>'owner',''));
498
+ IF v_assignee_member IS NULL AND v_assignee_agent IS NULL AND v_candidate IS NOT NULL THEN
499
+ IF EXISTS(SELECT 1 FROM semicolony.team_members WHERE domain=v_candidate AND status='active') THEN v_assignee_member:=v_candidate;
500
+ ELSIF EXISTS(SELECT 1 FROM semicolony.bot_status WHERE bot_id=v_candidate AND status IS DISTINCT FROM 'retired') THEN v_assignee_agent:=v_candidate;
501
+ END IF;
502
+ END IF;
503
+ v_proposal := jsonb_strip_nulls(jsonb_build_object('description',v_item->'description','project_domain',v_project,
504
+ 'deadline',v_item->>'deadline','assignee_member',v_assignee_member,'assignee_agent',v_assignee_agent,
505
+ 'external_contact_id',v_item->>'external_contact_id','sponsor_member',v_item->>'sponsor_member',
506
+ 'external_contact',CASE WHEN v_assignee_member IS NULL AND v_assignee_agent IS NULL THEN coalesce(nullif(v_item->>'external_contact',''),v_candidate) ELSE v_item->>'external_contact' END,
507
+ 'priority',coalesce(v_item->>'priority','normal'),'metadata',jsonb_build_object('meeting_id',v_meeting_id,'classification',jsonb_build_object('sensitivity',v_sensitivity),'raw_item',v_item)));
508
+ v_candidate_id:=semicolony.create_action_candidate(v_proposal,'meeting',v_meeting_id::text,v_identity,'system',NULL,NULL,v_meeting_id);
509
+ v_candidate_ids:=array_append(v_candidate_ids,v_candidate_id);
510
+ SELECT * INTO v_existing_candidate FROM semicolony.action_item_candidates WHERE candidate_id=v_candidate_id;
511
+ IF v_existing_candidate.status<>'pending' OR v_existing_candidate.proposal IS DISTINCT FROM v_proposal THEN CONTINUE; END IF;
512
+ v_reasons:=semicolony.action_candidate_reasons(v_proposal);
513
+ IF v_reasons='[]'::jsonb THEN
514
+ BEGIN
515
+ PERFORM semicolony.resolve_action_candidate(v_candidate_id,'accept',v_existing_candidate.version,v_proposal,
516
+ 'Complete explicit meeting commitment','system',NULL,NULL);
517
+ EXCEPTION WHEN OTHERS THEN
518
+ -- Retain only SQLSTATE, never a raw database error/credential string.
519
+ UPDATE semicolony.action_item_candidates SET reasons=jsonb_build_array('admission_failed',SQLSTATE) WHERE candidate_id=v_candidate_id;
520
+ END;
521
+ END IF;
522
+ END LOOP;
523
+ UPDATE semicolony.meetings SET metadata=coalesce(metadata,'{}') || jsonb_build_object('meeting_import',
524
+ jsonb_build_object('candidate_ids',to_jsonb(v_candidate_ids),'preserved_action_items',v_preserved,
525
+ 'preserved_count',jsonb_array_length(v_preserved),'submitted_count',jsonb_array_length(p_action_items),'recorded_at',now()))
526
+ WHERE meetings.meeting_id=v_meeting_id;
527
+ END IF;
528
+ RETURN QUERY SELECT v_meeting_id,v_kb_id,
529
+ ARRAY(SELECT a.action_item_id FROM semicolony.action_items a WHERE a.meeting_id=v_meeting_id ORDER BY a.created_at,a.action_item_id),
530
+ v_existing IS NOT NULL;
531
+ END;
532
+ $function$;
533
+
534
+ -- Gateway cannot mint delegation. The hive service cannot call a generic editor.
535
+ DO $$ BEGIN
536
+ IF NOT EXISTS(SELECT 1 FROM pg_roles WHERE rolname='semo_ops_action_loop') THEN
537
+ CREATE ROLE semo_ops_action_loop NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS;
538
+ END IF;
539
+ END $$;
540
+ -- The runner retargets qualified identifiers, not bare GRANT schema names.
541
+ -- Resolve the namespace through the retargeted ledger relation itself.
542
+ DO $$ DECLARE target_schema text;
543
+ BEGIN
544
+ SELECT n.nspname INTO target_schema FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace
545
+ WHERE c.oid='semicolony.action_items'::regclass;
546
+ EXECUTE format('GRANT USAGE ON SCHEMA %I TO semo_ops_action_loop',target_schema);
547
+ END $$;
548
+ GRANT SELECT ON semicolony.action_items,semicolony.action_item_events,semicolony.team_members,
549
+ semicolony.meetings,semicolony.action_item_candidates,semicolony.action_external_contacts,
550
+ semicolony.action_item_dependencies TO semo_ops_action_loop;
551
+ GRANT SELECT ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies TO sc_app;
552
+ GRANT ALL ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies,semicolony.action_delegations TO app;
553
+ DO $$ BEGIN
554
+ IF EXISTS(SELECT 1 FROM pg_roles WHERE rolname='sc_fleet_board') THEN
555
+ GRANT SELECT ON semicolony.action_item_candidates,semicolony.action_external_contacts,semicolony.action_item_dependencies TO sc_fleet_board;
556
+ END IF;
557
+ END $$;
558
+ REVOKE ALL ON FUNCTION semicolony.bump_action_item_version(),semicolony.action_candidate_reasons(jsonb),
559
+ semicolony.validate_action_patch(jsonb),semicolony.action_slack_member(text),
560
+ semicolony.create_action_candidate(jsonb,text,text,text,text,text,text,uuid),
561
+ semicolony.resolve_action_candidate(uuid,text,bigint,jsonb,text,text,text,text),
562
+ semicolony.edit_action_item(uuid,text,text,text,bigint,jsonb,text),
563
+ semicolony.issue_action_delegation(text,text,uuid,bigint,jsonb,text,text,timestamptz),
564
+ semicolony.apply_slack_action_update(text,uuid,bigint,jsonb,text) FROM PUBLIC;
565
+ GRANT EXECUTE ON FUNCTION semicolony.create_action_candidate(jsonb,text,text,text,text,text,text,uuid),
566
+ semicolony.resolve_action_candidate(uuid,text,bigint,jsonb,text,text,text,text),
567
+ semicolony.edit_action_item(uuid,text,text,text,bigint,jsonb,text) TO sc_app,app;
568
+ GRANT EXECUTE ON FUNCTION semicolony.issue_action_delegation(text,text,uuid,bigint,jsonb,text,text,timestamptz),
569
+ semicolony.apply_slack_action_update(text,uuid,bigint,jsonb,text) TO semo_ops_action_loop,app;
570
+
571
+ CREATE FUNCTION semicolony.create_action_item_v2(
572
+ p_description text,
573
+ p_project_domain text,
574
+ p_deadline date,
575
+ p_assignee_kind text,
576
+ p_assignee_member text,
577
+ p_assignee_agent text,
578
+ p_assignee_device text,
579
+ p_priority text,
580
+ p_source_kind text,
581
+ p_source_ref text,
582
+ p_related_url text,
583
+ p_external_contact text,
584
+ p_metadata jsonb,
585
+ p_origin_surface text,
586
+ p_origin_actor_kind text,
587
+ p_origin_member text,
588
+ p_origin_agent text,
589
+ p_origin_device text,
590
+ p_origin_harness text,
591
+ p_origin_session_key text,
592
+ p_external_contact_id uuid,
593
+ p_sponsor_member text
594
+ ) RETURNS uuid LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
595
+ DECLARE id uuid; sponsor uuid; contact uuid := p_external_contact_id;
596
+ BEGIN
597
+ IF contact IS NOT NULL AND NOT semicolony.action_contact_worker_visible(contact::text) THEN RAISE EXCEPTION 'external_contact_not_found' USING ERRCODE='AI002'; END IF;
598
+ IF p_sponsor_member IS NOT NULL THEN
599
+ SELECT member_id INTO sponsor FROM semicolony.team_members WHERE domain=p_sponsor_member AND status='active';
600
+ IF sponsor IS NULL THEN RAISE EXCEPTION 'unknown or inactive sponsor' USING ERRCODE='22023'; END IF;
601
+ END IF;
602
+ IF p_external_contact_id IS NOT NULL AND (sponsor IS NULL OR p_assignee_kind<>'unassigned') THEN RAISE EXCEPTION 'external work requires internal sponsor' USING ERRCODE='22023'; END IF;
603
+ IF contact IS NULL AND p_external_contact IS NOT NULL AND sponsor IS NOT NULL THEN
604
+ INSERT INTO semicolony.action_external_contacts(display_name,metadata) VALUES(p_external_contact,jsonb_build_object('classification',coalesce(p_metadata->'classification','{}'::jsonb))) RETURNING external_contact_id INTO contact;
605
+ END IF;
606
+ IF contact IS NOT NULL AND (sponsor IS NULL OR p_assignee_kind<>'unassigned') THEN RAISE EXCEPTION 'external work requires internal sponsor' USING ERRCODE='22023'; END IF;
607
+ id:=semicolony.create_action_item(p_description,p_project_domain,p_deadline,p_assignee_kind,p_assignee_member,p_assignee_agent,p_assignee_device,p_priority,p_source_kind,p_source_ref,p_related_url,p_external_contact,p_metadata,p_origin_surface,p_origin_actor_kind,p_origin_member,p_origin_agent,p_origin_device,p_origin_harness,p_origin_session_key);
608
+ UPDATE semicolony.action_items SET external_contact_id=contact,sponsor_member_id=sponsor,
609
+ waiting_on_external=(contact IS NOT NULL) WHERE action_item_id=id;
610
+ RETURN id;
611
+ END $$;
612
+ REVOKE ALL ON FUNCTION semicolony.create_action_item_v2(text,text,date,text,text,text,text,text,text,text,text,text,jsonb,text,text,text,text,text,text,text,uuid,text) FROM PUBLIC;
613
+ GRANT EXECUTE ON FUNCTION semicolony.create_action_item_v2(text,text,date,text,text,text,text,text,text,text,text,text,jsonb,text,text,text,text,text,text,text,uuid,text) TO sc_app,app;
614
+
615
+ -- Authenticated Slack confirmation uses the same candidate owner boundary.
616
+ CREATE FUNCTION semicolony.apply_slack_candidate_resolution(p_requester_slack_user_id text,p_candidate_id uuid,
617
+ p_decision text,p_expected_version bigint,p_fields jsonb,p_note text,p_source_ref text) RETURNS uuid
618
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path=pg_catalog AS $$
619
+ DECLARE id uuid;
620
+ BEGIN
621
+ IF p_expected_version IS NULL OR length(btrim(coalesce(p_source_ref,'')))=0 THEN RAISE EXCEPTION 'version and Slack source required' USING ERRCODE='22023'; END IF;
622
+ id:=semicolony.resolve_action_candidate(p_candidate_id,p_decision,p_expected_version,p_fields,p_note,
623
+ 'team-member',semicolony.action_slack_member(p_requester_slack_user_id),NULL);
624
+ UPDATE semicolony.action_item_candidates SET resolution_source_ref=coalesce(resolution_source_ref,p_source_ref) WHERE candidate_id=p_candidate_id;
625
+ RETURN id;
626
+ END $$;
627
+ REVOKE ALL ON FUNCTION semicolony.apply_slack_candidate_resolution(text,uuid,text,bigint,jsonb,text,text) FROM PUBLIC;
628
+ GRANT EXECUTE ON FUNCTION semicolony.apply_slack_candidate_resolution(text,uuid,text,bigint,jsonb,text,text) TO semo_ops_action_loop,app;
629
+
630
+ REVOKE ALL ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) FROM PUBLIC;
631
+ GRANT EXECUTE ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) TO sc_app,app,semo_ops_action_loop;
632
+ DO $$ BEGIN IF EXISTS(SELECT 1 FROM pg_roles WHERE rolname='sc_fleet_board') THEN
633
+ GRANT EXECUTE ON FUNCTION semicolony.action_item_worker_visible(uuid),semicolony.action_candidate_worker_visible(uuid),semicolony.action_contact_worker_visible(text) TO sc_fleet_board;
634
+ END IF; END $$;