@team-semicolon/semicolony-cli 4.18.104 → 4.18.106

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,1369 @@
1
+ -- 203_action_item_redesign_cutover.sql
2
+ --
3
+ -- Measured on 2026-08-12 against the live operator database, the old table had
4
+ -- 1,757 rows and could not answer who owned anything:
5
+ -- * assignee NULL in 1,526 rows (86.8%), free text otherwise
6
+ -- * owner_domain conflated person and project domains under one ontology FK
7
+ -- * deadline NULL in 1,243 rows (70.7%); 334 open rows already overdue
8
+ -- * source uncontrolled free text, 50+ values including raw Slack coordinates
9
+ -- * runtime_source NULL in 1,429 rows and 'manual' in 328 — no provenance
10
+ --
11
+ -- The rename and the create live in one file on purpose: two migrations would
12
+ -- leave a window in which slack-router and the dashboard see no action_items
13
+ -- table at all.
14
+
15
+ ALTER TABLE semicolony.action_items
16
+ RENAME TO action_items_archive_20260812;
17
+
18
+ COMMENT ON TABLE semicolony.action_items_archive_20260812 IS
19
+ 'Frozen pre-redesign action items (1,757 rows as of 2026-08-12). Read-only. Rollback path: run packages/cli/scripts/rollback-action-item-redesign.sql -- a bare rename-back does not work (see that file''s header: the inbound action_item_events FK, the seven archive index names, record_feedback still reading the archive, and the four write functions late-binding to the wrong table all have to be reversed together).';
20
+
21
+ -- ALTER TABLE ... RENAME does not rename the table's indexes: index names
22
+ -- live in the schema namespace, not the table's, so after the rename above
23
+ -- these seven names still sit on the archive and collide with what this file
24
+ -- creates below. Measured live indexes on action_items (2026-08-13, against
25
+ -- the production schema dump taken after migrations 198-201 landed):
26
+ -- action_items_pkey, action_items_assignee_status_idx,
27
+ -- action_items_created_at_idx, action_items_owner_domain_status_idx,
28
+ -- action_items_target_domain_status_idx, idx_action_items_meeting,
29
+ -- idx_action_items_tenant. Three collide today —
30
+ -- action_items_assignee_status_idx explicitly (CREATE INDEX fails with
31
+ -- 42P07: relation already exists), idx_action_items_meeting explicitly for
32
+ -- exactly the same reason now that this file recreates it on the new table
33
+ -- (added by 198_meeting_record_canonical.sql, which is why the earlier
34
+ -- six-index list is no longer complete), and action_items_pkey implicitly
35
+ -- (the new table's unnamed PRIMARY KEY would silently be named
36
+ -- action_items_pkey1 instead of erroring, leaving the live table with a
37
+ -- mangled constraint name). Renaming all seven, not just the three that
38
+ -- collide right now, so a future index added to either this file or the
39
+ -- archive doesn't reopen the same trap. ALTER INDEX is catalog-only and
40
+ -- transactional, so it belongs in this same file/transaction as the rename
41
+ -- it depends on. idx_action_items_tenant is renamed here for the same reason
42
+ -- but deliberately not recreated on the new table below — that gap is a
43
+ -- separate, already-recorded deferred item, not part of this fix.
44
+ ALTER INDEX semicolony.action_items_pkey RENAME TO action_items_pkey_archive_20260812;
45
+ ALTER INDEX semicolony.action_items_assignee_status_idx RENAME TO action_items_assignee_status_idx_archive_20260812;
46
+ ALTER INDEX semicolony.action_items_created_at_idx RENAME TO action_items_created_at_idx_archive_20260812;
47
+ ALTER INDEX semicolony.action_items_owner_domain_status_idx RENAME TO action_items_owner_domain_status_idx_archive_20260812;
48
+ ALTER INDEX semicolony.action_items_target_domain_status_idx RENAME TO action_items_target_domain_status_idx_archive_20260812;
49
+ ALTER INDEX semicolony.idx_action_items_meeting RENAME TO idx_action_items_meeting_archive_20260812;
50
+ ALTER INDEX semicolony.idx_action_items_tenant RENAME TO idx_action_items_tenant_archive_20260812;
51
+
52
+ CREATE TABLE semicolony.action_items (
53
+ action_item_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
54
+ description text NOT NULL CHECK (length(btrim(description)) >= 5),
55
+
56
+ assignee_kind text NOT NULL
57
+ CHECK (assignee_kind IN ('team-member', 'agent', 'unassigned')),
58
+ assignee_member_id uuid REFERENCES semicolony.team_members(member_id),
59
+ assignee_agent_id text REFERENCES semicolony.bot_status(bot_id),
60
+ assignee_device_id text,
61
+ external_contact text,
62
+
63
+ project_domain varchar(100) NOT NULL,
64
+ project_entity text GENERATED ALWAYS AS ('service') STORED,
65
+
66
+ issued_at timestamptz NOT NULL DEFAULT now(),
67
+ deadline date NOT NULL,
68
+ completed_at timestamptz,
69
+
70
+ status text NOT NULL DEFAULT 'open'
71
+ CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'cancelled')),
72
+ priority text NOT NULL DEFAULT 'normal'
73
+ CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
74
+
75
+ origin_surface text NOT NULL
76
+ CHECK (origin_surface IN ('worker', 'system', 'dashboard')),
77
+ origin_actor_kind text CHECK (origin_actor_kind IN ('human', 'agent')),
78
+ origin_member_id uuid REFERENCES semicolony.team_members(member_id),
79
+ origin_agent_id text REFERENCES semicolony.bot_status(bot_id),
80
+ origin_device_id text REFERENCES semicolony.worker_devices(device_id),
81
+ origin_harness text CHECK (origin_harness IN
82
+ ('claude-code', 'codex', 'cli', 'slack', 'web', 'cron')),
83
+ origin_session_key text,
84
+
85
+ source_kind text NOT NULL CHECK (source_kind IN
86
+ ('meeting', 'feedback', 'incident', 'review',
87
+ 'decision', 'manual', 'cron', 'slack', 'kb')),
88
+ source_ref text,
89
+ related_url text,
90
+
91
+ -- Carried forward from 198_meeting_record_canonical.sql, which added this
92
+ -- column (and idx_action_items_meeting below) to the pre-redesign table
93
+ -- while this branch was in review. The typed relation is kept rather than
94
+ -- folded into source_kind/source_ref: source_ref is uncontrolled text and
95
+ -- cannot express "this row belongs to that meeting" to the planner or to a
96
+ -- FK, and record_meeting's idempotency (delete-and-recreate this meeting's
97
+ -- machine-created items, leave every human-filed item alone) is defined by
98
+ -- exactly this column. Same target and same nullability as 198 declared:
99
+ -- nullable, because the overwhelming majority of action items have no
100
+ -- meeting and must not be invented one. source_kind='meeting' /
101
+ -- source_ref=<meeting_id> is written alongside it so the untyped source
102
+ -- vocabulary stays complete for readers that only know that pair.
103
+ meeting_id uuid REFERENCES semicolony.meetings(meeting_id),
104
+
105
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
106
+ created_at timestamptz NOT NULL DEFAULT now(),
107
+ updated_at timestamptz NOT NULL DEFAULT now(),
108
+
109
+ tenant_id uuid,
110
+ scope text NOT NULL DEFAULT 'platform-global'
111
+ CHECK (scope IN ('tenant-local', 'platform-global')),
112
+ install_id uuid,
113
+
114
+ FOREIGN KEY (project_domain, project_entity)
115
+ REFERENCES semicolony.ontology(domain, entity_type),
116
+ FOREIGN KEY (assignee_device_id, assignee_member_id)
117
+ REFERENCES semicolony.worker_devices(device_id, member_id),
118
+
119
+ -- MATCH SIMPLE skips the composite FK entirely when one column is NULL, so a
120
+ -- device with no member would be unverified. MATCH FULL cannot be used: it
121
+ -- would also reject the common (no device, member) case.
122
+ CONSTRAINT action_items_device_requires_member
123
+ CHECK (assignee_device_id IS NULL OR assignee_member_id IS NOT NULL),
124
+ CONSTRAINT action_items_member_assignee
125
+ CHECK (assignee_kind <> 'team-member' OR assignee_member_id IS NOT NULL),
126
+ CONSTRAINT action_items_agent_assignee
127
+ CHECK (assignee_kind <> 'agent' OR assignee_agent_id IS NOT NULL),
128
+ CONSTRAINT action_items_unassigned_is_empty
129
+ CHECK (assignee_kind <> 'unassigned'
130
+ OR (assignee_member_id IS NULL AND assignee_agent_id IS NULL)),
131
+ CONSTRAINT action_items_worker_origin_has_actor
132
+ CHECK (origin_surface <> 'worker' OR origin_actor_kind IS NOT NULL),
133
+ CONSTRAINT action_items_human_origin_complete
134
+ CHECK (origin_actor_kind <> 'human'
135
+ OR (origin_member_id IS NOT NULL AND origin_harness IS NOT NULL)),
136
+ CONSTRAINT action_items_agent_origin_complete
137
+ CHECK (origin_actor_kind <> 'agent' OR origin_agent_id IS NOT NULL),
138
+ CONSTRAINT action_items_done_has_timestamp
139
+ CHECK (status <> 'done' OR completed_at IS NOT NULL)
140
+ );
141
+
142
+ CREATE INDEX action_items_status_deadline_idx ON semicolony.action_items (status, deadline);
143
+ CREATE INDEX action_items_assignee_status_idx ON semicolony.action_items (assignee_member_id, status);
144
+ CREATE INDEX action_items_project_status_idx ON semicolony.action_items (project_domain, status);
145
+ CREATE INDEX action_items_open_deadline_idx ON semicolony.action_items (deadline)
146
+ WHERE status IN ('open', 'in_progress', 'blocked');
147
+ CREATE INDEX action_items_origin_idx ON semicolony.action_items (origin_surface, origin_actor_kind);
148
+
149
+ -- Recreated with 198_meeting_record_canonical.sql's exact definition (partial
150
+ -- on meeting_id IS NOT NULL). The name was freed by the archive rename above;
151
+ -- without that rename this statement fails with 42P07, the same way
152
+ -- action_items_assignee_status_idx did on the first scratch-database apply.
153
+ CREATE INDEX IF NOT EXISTS idx_action_items_meeting
154
+ ON semicolony.action_items (meeting_id) WHERE meeting_id IS NOT NULL;
155
+
156
+ CREATE TABLE semicolony.action_item_events (
157
+ event_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
158
+ action_item_id uuid NOT NULL REFERENCES semicolony.action_items(action_item_id) ON DELETE CASCADE,
159
+ event_type text NOT NULL CHECK (event_type IN
160
+ ('created', 'assigned', 'status_changed', 'deadline_changed', 'noted', 'closed')),
161
+ actor_kind text CHECK (actor_kind IN ('human', 'agent', 'system')),
162
+ actor_member_id uuid REFERENCES semicolony.team_members(member_id),
163
+ actor_agent_id text REFERENCES semicolony.bot_status(bot_id),
164
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
165
+ created_at timestamptz NOT NULL DEFAULT now()
166
+ );
167
+
168
+ CREATE INDEX action_item_events_item_idx ON semicolony.action_item_events (action_item_id, created_at);
169
+
170
+ CREATE OR REPLACE VIEW semicolony.v_action_actors AS
171
+ SELECT 'team-member'::text AS actor_kind,
172
+ tm.domain AS actor_ref,
173
+ tm.display_name AS display_name,
174
+ coalesce(array_agg(wd.device_id) FILTER (WHERE wd.device_id IS NOT NULL), '{}') AS device_ids
175
+ FROM semicolony.team_members tm
176
+ LEFT JOIN semicolony.worker_devices wd ON wd.member_id = tm.member_id
177
+ WHERE tm.status = 'active'
178
+ GROUP BY tm.domain, tm.display_name
179
+ UNION ALL
180
+ -- F9: <> silently drops a NULL-status bot (NULL <> 'retired' is NULL, not
181
+ -- true, so WHERE excludes it) instead of treating "no status recorded" as
182
+ -- "not retired". IS DISTINCT FROM is NULL-safe: a NULL status is retained.
183
+ SELECT 'agent'::text, bs.bot_id, bs.name, '{}'::text[]
184
+ FROM semicolony.bot_status bs
185
+ WHERE bs.status IS DISTINCT FROM 'retired';
186
+
187
+ -- STABLE, not IMMUTABLE: the function reads current_date, which changes across
188
+ -- calls in different transactions. IMMUTABLE would be legal in index
189
+ -- expressions/generated columns (where the date would freeze at DDL time) and
190
+ -- invites plan-time constant folding — wrong for a moving default.
191
+ CREATE OR REPLACE FUNCTION semicolony.action_item_default_deadline(p_priority text)
192
+ RETURNS date
193
+ LANGUAGE sql STABLE
194
+ AS $$
195
+ SELECT current_date + CASE p_priority
196
+ WHEN 'urgent' THEN 1
197
+ WHEN 'high' THEN 3
198
+ WHEN 'low' THEN 14
199
+ ELSE 7
200
+ END;
201
+ $$;
202
+
203
+ -- F9: every other function in this file gets a REVOKE ALL ... FROM PUBLIC;
204
+ -- this one didn't, so PostgreSQL's default EXECUTE-to-PUBLIC on a new
205
+ -- function left it callable by any role. It doesn't touch data, but nothing
206
+ -- else in this file leaves that gap open, so this one shouldn't either.
207
+ REVOKE ALL ON FUNCTION semicolony.action_item_default_deadline(text) FROM PUBLIC;
208
+
209
+ CREATE OR REPLACE FUNCTION semicolony.create_action_item(
210
+ p_description text,
211
+ p_project_domain text,
212
+ p_deadline date,
213
+ p_assignee_kind text,
214
+ p_assignee_member text,
215
+ p_assignee_agent text,
216
+ p_assignee_device text,
217
+ p_priority text,
218
+ p_source_kind text,
219
+ p_source_ref text,
220
+ p_related_url text,
221
+ p_external_contact text,
222
+ p_metadata jsonb,
223
+ p_origin_surface text,
224
+ p_origin_actor_kind text,
225
+ p_origin_member text,
226
+ p_origin_agent text,
227
+ p_origin_device text,
228
+ p_origin_harness text,
229
+ p_origin_session_key text
230
+ ) RETURNS uuid
231
+ LANGUAGE plpgsql SECURITY DEFINER
232
+ SET search_path = pg_catalog
233
+ AS $$
234
+ DECLARE
235
+ v_assignee_member_id uuid;
236
+ v_origin_member_id uuid;
237
+ v_id uuid;
238
+ BEGIN
239
+ IF p_description IS NULL OR char_length(btrim(p_description)) < 5 THEN
240
+ RAISE EXCEPTION 'invalid action item description' USING ERRCODE = '22023';
241
+ END IF;
242
+ IF p_deadline IS NULL THEN
243
+ RAISE EXCEPTION 'action item deadline is required' USING ERRCODE = '22023';
244
+ END IF;
245
+
246
+ IF p_assignee_member IS NOT NULL THEN
247
+ SELECT member_id INTO v_assignee_member_id
248
+ FROM semicolony.team_members WHERE domain = p_assignee_member;
249
+ IF v_assignee_member_id IS NULL THEN
250
+ RAISE EXCEPTION 'unknown team member %', p_assignee_member USING ERRCODE = '22023';
251
+ END IF;
252
+ END IF;
253
+
254
+ IF p_origin_member IS NOT NULL THEN
255
+ SELECT member_id INTO v_origin_member_id
256
+ FROM semicolony.team_members WHERE domain = p_origin_member;
257
+ IF v_origin_member_id IS NULL THEN
258
+ RAISE EXCEPTION 'unknown origin member %', p_origin_member USING ERRCODE = '22023';
259
+ END IF;
260
+ END IF;
261
+
262
+ -- F5: give agent ids the same friendly 22023 validation the member ids
263
+ -- above get, instead of letting an unknown agent fall through to the raw
264
+ -- FK violation (23503) at INSERT time below.
265
+ IF p_assignee_agent IS NOT NULL THEN
266
+ IF NOT EXISTS (SELECT 1 FROM semicolony.bot_status WHERE bot_id = p_assignee_agent) THEN
267
+ RAISE EXCEPTION 'unknown assignee agent %', p_assignee_agent USING ERRCODE = '22023';
268
+ END IF;
269
+ END IF;
270
+
271
+ IF p_origin_agent IS NOT NULL THEN
272
+ IF NOT EXISTS (SELECT 1 FROM semicolony.bot_status WHERE bot_id = p_origin_agent) THEN
273
+ RAISE EXCEPTION 'unknown origin agent %', p_origin_agent USING ERRCODE = '22023';
274
+ END IF;
275
+ END IF;
276
+
277
+ INSERT INTO semicolony.action_items (
278
+ description, assignee_kind, assignee_member_id, assignee_agent_id,
279
+ assignee_device_id, external_contact, project_domain, deadline, priority,
280
+ origin_surface, origin_actor_kind, origin_member_id, origin_agent_id,
281
+ origin_device_id, origin_harness, origin_session_key,
282
+ source_kind, source_ref, related_url, metadata
283
+ ) VALUES (
284
+ btrim(p_description), p_assignee_kind, v_assignee_member_id, p_assignee_agent,
285
+ p_assignee_device, p_external_contact, p_project_domain, p_deadline,
286
+ coalesce(p_priority, 'normal'),
287
+ p_origin_surface, p_origin_actor_kind, v_origin_member_id, p_origin_agent,
288
+ p_origin_device, p_origin_harness, p_origin_session_key,
289
+ p_source_kind, p_source_ref, p_related_url, coalesce(p_metadata, '{}'::jsonb)
290
+ ) RETURNING action_item_id INTO v_id;
291
+
292
+ INSERT INTO semicolony.action_item_events (action_item_id, event_type, actor_kind,
293
+ actor_member_id, actor_agent_id, payload)
294
+ VALUES (v_id, 'created',
295
+ CASE WHEN p_origin_actor_kind IS NULL THEN 'system' ELSE p_origin_actor_kind END,
296
+ v_origin_member_id, p_origin_agent,
297
+ jsonb_build_object('surface', p_origin_surface, 'harness', p_origin_harness));
298
+
299
+ RETURN v_id;
300
+ END;
301
+ $$;
302
+
303
+ CREATE OR REPLACE FUNCTION semicolony.update_action_item(
304
+ p_action_item_id uuid,
305
+ p_caller_kind text,
306
+ p_caller_member text,
307
+ p_caller_agent text,
308
+ p_status text,
309
+ p_deadline date,
310
+ p_note text,
311
+ p_metadata jsonb
312
+ ) RETURNS uuid
313
+ LANGUAGE plpgsql SECURITY DEFINER
314
+ SET search_path = pg_catalog
315
+ AS $$
316
+ DECLARE
317
+ v_item semicolony.action_items%ROWTYPE;
318
+ v_caller_member_id uuid;
319
+ BEGIN
320
+ SELECT * INTO v_item FROM semicolony.action_items
321
+ WHERE action_item_id = p_action_item_id FOR UPDATE;
322
+ IF NOT FOUND THEN
323
+ RAISE EXCEPTION 'action_item_not_found' USING ERRCODE = 'AI002';
324
+ END IF;
325
+
326
+ -- F1: `metadata || p_metadata` reshapes the jsonb column into an array
327
+ -- whenever p_metadata is anything but a JSON object (e.g. '[1,2]'::jsonb
328
+ -- concatenated onto '{"a":1}'::jsonb yields '[{"a":1},1,2]'), and the
329
+ -- column carries no object CHECK. Reachable from a dashboard PATCH route
330
+ -- that passes metadata straight from a request body. NULL is valid here
331
+ -- (it means "no metadata change"), unlike record_feedback's p_metadata,
332
+ -- which is required -- so this only rejects a non-NULL non-object.
333
+ IF p_metadata IS NOT NULL AND jsonb_typeof(p_metadata) <> 'object' THEN
334
+ RAISE EXCEPTION 'invalid action item metadata' USING ERRCODE = '22023';
335
+ END IF;
336
+
337
+ IF p_caller_member IS NOT NULL THEN
338
+ SELECT member_id INTO v_caller_member_id
339
+ FROM semicolony.team_members WHERE domain = p_caller_member;
340
+ END IF;
341
+
342
+ -- Ownership is decided here, not in the route: any other caller of this
343
+ -- function must face the same gate. coalesce(..., false) is load-bearing:
344
+ -- an unassigned item has assignee_member_id/assignee_agent_id NULL, so the
345
+ -- bare comparison evaluates to NULL (not false) and a bare `IF NOT (...)`
346
+ -- would treat NULL as false-y and silently skip the RAISE, letting any
347
+ -- caller "own" an unassigned item. Every item record_feedback creates is
348
+ -- unassigned, so this was reachable from production traffic.
349
+ IF NOT coalesce(
350
+ (p_caller_kind = 'team-member' AND v_caller_member_id IS NOT NULL
351
+ AND v_item.assignee_member_id = v_caller_member_id)
352
+ OR (p_caller_kind = 'agent' AND p_caller_agent IS NOT NULL
353
+ AND v_item.assignee_agent_id = p_caller_agent),
354
+ false
355
+ ) THEN
356
+ RAISE EXCEPTION 'action_item_not_owned' USING ERRCODE = 'AI001';
357
+ END IF;
358
+
359
+ -- completed_at tracks the *current* status, not just the first time it hit
360
+ -- 'done': clear it on any move to a different non-NULL status, so
361
+ -- reopening an item (done -> in_progress) doesn't leave a stale completion
362
+ -- timestamp that a `completed_at IS NOT NULL` consumer would misread as
363
+ -- still-done. p_status IS NULL means "leave status unchanged," so
364
+ -- completed_at is left unchanged too in that case. p_metadata (last
365
+ -- parameter, so the existing 7-argument call sites are easy to update)
366
+ -- merges into the row's metadata with ||; NULL means no change — this
367
+ -- keeps JSON detail out of the free-text p_note field.
368
+ UPDATE semicolony.action_items
369
+ SET status = coalesce(p_status, status),
370
+ deadline = coalesce(p_deadline, deadline),
371
+ -- F7: only stamp completed_at on an actual transition into 'done'.
372
+ -- `status` here is the table's own pre-update column value (single-
373
+ -- table UPDATE SET-list expressions see the OLD row), so a done ->
374
+ -- done no-op re-save leaves the original completion time alone
375
+ -- instead of silently bumping it to now().
376
+ completed_at = CASE
377
+ WHEN p_status = 'done' AND status IS DISTINCT FROM 'done' THEN now()
378
+ WHEN p_status = 'done' THEN completed_at
379
+ WHEN p_status IS NOT NULL THEN NULL
380
+ ELSE completed_at
381
+ END,
382
+ metadata = CASE WHEN p_metadata IS NULL THEN metadata ELSE metadata || p_metadata END,
383
+ updated_at = now()
384
+ WHERE action_item_id = p_action_item_id;
385
+
386
+ INSERT INTO semicolony.action_item_events (action_item_id, event_type, actor_kind,
387
+ actor_member_id, actor_agent_id, payload)
388
+ VALUES (p_action_item_id,
389
+ CASE WHEN p_status = 'done' THEN 'closed'
390
+ WHEN p_status IS NOT NULL THEN 'status_changed'
391
+ WHEN p_deadline IS NOT NULL THEN 'deadline_changed'
392
+ ELSE 'noted' END,
393
+ CASE WHEN p_caller_kind = 'agent' THEN 'agent' ELSE 'human' END,
394
+ v_caller_member_id, p_caller_agent,
395
+ jsonb_build_object('status', p_status, 'deadline', p_deadline, 'note', p_note, 'metadata', p_metadata));
396
+
397
+ RETURN p_action_item_id;
398
+ END;
399
+ $$;
400
+
401
+ -- Authority is separated by GRANT, never by a caller-supplied string:
402
+ -- assign_action_item and operator_update_action_item below carry no
403
+ -- ownership check and are never granted to sc_app (the worker Gateway
404
+ -- role) — only to app and semo_ops_bot. A p_caller_kind='operator' bypass
405
+ -- on update_action_item would have let sc_app simply claim it, so authority
406
+ -- is separated by which role can execute which function instead.
407
+ --
408
+ -- record_feedback creates every item with p_assignee_kind => 'unassigned'
409
+ -- (feedback is unassigned until triaged), and neither create_action_item nor
410
+ -- update_action_item can set an assignee after creation — update_action_item
411
+ -- can't, because its ownership gate above denies every caller when both
412
+ -- assignee_member_id and assignee_agent_id are NULL. Without this function
413
+ -- the entire feedback backlog is permanently unclosable by anyone, and the
414
+ -- dashboard's "담당자 없음" triage flow has no action behind it.
415
+ CREATE OR REPLACE FUNCTION semicolony.assign_action_item(
416
+ p_action_item_id uuid,
417
+ p_assignee_kind text,
418
+ p_assignee_member text,
419
+ p_assignee_agent text,
420
+ p_assignee_device text,
421
+ p_actor_kind text,
422
+ p_actor_member text,
423
+ p_actor_agent text
424
+ ) RETURNS uuid
425
+ LANGUAGE plpgsql SECURITY DEFINER
426
+ SET search_path = pg_catalog
427
+ AS $$
428
+ DECLARE
429
+ v_assignee_member_id uuid;
430
+ v_actor_member_id uuid;
431
+ BEGIN
432
+ PERFORM 1 FROM semicolony.action_items WHERE action_item_id = p_action_item_id FOR UPDATE;
433
+ IF NOT FOUND THEN
434
+ RAISE EXCEPTION 'action_item_not_found' USING ERRCODE = 'AI002';
435
+ END IF;
436
+
437
+ IF p_assignee_member IS NOT NULL THEN
438
+ SELECT member_id INTO v_assignee_member_id
439
+ FROM semicolony.team_members WHERE domain = p_assignee_member;
440
+ IF v_assignee_member_id IS NULL THEN
441
+ RAISE EXCEPTION 'unknown team member %', p_assignee_member USING ERRCODE = '22023';
442
+ END IF;
443
+ END IF;
444
+
445
+ -- F4: on this ungated path the event row's actor is the only
446
+ -- accountability control, so an unknown actor domain must not silently
447
+ -- resolve to a NULL actor. Same friendly-error form create_action_item
448
+ -- already uses for p_origin_member.
449
+ IF p_actor_member IS NOT NULL THEN
450
+ SELECT member_id INTO v_actor_member_id
451
+ FROM semicolony.team_members WHERE domain = p_actor_member;
452
+ IF v_actor_member_id IS NULL THEN
453
+ RAISE EXCEPTION 'unknown actor member %', p_actor_member USING ERRCODE = '22023';
454
+ END IF;
455
+ END IF;
456
+
457
+ -- assignee_kind/assignee_member_id/assignee_agent_id/assignee_device_id
458
+ -- consistency (team-member needs a member, agent needs an agent,
459
+ -- unassigned needs neither, a device needs a member) is enforced by the
460
+ -- table's own CHECK/FK constraints — the same ones create_action_item's
461
+ -- INSERT is subject to — so this UPDATE doesn't duplicate that logic.
462
+ UPDATE semicolony.action_items
463
+ SET assignee_kind = p_assignee_kind,
464
+ assignee_member_id = v_assignee_member_id,
465
+ assignee_agent_id = p_assignee_agent,
466
+ assignee_device_id = p_assignee_device,
467
+ updated_at = now()
468
+ WHERE action_item_id = p_action_item_id;
469
+
470
+ INSERT INTO semicolony.action_item_events (action_item_id, event_type, actor_kind,
471
+ actor_member_id, actor_agent_id, payload)
472
+ VALUES (p_action_item_id, 'assigned',
473
+ -- F4: match create_action_item's actor_kind default (NULL ->
474
+ -- 'system'), not a bare "anything but 'agent' is 'human'" that
475
+ -- silently mislabels an unspecified actor as a person.
476
+ CASE WHEN p_actor_kind IS NULL THEN 'system' ELSE p_actor_kind END,
477
+ v_actor_member_id, p_actor_agent,
478
+ jsonb_build_object('assignee_kind', p_assignee_kind,
479
+ 'assignee_member', p_assignee_member,
480
+ 'assignee_agent', p_assignee_agent,
481
+ 'assignee_device', p_assignee_device));
482
+
483
+ RETURN p_action_item_id;
484
+ END;
485
+ $$;
486
+
487
+ CREATE OR REPLACE FUNCTION semicolony.operator_update_action_item(
488
+ p_action_item_id uuid,
489
+ p_status text,
490
+ p_deadline date,
491
+ p_note text,
492
+ p_metadata jsonb,
493
+ p_actor_kind text,
494
+ p_actor_member text,
495
+ p_actor_agent text
496
+ ) RETURNS uuid
497
+ LANGUAGE plpgsql SECURITY DEFINER
498
+ SET search_path = pg_catalog
499
+ AS $$
500
+ DECLARE
501
+ v_actor_member_id uuid;
502
+ BEGIN
503
+ PERFORM 1 FROM semicolony.action_items WHERE action_item_id = p_action_item_id FOR UPDATE;
504
+ IF NOT FOUND THEN
505
+ RAISE EXCEPTION 'action_item_not_found' USING ERRCODE = 'AI002';
506
+ END IF;
507
+
508
+ -- F1: same non-object metadata guard as update_action_item -- see that
509
+ -- function's comment. NULL still means "no metadata change".
510
+ IF p_metadata IS NOT NULL AND jsonb_typeof(p_metadata) <> 'object' THEN
511
+ RAISE EXCEPTION 'invalid action item metadata' USING ERRCODE = '22023';
512
+ END IF;
513
+
514
+ -- F4: this is the other ungated path -- same unknown-actor guard as
515
+ -- assign_action_item above, for the same reason (the event row is the
516
+ -- only accountability control here).
517
+ IF p_actor_member IS NOT NULL THEN
518
+ SELECT member_id INTO v_actor_member_id
519
+ FROM semicolony.team_members WHERE domain = p_actor_member;
520
+ IF v_actor_member_id IS NULL THEN
521
+ RAISE EXCEPTION 'unknown actor member %', p_actor_member USING ERRCODE = '22023';
522
+ END IF;
523
+ END IF;
524
+
525
+ -- No ownership check — same completed_at-clearing semantics as
526
+ -- update_action_item above (J3), so an operator reopening an item doesn't
527
+ -- leave a stale completion timestamp either.
528
+ UPDATE semicolony.action_items
529
+ SET status = coalesce(p_status, status),
530
+ deadline = coalesce(p_deadline, deadline),
531
+ -- F7: only stamp completed_at on an actual transition into 'done'.
532
+ -- `status` here is the table's own pre-update column value (single-
533
+ -- table UPDATE SET-list expressions see the OLD row), so a done ->
534
+ -- done no-op re-save leaves the original completion time alone
535
+ -- instead of silently bumping it to now().
536
+ completed_at = CASE
537
+ WHEN p_status = 'done' AND status IS DISTINCT FROM 'done' THEN now()
538
+ WHEN p_status = 'done' THEN completed_at
539
+ WHEN p_status IS NOT NULL THEN NULL
540
+ ELSE completed_at
541
+ END,
542
+ metadata = CASE WHEN p_metadata IS NULL THEN metadata ELSE metadata || p_metadata END,
543
+ updated_at = now()
544
+ WHERE action_item_id = p_action_item_id;
545
+
546
+ -- The acting operator is still named in the event row even though there is
547
+ -- no ownership gate, so the audit trail keeps a person attached to the
548
+ -- change.
549
+ INSERT INTO semicolony.action_item_events (action_item_id, event_type, actor_kind,
550
+ actor_member_id, actor_agent_id, payload)
551
+ VALUES (p_action_item_id,
552
+ CASE WHEN p_status = 'done' THEN 'closed'
553
+ WHEN p_status IS NOT NULL THEN 'status_changed'
554
+ WHEN p_deadline IS NOT NULL THEN 'deadline_changed'
555
+ ELSE 'noted' END,
556
+ -- F4: match create_action_item's actor_kind default (NULL ->
557
+ -- 'system'), same as assign_action_item above.
558
+ CASE WHEN p_actor_kind IS NULL THEN 'system' ELSE p_actor_kind END,
559
+ v_actor_member_id, p_actor_agent,
560
+ jsonb_build_object('status', p_status, 'deadline', p_deadline, 'note', p_note, 'metadata', p_metadata));
561
+
562
+ RETURN p_action_item_id;
563
+ END;
564
+ $$;
565
+
566
+ REVOKE ALL ON TABLE
567
+ semicolony.action_items,
568
+ semicolony.action_item_events,
569
+ semicolony.team_members,
570
+ semicolony.v_action_actors,
571
+ semicolony.action_items_archive_20260812
572
+ FROM PUBLIC, sc_app;
573
+
574
+ GRANT SELECT ON TABLE
575
+ semicolony.action_items,
576
+ semicolony.action_item_events,
577
+ semicolony.team_members,
578
+ semicolony.v_action_actors
579
+ TO sc_app;
580
+
581
+ -- F2: the table comment above calls the archive 'Read-only.', but ALTER
582
+ -- TABLE ... RENAME carries the *entire* ACL from the old live table onto it
583
+ -- -- including app's DELETE/TRUNCATE and semo_ops_bot's INSERT/UPDATE/DELETE,
584
+ -- exactly the write access "read-only" promises does not exist. Nothing in
585
+ -- this repository writes to the archive; the only reference anywhere is the
586
+ -- SELECT inside record_feedback below. Revoke every write verb and leave
587
+ -- SELECT for the roles that already had it, using the same REVOKE ALL /
588
+ -- GRANT SELECT shape as the sc_app block above. semo_ops_bot is guarded
589
+ -- inside the same pg_roles existence check its other grants below use (no
590
+ -- creating migration for that role in this repo, so a bare statement
591
+ -- referencing it would abort the whole migration where it was never
592
+ -- provisioned); app is revoked unguarded since it is always present, same
593
+ -- precedent as the GRANT ALL ... TO app below.
594
+ REVOKE ALL ON TABLE semicolony.action_items_archive_20260812 FROM app;
595
+ GRANT SELECT ON TABLE semicolony.action_items_archive_20260812 TO app;
596
+
597
+ DO $$
598
+ BEGIN
599
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'semo_ops_bot') THEN
600
+ EXECUTE 'REVOKE ALL ON TABLE semicolony.action_items_archive_20260812 FROM semo_ops_bot';
601
+ EXECUTE 'GRANT SELECT ON TABLE semicolony.action_items_archive_20260812 TO semo_ops_bot';
602
+ END IF;
603
+ END
604
+ $$;
605
+
606
+ -- The archive also still carries the BEFORE UPDATE trigger inherited from
607
+ -- 069_action_items_sot.sql (trg_action_items_updated). A table the comment
608
+ -- calls read-only has no business running an updated_at-maintaining trigger.
609
+ DROP TRIGGER IF EXISTS trg_action_items_updated ON semicolony.action_items_archive_20260812;
610
+
611
+ -- Reproduce the grant surface the live production action_items table carried
612
+ -- before the rename (measured 2026-08-12): app full DML (table owner), sc_app
613
+ -- SELECT-only, sc_fleet_board SELECT for the /ops control room board
614
+ -- (migration 196 — that board once broke with "permission denied for table
615
+ -- action_items" when this grant was missing), and semo_ops_bot's four DML
616
+ -- verbs. RENAME carries this matrix onto the archive automatically, but the
617
+ -- fresh CREATE TABLE above starts with none of it, so every grantee besides
618
+ -- sc_app must be restated here. sc_app staying SELECT/EXECUTE-only is the one
619
+ -- invariant this migration tightens; the other three roles are unaffected.
620
+ -- app is granted unguarded, matching precedent
621
+ -- (186_worker_agent_decision_capture.sql:308,313 also grants to app unguarded)
622
+ -- since app is the migration-running/owning role and is always present.
623
+ GRANT ALL ON TABLE semicolony.action_items TO app;
624
+
625
+ -- sc_fleet_board and semo_ops_bot are NOT created by this migration (or by
626
+ -- any migration in this repo, in semo_ops_bot's case), so a bare GRANT to
627
+ -- either fails with "role ... does not exist" on any database where that
628
+ -- role was never provisioned — e.g. the throwaway Docker instance a later
629
+ -- task applies this file to. Because the runner wraps the whole migration in
630
+ -- one transaction, that failure would roll back the entire cutover. Guarded
631
+ -- the same way 174/176/135 guard role creation, but skipping the grant
632
+ -- instead of creating the role: this migration doesn't own either role's
633
+ -- lifecycle, so it shouldn't fabricate one.
634
+ --
635
+ -- team_members and v_action_actors SELECT added here for the same two roles
636
+ -- (the same class of gap migration 196 exists to repair): the dashboard port
637
+ -- added `LEFT JOIN semicolony.team_members` on the pool that connects as
638
+ -- sc_fleet_board, and that role's twelve-table grant list measured live
639
+ -- doesn't include team_members — every /ops action-board request would fail
640
+ -- 42501 permission denied. Other dashboard reads go through either app or
641
+ -- semo_ops_bot and which one couldn't be determined from here, so both are
642
+ -- covered. v_action_actors is granted alongside it for a later plan's
643
+ -- assignee-picker work, since a second grant-gap round costs a full cycle
644
+ -- and adding it now costs nothing. action_item_events is deliberately not
645
+ -- granted here — no current reader needs it.
646
+ DO $$
647
+ BEGIN
648
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'sc_fleet_board') THEN
649
+ RAISE NOTICE 'sc_fleet_board role not found; skipping action_items/team_members/v_action_actors grants';
650
+ ELSE
651
+ EXECUTE 'GRANT SELECT ON TABLE semicolony.action_items, semicolony.team_members, semicolony.v_action_actors TO sc_fleet_board';
652
+ END IF;
653
+
654
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'semo_ops_bot') THEN
655
+ RAISE NOTICE 'semo_ops_bot role not found; skipping action_items/team_members/v_action_actors grants';
656
+ ELSE
657
+ EXECUTE 'GRANT INSERT, SELECT, UPDATE, DELETE ON TABLE semicolony.action_items TO semo_ops_bot';
658
+ EXECUTE 'GRANT SELECT ON TABLE semicolony.team_members, semicolony.v_action_actors TO semo_ops_bot';
659
+ END IF;
660
+ END
661
+ $$;
662
+
663
+ REVOKE ALL ON FUNCTION
664
+ semicolony.create_action_item(text, text, date, text, text, text, text, text, text,
665
+ text, text, text, jsonb, text, text, text, text, text, text, text),
666
+ semicolony.update_action_item(uuid, text, text, text, text, date, text, jsonb)
667
+ FROM PUBLIC;
668
+
669
+ GRANT EXECUTE ON FUNCTION
670
+ semicolony.create_action_item(text, text, date, text, text, text, text, text, text,
671
+ text, text, text, jsonb, text, text, text, text, text, text, text),
672
+ semicolony.update_action_item(uuid, text, text, text, text, date, text, jsonb)
673
+ TO sc_app;
674
+
675
+ -- assign_action_item and operator_update_action_item carry no ownership
676
+ -- check (see the comment above assign_action_item), so sc_app — the worker
677
+ -- Gateway role — must appear in no grant for either. Granted to app
678
+ -- unguarded, matching the table grant precedent above and
679
+ -- 186_worker_agent_decision_capture.sql:308,313, and to semo_ops_bot inside
680
+ -- the same pg_roles existence guard already used for the table grants (that
681
+ -- role has no creating migration in this repo, so a bare grant would abort
682
+ -- the whole migration on a database where it was never provisioned).
683
+ REVOKE ALL ON FUNCTION
684
+ semicolony.assign_action_item(uuid, text, text, text, text, text, text, text),
685
+ semicolony.operator_update_action_item(uuid, text, date, text, jsonb, text, text, text)
686
+ FROM PUBLIC;
687
+
688
+ GRANT EXECUTE ON FUNCTION
689
+ semicolony.assign_action_item(uuid, text, text, text, text, text, text, text),
690
+ semicolony.operator_update_action_item(uuid, text, date, text, jsonb, text, text, text)
691
+ TO app;
692
+
693
+ -- create_action_item is also granted to semo_ops_bot here — same class of gap
694
+ -- as L1 (team_members/v_action_actors), this time on a function grant instead
695
+ -- of a table grant. Both the dashboard (lib/core/action-items.ts) and the
696
+ -- Slack router (packages/slack-router/src/action-items.ts) call
697
+ -- create_action_item, and neither connects as the worker Gateway role above;
698
+ -- which of app/semo_ops_bot either deployment's DATABASE_URL actually
699
+ -- resolves to could not be measured, so both are covered rather than guessed
700
+ -- at. This confers nothing new in substance: semo_ops_bot already holds
701
+ -- INSERT/SELECT/UPDATE/DELETE directly on action_items (see the table grant
702
+ -- above), so it could already write the rows — create_action_item is the
703
+ -- constrained, provenance-stamping path we would rather it use instead of
704
+ -- raw DML. update_action_item is deliberately NOT granted here: it is the
705
+ -- ownership-gated worker function, and operator surfaces have
706
+ -- operator_update_action_item (already granted above) for the same job.
707
+ DO $$
708
+ BEGIN
709
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'semo_ops_bot') THEN
710
+ RAISE NOTICE 'semo_ops_bot role not found; skipping assign_action_item/operator_update_action_item/create_action_item grants';
711
+ ELSE
712
+ EXECUTE 'GRANT EXECUTE ON FUNCTION semicolony.assign_action_item(uuid, text, text, text, text, text, text, text) TO semo_ops_bot';
713
+ EXECUTE 'GRANT EXECUTE ON FUNCTION semicolony.operator_update_action_item(uuid, text, date, text, jsonb, text, text, text) TO semo_ops_bot';
714
+ EXECUTE 'GRANT EXECUTE ON FUNCTION semicolony.create_action_item(text, text, date, text, text, text, text, text, text, text, text, text, jsonb, text, text, text, text, text, text, text) TO semo_ops_bot';
715
+ END IF;
716
+ END
717
+ $$;
718
+
719
+ -- record_feedback rewritten onto the redesigned model. Signature reproduced
720
+ -- exactly (p_embedding before p_worker_id, same RETURNS TABLE) so this
721
+ -- CREATE OR REPLACE replaces the existing function instead of adding a second
722
+ -- overload the Gateway would never call. search_path stays pg_catalog only,
723
+ -- matching the function as it runs in production today: retargetSchemaSql's
724
+ -- qualifier rewrite requires a trailing dot ("semicolony." not bare
725
+ -- "semicolony"), so a bare schema name placed here would NOT be retargeted to
726
+ -- semo_ops and would point at a schema that doesn't exist there. The inner
727
+ -- semicolony.create_action_item(...) call below resolves correctly regardless
728
+ -- of search_path only because it is itself schema-qualified, not because of
729
+ -- anything set here.
730
+ CREATE OR REPLACE FUNCTION semicolony.record_feedback(
731
+ p_slug text,
732
+ p_summary text,
733
+ p_content text,
734
+ p_metadata jsonb,
735
+ p_embedding text,
736
+ p_worker_id text,
737
+ p_device_id text
738
+ )
739
+ RETURNS TABLE(kb_id bigint, action_item_id uuid, duplicate boolean)
740
+ LANGUAGE plpgsql
741
+ SECURITY DEFINER
742
+ SET search_path = pg_catalog
743
+ AS $function$
744
+ DECLARE
745
+ v_kb_id bigint;
746
+ v_action_item_id uuid;
747
+ v_duplicate boolean := false;
748
+ v_metadata jsonb;
749
+ v_priority text;
750
+ v_source text;
751
+ BEGIN
752
+ IF p_slug IS NULL
753
+ OR char_length(p_slug) < 3
754
+ OR char_length(p_slug) > 60
755
+ OR p_slug !~ '^[[:alnum:]가-힣][[:alnum:]가-힣-]*$' THEN
756
+ RAISE EXCEPTION 'invalid feedback slug' USING ERRCODE = '22023';
757
+ END IF;
758
+ IF p_summary IS NULL OR char_length(btrim(p_summary)) < 8 OR char_length(p_summary) > 500 THEN
759
+ RAISE EXCEPTION 'invalid feedback summary' USING ERRCODE = '22023';
760
+ END IF;
761
+ IF p_content IS NULL OR char_length(p_content) < 30 OR char_length(p_content) > 30000 THEN
762
+ RAISE EXCEPTION 'invalid feedback content' USING ERRCODE = '22023';
763
+ END IF;
764
+ IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
765
+ RAISE EXCEPTION 'invalid feedback metadata' USING ERRCODE = '22023';
766
+ END IF;
767
+ IF p_metadata ->> 'category' NOT IN ('context-failure', 'env-drift', 'policy-gap', 'tooling') THEN
768
+ RAISE EXCEPTION 'invalid feedback category' USING ERRCODE = '22023';
769
+ END IF;
770
+ v_priority := coalesce(p_metadata ->> 'severity', 'normal');
771
+ IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
772
+ RAISE EXCEPTION 'invalid feedback severity' USING ERRCODE = '22023';
773
+ END IF;
774
+ IF p_worker_id IS NULL OR p_worker_id !~ '^[a-z][a-z0-9-]{0,63}$'
775
+ OR p_device_id IS NULL
776
+ OR (p_device_id !~ '^[a-z][a-z0-9-]{0,99}$'
777
+ AND p_device_id <> 'hermes-principals@hermes') THEN
778
+ RAISE EXCEPTION 'invalid worker/device binding' USING ERRCODE = '22023';
779
+ END IF;
780
+
781
+ v_source := coalesce(p_metadata ->> 'source', 'session-hook');
782
+ IF v_source NOT IN ('session-hook', 'runtime-native-tool') THEN
783
+ RAISE EXCEPTION 'invalid feedback source' USING ERRCODE = '22023';
784
+ END IF;
785
+ IF v_source = 'runtime-native-tool' AND NOT (
786
+ p_worker_id IN ('semi', 'colony')
787
+ AND p_device_id = 'hermes-principals@hermes'
788
+ AND p_metadata ->> 'reported_by' = p_worker_id
789
+ AND p_metadata ->> 'installation_id' = 'hermes-principals@hermes'
790
+ AND p_metadata ->> 'runtime_kind' = 'hermes-cli'
791
+ AND p_metadata ->> 'profile_id' = 'semo-ops-worker-agent'
792
+ AND (
793
+ (p_worker_id = 'semi' AND p_metadata ->> 'runtime_profile' = 'semo-semi')
794
+ OR (p_worker_id = 'colony' AND p_metadata ->> 'runtime_profile' = 'semo-colony')
795
+ )
796
+ ) THEN
797
+ RAISE EXCEPTION 'invalid runtime-native feedback attribution' USING ERRCODE = '22023';
798
+ END IF;
799
+
800
+ PERFORM p_embedding::public.vector;
801
+ PERFORM pg_advisory_xact_lock(hashtextextended('feedback:' || p_slug, 0));
802
+
803
+ v_metadata := p_metadata || jsonb_build_object(
804
+ 'status', 'open',
805
+ 'source', v_source,
806
+ 'worker_id', p_worker_id,
807
+ 'device_id', p_device_id
808
+ );
809
+
810
+ INSERT INTO semicolony.knowledge_base AS kb (
811
+ domain, key, sub_key, content, created_by, embedding, metadata
812
+ ) VALUES (
813
+ 'semicolon',
814
+ 'feedback',
815
+ p_slug,
816
+ p_content,
817
+ 'feedback-pipeline:' || p_worker_id,
818
+ p_embedding::public.vector,
819
+ v_metadata
820
+ )
821
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
822
+ content = EXCLUDED.content,
823
+ embedding = EXCLUDED.embedding,
824
+ metadata = coalesce(kb.metadata, '{}'::jsonb) || EXCLUDED.metadata,
825
+ archived = false,
826
+ updated_at = clock_timestamp()
827
+ RETURNING kb.kb_id INTO v_kb_id;
828
+
829
+ -- Duplicate check now spans the archive: 637 feedback items were resolved
830
+ -- before the redesign, and ignoring them would recreate every one of them on
831
+ -- the next capture.
832
+ SELECT ai.action_item_id INTO v_action_item_id
833
+ FROM semicolony.action_items AS ai
834
+ WHERE ai.source_kind = 'feedback'
835
+ AND ai.status IN ('open', 'in_progress', 'blocked')
836
+ AND ai.source_ref = p_slug
837
+ ORDER BY ai.created_at DESC
838
+ LIMIT 1
839
+ FOR UPDATE;
840
+
841
+ IF v_action_item_id IS NULL THEN
842
+ -- Only a *resolved* archived item suppresses a new capture. An archived
843
+ -- item still open at cutover time is unfinished work, not a duplicate —
844
+ -- filtering only on slug (with no status check) would freeze it forever
845
+ -- and silently drop every future re-report of that same failure.
846
+ --
847
+ -- F6: deliberately NOT selecting old.action_item_id into v_action_item_id
848
+ -- here. That id lives in action_items_archive_20260812, not
849
+ -- action_items -- handing it back as action_item_id would give the
850
+ -- caller a UUID that 404s on every later lookup against the live table.
851
+ -- v_action_item_id stays NULL (its DECLARE default); duplicate=true with
852
+ -- a NULL id is the unambiguous signal instead: "already handled, no live
853
+ -- row to point you at."
854
+ SELECT true INTO v_duplicate
855
+ FROM semicolony.action_items_archive_20260812 AS old
856
+ WHERE old.source = 'feedback'
857
+ AND old.status IN ('completed', 'cancelled')
858
+ AND old.metadata ->> 'feedback_slug' = p_slug
859
+ LIMIT 1;
860
+ v_duplicate := coalesce(v_duplicate, false);
861
+ ELSE
862
+ v_duplicate := true;
863
+ END IF;
864
+
865
+ IF NOT v_duplicate THEN
866
+ v_action_item_id := semicolony.create_action_item(
867
+ p_description => '[SEMO Ops feedback] ' || btrim(p_summary),
868
+ -- 'semicolon' is entity_type='organization' and is not a valid project.
869
+ p_project_domain => 'semo-ops',
870
+ p_deadline => semicolony.action_item_default_deadline(v_priority),
871
+ -- Feedback is unassigned until triaged; 'unassigned' is explicit, not NULL.
872
+ p_assignee_kind => 'unassigned',
873
+ p_assignee_member => NULL,
874
+ p_assignee_agent => NULL,
875
+ p_assignee_device => NULL,
876
+ p_priority => v_priority,
877
+ p_source_kind => 'feedback',
878
+ p_source_ref => p_slug,
879
+ -- Preserved from the live function: the KB backlink, not NULL.
880
+ p_related_url => 'semo kb get semicolon feedback ' || p_slug,
881
+ p_external_contact => NULL,
882
+ p_metadata => p_metadata,
883
+ p_origin_surface => 'worker',
884
+ p_origin_actor_kind => CASE WHEN v_source = 'runtime-native-tool' THEN 'agent' ELSE 'human' END,
885
+ p_origin_member => CASE WHEN v_source = 'runtime-native-tool' THEN NULL ELSE p_worker_id END,
886
+ p_origin_agent => CASE WHEN v_source = 'runtime-native-tool' THEN p_worker_id ELSE NULL END,
887
+ p_origin_device => p_device_id,
888
+ p_origin_harness => CASE WHEN v_source = 'runtime-native-tool' THEN NULL ELSE 'cli' END,
889
+ p_origin_session_key => p_metadata ->> 'session_key'
890
+ );
891
+ END IF;
892
+
893
+ RETURN QUERY SELECT v_kb_id, v_action_item_id, v_duplicate;
894
+ END;
895
+ $function$;
896
+
897
+ -- Y1: a place to record an action item that was never created.
898
+ --
899
+ -- Every other fallback record_meeting takes below lands on the action item it
900
+ -- produced, in that row's own metadata. A *skipped* item has no row, so the
901
+ -- only surface left that can carry the evidence is the meeting itself — and
902
+ -- 198_meeting_record_canonical.sql made the meetings table the canonical
903
+ -- meeting record, so an integrity fact about a meeting's follow-up list
904
+ -- belongs there rather than in the derived Knowledge Fabric projection.
905
+ -- The table had no metadata column; this adds one, additively and with a
906
+ -- non-NULL default, so the UPDATE inside record_meeting below never has to
907
+ -- reason about NULL and no existing row changes meaning.
908
+ --
909
+ -- Deliberately not reversed by packages/cli/scripts/rollback-action-item-
910
+ -- redesign.sql: that script's contract was verified as-is, and an unread
911
+ -- defaulted jsonb column left behind by a rollback is inert. Recorded here
912
+ -- rather than fixed silently.
913
+ ALTER TABLE semicolony.meetings
914
+ ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
915
+
916
+ -- record_meeting rewritten onto the redesigned model.
917
+ --
918
+ -- 199_meeting_record_capability.sql created this function on dev while this
919
+ -- branch was in review, and it is live: packages/cli/src/commands/meetings.ts
920
+ -- and packages/kb-gateway/src/lib/meeting-service.ts both call it positionally
921
+ -- on every meeting-record write. Its action-item INSERT named
922
+ -- owner_domain/target_domain/assignee/source/status — five columns this file
923
+ -- removes — so leaving it untouched would break meeting recording through the
924
+ -- running kb-gateway at first use, not at migration time (a PL/pgSQL body's
925
+ -- column references are resolved at first execution, not at CREATE).
926
+ --
927
+ -- The 13-argument signature and the RETURNS TABLE shape are reproduced
928
+ -- byte-identically from 199 on purpose: a different argument list would create
929
+ -- a second overload instead of replacing the function, and both callers would
930
+ -- keep resolving to the old one. Every validation, the meetings upsert, the
931
+ -- Knowledge Fabric projection with its restricted-classification metadata, the
932
+ -- delete-and-recreate idempotency, and the receipt are ported unchanged from
933
+ -- 199. Only the action-item creation changed, and only because it had to.
934
+ --
935
+ -- Mapping from 199's INSERT onto the new contract (each item of
936
+ -- p_action_items):
937
+ --
938
+ -- * description — unchanged, except that the new table requires >= 5
939
+ -- characters where 199 required only non-empty. A shorter description is
940
+ -- skipped with a WARNING rather than raised: aborting the transaction
941
+ -- would discard the whole meeting record, which is the exact failure
942
+ -- 198-201 exist to prevent, and record_meeting is the only place the
943
+ -- minutes still exist at that point.
944
+ -- * item->>'target', then item->>'owner', then p_target_domain — first one
945
+ -- that is an entity_type='service' ontology domain becomes project_domain.
946
+ -- 'owner' is in that list because the pre-redesign owner_domain conflated
947
+ -- person and project domains under one ontology FK; when it holds a
948
+ -- project it is the right project. Falls back to 'semo-ops' (the same
949
+ -- service domain record_feedback uses) rather than raising, because
950
+ -- project_domain is NOT NULL here while 199 allowed NULL, and a meeting
951
+ -- that names no project must still record its follow-ups.
952
+ -- * item->>'assignee', then item->>'owner' — first one matching
953
+ -- team_members.domain becomes assignee_kind='team-member', else first one
954
+ -- matching bot_status.bot_id becomes assignee_kind='agent'. Neither
955
+ -- matching leaves assignee_kind='unassigned' with the raw
956
+ -- item->>'assignee' text in external_contact, which is what that column is
957
+ -- for (spec §7: non-team people such as '기백' or 'Space CL (박희원)' are
958
+ -- external_contact text, not assignees). item->>'owner' is never written
959
+ -- to external_contact — it is a domain field, not a person's name.
960
+ -- * item->>'deadline' — cast exactly as 199 cast it, then defaulted through
961
+ -- action_item_default_deadline(priority) when absent, because the new
962
+ -- deadline column is NOT NULL where 199 allowed NULL.
963
+ -- * item->>'priority' — 199's coalesce(..., 'normal') is preserved, then
964
+ -- clamped to the new CHECK's vocabulary. The pre-redesign column was
965
+ -- unconstrained varchar(10), so a meeting that said 'medium' used to
966
+ -- insert cleanly and would now raise 23514 and lose the record; it is
967
+ -- recorded as 'normal' with a WARNING instead.
968
+ -- * status/source — 199 wrote 'open'/'meeting' literally. status='open' is
969
+ -- now the column default, and source becomes source_kind='meeting' with
970
+ -- source_ref carrying the meeting id, alongside the typed meeting_id.
971
+ -- * provenance — origin_surface='system' with a NULL origin_actor_kind. The
972
+ -- meeting recorder is not a person and not a registered bot, and
973
+ -- origin_device_id is an FK to worker_devices: stamping p_device_id there
974
+ -- would raise 23503 on exactly the headless host 199 was written for.
975
+ -- p_worker_id/p_device_id/p_record_source are kept in the item's metadata
976
+ -- instead, together with the raw owner/target/assignee strings, so nothing
977
+ -- the caller sent is discarded by the mapping.
978
+ --
979
+ -- Y1 — every one of those fallbacks is recorded as queryable data, not only as
980
+ -- a log line. None of them raises, for the reason above: aborting would discard
981
+ -- the meeting record. But a silent substitution is the same failure this whole
982
+ -- redesign exists to end — an item filed under the wrong project, or one that
983
+ -- vanishes entirely, with nothing in the database saying so. A RAISE WARNING is
984
+ -- not a substitute: neither packages/kb-gateway/src/lib/meeting-service.ts nor
985
+ -- packages/cli/src/commands/meetings.ts subscribes to pg's `notice` event, so a
986
+ -- warning reaches nobody who is not attached with psql by hand. The warnings
987
+ -- are kept, and the evidence is written twice over:
988
+ --
989
+ -- * an item that took a fallback carries it in its own row, at
990
+ -- metadata -> 'meeting_import' -> 'fallbacks' — an array naming the field,
991
+ -- the reason, what was substituted, and the original value. The key is
992
+ -- always present (empty array when the item mapped cleanly) so a consumer
993
+ -- can filter on jsonb_array_length without distinguishing absent from
994
+ -- empty.
995
+ -- * an item that was skipped has no row to carry anything, so the whole
996
+ -- submitted item lands on the meeting, at
997
+ -- meetings.metadata -> 'meeting_import' -> 'skipped_action_items'. The
998
+ -- meeting then carries the evidence that its own follow-up list is
999
+ -- incomplete, which is the only place an operator could still find it.
1000
+ --
1001
+ -- The return shape is deliberately NOT extended: both callers depend on the
1002
+ -- 13-argument signature and the four-column receipt, and neither may change.
1003
+ -- packages/cli/scripts/verify-action-item-redesign.sql asserts the recorded
1004
+ -- evidence, not merely the behaviour, and carries the two queries an operator
1005
+ -- actually runs: every item filed by fallback, and every meeting with skips.
1006
+ CREATE OR REPLACE FUNCTION semicolony.record_meeting(
1007
+ p_title text,
1008
+ p_meeting_date date,
1009
+ p_meeting_type text,
1010
+ p_adhoc_subtype text,
1011
+ p_visibility text,
1012
+ p_target_domain text,
1013
+ p_attendees jsonb,
1014
+ p_record_body text,
1015
+ p_transcript text,
1016
+ p_action_items jsonb,
1017
+ p_record_source text,
1018
+ p_worker_id text,
1019
+ p_device_id text
1020
+ )
1021
+ RETURNS TABLE(meeting_id uuid, kb_id bigint, action_item_ids uuid[], duplicate boolean)
1022
+ LANGUAGE plpgsql
1023
+ SECURITY DEFINER
1024
+ SET search_path = pg_catalog
1025
+ AS $function$
1026
+ DECLARE
1027
+ v_meeting_id uuid;
1028
+ v_kb_id bigint;
1029
+ v_existing uuid;
1030
+ v_norm text;
1031
+ v_sensitivity text;
1032
+ v_sub_key text;
1033
+ v_item jsonb;
1034
+ v_item_id uuid;
1035
+ v_description text;
1036
+ v_priority text;
1037
+ v_project text;
1038
+ v_candidate text;
1039
+ v_assignee_kind text;
1040
+ v_assignee_member text;
1041
+ v_assignee_agent text;
1042
+ v_external_contact text;
1043
+ v_deadline date;
1044
+ -- Y1: fallbacks are recorded as data, not only as a log line. v_fallbacks
1045
+ -- accumulates per item and lands in that item's own metadata; v_skipped
1046
+ -- accumulates items that produced no row at all and lands on the meeting.
1047
+ v_fallbacks jsonb;
1048
+ v_skipped jsonb;
1049
+ BEGIN
1050
+ IF p_title IS NULL OR length(btrim(p_title)) < 3 THEN
1051
+ RAISE EXCEPTION 'title must be at least 3 characters';
1052
+ END IF;
1053
+ IF p_meeting_date IS NULL THEN
1054
+ RAISE EXCEPTION 'meeting_date is required';
1055
+ END IF;
1056
+ IF p_visibility IS NULL OR p_visibility NOT IN ('team', 'restricted') THEN
1057
+ RAISE EXCEPTION 'visibility must be team or restricted, got %', p_visibility;
1058
+ END IF;
1059
+ IF p_record_source IS NULL
1060
+ OR p_record_source NOT IN ('slack-worker', 'operator', 'backfill-discussion') THEN
1061
+ RAISE EXCEPTION 'invalid record_source %', p_record_source;
1062
+ END IF;
1063
+ IF p_record_body IS NULL OR length(btrim(p_record_body)) = 0 THEN
1064
+ RAISE EXCEPTION 'record_body is required';
1065
+ END IF;
1066
+
1067
+ v_norm := lower(regexp_replace(btrim(p_title), '\s+', ' ', 'g'));
1068
+ v_sensitivity := CASE WHEN p_visibility = 'restricted' THEN 'restricted' ELSE 'internal' END;
1069
+
1070
+ SELECT m.meeting_id INTO v_existing
1071
+ FROM semicolony.meetings m
1072
+ WHERE m.meeting_date = p_meeting_date
1073
+ AND lower(regexp_replace(m.title, '\s+', ' ', 'g')) = v_norm;
1074
+
1075
+ IF v_existing IS NOT NULL THEN
1076
+ UPDATE semicolony.meetings m
1077
+ SET record_body = COALESCE(p_record_body, m.record_body),
1078
+ mapped_transcript = COALESCE(p_transcript, m.mapped_transcript),
1079
+ visibility = p_visibility,
1080
+ record_source = p_record_source,
1081
+ target_domain = COALESCE(p_target_domain, m.target_domain),
1082
+ attendees = COALESCE(p_attendees, m.attendees),
1083
+ updated_at = now()
1084
+ WHERE m.meeting_id = v_existing;
1085
+ v_meeting_id := v_existing;
1086
+ ELSE
1087
+ INSERT INTO semicolony.meetings (
1088
+ meeting_id, title, meeting_type, adhoc_subtype, meeting_date, attendees,
1089
+ visibility, record_body, mapped_transcript, record_source, target_domain,
1090
+ transcription_status, speaker_map, created_at, updated_at
1091
+ ) VALUES (
1092
+ gen_random_uuid(), btrim(p_title), p_meeting_type, p_adhoc_subtype, p_meeting_date,
1093
+ COALESCE(p_attendees, '[]'::jsonb), p_visibility, p_record_body,
1094
+ p_transcript, p_record_source, p_target_domain,
1095
+ 'completed', '{}'::jsonb, now(), now()
1096
+ ) RETURNING meetings.meeting_id INTO v_meeting_id;
1097
+ END IF;
1098
+
1099
+ v_sub_key := to_char(p_meeting_date, 'YYYY-MM-DD') || '-' || left(v_norm, 60);
1100
+
1101
+ INSERT INTO semicolony.knowledge_base (
1102
+ domain, key, sub_key, content, metadata, created_by, created_at, updated_at
1103
+ ) VALUES (
1104
+ COALESCE(p_target_domain, 'semicolon'),
1105
+ 'meeting',
1106
+ v_sub_key,
1107
+ p_record_body,
1108
+ jsonb_build_object(
1109
+ 'classification', jsonb_build_object('sensitivity', v_sensitivity),
1110
+ 'meeting_id', v_meeting_id,
1111
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
1112
+ 'record_source', p_record_source
1113
+ ),
1114
+ COALESCE(p_worker_id, 'meeting-worker'),
1115
+ now(), now()
1116
+ )
1117
+ ON CONFLICT (domain, key, sub_key) DO UPDATE
1118
+ SET content = EXCLUDED.content,
1119
+ metadata = semicolony.knowledge_base.metadata || EXCLUDED.metadata,
1120
+ updated_at = now()
1121
+ RETURNING semicolony.knowledge_base.kb_id INTO v_kb_id;
1122
+
1123
+ -- Re-recording the same meeting must not duplicate its follow-ups. Existing
1124
+ -- machine-created items for this meeting are replaced; anything a human filed
1125
+ -- separately carries no meeting_id and is untouched. Unchanged from 199,
1126
+ -- including the fact that the DELETE only runs when the caller actually sent
1127
+ -- items -- a record submitted with an empty action-item list must not silently
1128
+ -- erase the follow-ups an earlier submission of the same meeting created.
1129
+ -- action_item_events rows disappear with their item through the events
1130
+ -- table's ON DELETE CASCADE, so no orphan history is left behind.
1131
+ IF p_action_items IS NOT NULL AND jsonb_typeof(p_action_items) = 'array'
1132
+ AND jsonb_array_length(p_action_items) > 0 THEN
1133
+ DELETE FROM semicolony.action_items a WHERE a.meeting_id = v_meeting_id;
1134
+
1135
+ v_skipped := '[]'::jsonb;
1136
+
1137
+ FOR v_item IN
1138
+ SELECT item FROM jsonb_array_elements(p_action_items) AS item
1139
+ LOOP
1140
+ v_description := btrim(COALESCE(v_item ->> 'description', ''));
1141
+ IF char_length(v_description) < 5 THEN
1142
+ RAISE WARNING 'record_meeting: skipping meeting % action item, description shorter than 5 characters (%)',
1143
+ v_meeting_id, v_description;
1144
+ -- Y1: the RAISE alone is not evidence. Neither meeting-service.ts nor
1145
+ -- meetings.ts subscribes to pg's `notice` event, so a WARNING is
1146
+ -- visible only to someone attached with psql by hand. This item
1147
+ -- produces no row to carry its own record, so the whole submitted
1148
+ -- item is preserved on the meeting instead — an operator can then see
1149
+ -- that the meeting's follow-up list is incomplete, and what was lost.
1150
+ v_skipped := v_skipped || jsonb_build_array(jsonb_build_object(
1151
+ 'reason', 'description_shorter_than_5_characters',
1152
+ 'description', v_item ->> 'description',
1153
+ 'owner', v_item ->> 'owner',
1154
+ 'target', v_item ->> 'target',
1155
+ 'assignee', v_item ->> 'assignee',
1156
+ 'deadline', v_item ->> 'deadline',
1157
+ 'priority', v_item ->> 'priority'
1158
+ ));
1159
+ CONTINUE;
1160
+ END IF;
1161
+
1162
+ -- Y1: reset per item. Anything appended here lands in the created row's
1163
+ -- metadata under meeting_import.fallbacks, so the row itself answers
1164
+ -- "why is this filed under semo-ops / due then / unassigned?".
1165
+ v_fallbacks := '[]'::jsonb;
1166
+
1167
+ v_priority := COALESCE(NULLIF(btrim(v_item ->> 'priority'), ''), 'normal');
1168
+ IF v_priority NOT IN ('low', 'normal', 'high', 'urgent') THEN
1169
+ RAISE WARNING 'record_meeting: meeting % action item priority % is not one of low/normal/high/urgent, recording as normal',
1170
+ v_meeting_id, v_priority;
1171
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
1172
+ 'field', 'priority',
1173
+ 'reason', 'not_in_low_normal_high_urgent',
1174
+ 'substituted', 'normal',
1175
+ 'original', v_priority
1176
+ ));
1177
+ v_priority := 'normal';
1178
+ END IF;
1179
+
1180
+ v_project := NULL;
1181
+ FOREACH v_candidate IN ARRAY ARRAY[
1182
+ NULLIF(btrim(v_item ->> 'target'), ''),
1183
+ NULLIF(btrim(v_item ->> 'owner'), ''),
1184
+ NULLIF(btrim(p_target_domain), '')
1185
+ ]
1186
+ LOOP
1187
+ CONTINUE WHEN v_candidate IS NULL;
1188
+ IF EXISTS (SELECT 1 FROM semicolony.ontology o
1189
+ WHERE o.domain = v_candidate AND o.entity_type = 'service') THEN
1190
+ v_project := v_candidate;
1191
+ EXIT;
1192
+ END IF;
1193
+ END LOOP;
1194
+ -- Y1: record the substitution before it is applied, while the rejected
1195
+ -- candidates are still distinguishable from a deliberate 'semo-ops'.
1196
+ IF v_project IS NULL THEN
1197
+ RAISE WARNING 'record_meeting: meeting % action item names no entity_type=service domain (target %, owner %, meeting target %), filing under semo-ops',
1198
+ v_meeting_id, v_item ->> 'target', v_item ->> 'owner', p_target_domain;
1199
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
1200
+ 'field', 'project_domain',
1201
+ 'reason', 'no_service_domain_among_candidates',
1202
+ 'substituted', 'semo-ops',
1203
+ 'original', jsonb_build_object(
1204
+ 'target', v_item ->> 'target',
1205
+ 'owner', v_item ->> 'owner',
1206
+ 'meeting_target_domain', p_target_domain
1207
+ )
1208
+ ));
1209
+ END IF;
1210
+ v_project := COALESCE(v_project, 'semo-ops');
1211
+
1212
+ v_assignee_kind := 'unassigned';
1213
+ v_assignee_member := NULL;
1214
+ v_assignee_agent := NULL;
1215
+ FOREACH v_candidate IN ARRAY ARRAY[
1216
+ NULLIF(btrim(v_item ->> 'assignee'), ''),
1217
+ NULLIF(btrim(v_item ->> 'owner'), '')
1218
+ ]
1219
+ LOOP
1220
+ CONTINUE WHEN v_candidate IS NULL;
1221
+ IF EXISTS (SELECT 1 FROM semicolony.team_members tm WHERE tm.domain = v_candidate) THEN
1222
+ v_assignee_kind := 'team-member';
1223
+ v_assignee_member := v_candidate;
1224
+ EXIT;
1225
+ ELSIF EXISTS (SELECT 1 FROM semicolony.bot_status bs WHERE bs.bot_id = v_candidate) THEN
1226
+ v_assignee_kind := 'agent';
1227
+ v_assignee_agent := v_candidate;
1228
+ EXIT;
1229
+ END IF;
1230
+ END LOOP;
1231
+
1232
+ v_external_contact := CASE WHEN v_assignee_kind = 'unassigned'
1233
+ THEN NULLIF(btrim(v_item ->> 'assignee'), '')
1234
+ ELSE NULL END;
1235
+
1236
+ -- Y1: only a *supplied* name that failed to resolve is a fallback. An
1237
+ -- item that named nobody is simply unassigned, not a substitution, and
1238
+ -- recording it as one would bury the real cases in noise.
1239
+ IF v_assignee_kind = 'unassigned'
1240
+ AND (NULLIF(btrim(v_item ->> 'assignee'), '') IS NOT NULL
1241
+ OR NULLIF(btrim(v_item ->> 'owner'), '') IS NOT NULL) THEN
1242
+ RAISE WARNING 'record_meeting: meeting % action item assignee (assignee %, owner %) is neither a team member nor an agent, leaving it unassigned',
1243
+ v_meeting_id, v_item ->> 'assignee', v_item ->> 'owner';
1244
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
1245
+ 'field', 'assignee',
1246
+ 'reason', 'not_a_team_member_or_agent',
1247
+ 'substituted', 'unassigned',
1248
+ 'original', jsonb_build_object(
1249
+ 'assignee', v_item ->> 'assignee',
1250
+ 'owner', v_item ->> 'owner'
1251
+ ),
1252
+ 'external_contact', v_external_contact
1253
+ ));
1254
+ END IF;
1255
+
1256
+ v_deadline := COALESCE(
1257
+ NULLIF(btrim(v_item ->> 'deadline'), '')::date,
1258
+ semicolony.action_item_default_deadline(v_priority)
1259
+ );
1260
+
1261
+ -- Y1: the COALESCE above is deliberately left byte-identical (its shape
1262
+ -- is pinned by the migration test); the fallback is detected separately
1263
+ -- on the same condition rather than by restructuring it.
1264
+ IF NULLIF(btrim(v_item ->> 'deadline'), '') IS NULL THEN
1265
+ RAISE WARNING 'record_meeting: meeting % action item has no deadline, defaulting to % for priority %',
1266
+ v_meeting_id, v_deadline, v_priority;
1267
+ v_fallbacks := v_fallbacks || jsonb_build_array(jsonb_build_object(
1268
+ 'field', 'deadline',
1269
+ 'reason', 'absent',
1270
+ 'substituted', to_char(v_deadline, 'YYYY-MM-DD'),
1271
+ 'original', v_item ->> 'deadline',
1272
+ 'derived_from_priority', v_priority
1273
+ ));
1274
+ END IF;
1275
+
1276
+ v_item_id := semicolony.create_action_item(
1277
+ p_description => v_description,
1278
+ p_project_domain => v_project,
1279
+ p_deadline => v_deadline,
1280
+ p_assignee_kind => v_assignee_kind,
1281
+ p_assignee_member => v_assignee_member,
1282
+ p_assignee_agent => v_assignee_agent,
1283
+ p_assignee_device => NULL,
1284
+ p_priority => v_priority,
1285
+ p_source_kind => 'meeting',
1286
+ p_source_ref => v_meeting_id::text,
1287
+ p_related_url => NULL,
1288
+ p_external_contact => v_external_contact,
1289
+ p_metadata => jsonb_build_object(
1290
+ 'meeting_id', v_meeting_id,
1291
+ 'meeting_date', to_char(p_meeting_date, 'YYYY-MM-DD'),
1292
+ 'record_source', p_record_source,
1293
+ 'worker_id', p_worker_id,
1294
+ 'device_id', p_device_id,
1295
+ 'raw_owner', v_item ->> 'owner',
1296
+ 'raw_target', v_item ->> 'target',
1297
+ 'raw_assignee', v_item ->> 'assignee',
1298
+ -- Y1: always present, empty array when the
1299
+ -- item mapped cleanly, so a consumer can
1300
+ -- filter on jsonb_array_length() without
1301
+ -- having to distinguish absent from empty.
1302
+ 'meeting_import', jsonb_build_object(
1303
+ 'fallbacks', v_fallbacks
1304
+ )
1305
+ ),
1306
+ p_origin_surface => 'system',
1307
+ p_origin_actor_kind => NULL,
1308
+ p_origin_member => NULL,
1309
+ p_origin_agent => NULL,
1310
+ p_origin_device => NULL,
1311
+ p_origin_harness => NULL,
1312
+ p_origin_session_key => NULL
1313
+ );
1314
+
1315
+ -- The typed relation 198 added. create_action_item deliberately has no
1316
+ -- meeting_id parameter: meetings are one source among nine and the
1317
+ -- function's parameter list is already twenty wide, so the linkage is
1318
+ -- stamped here, in the same transaction, rather than widening every
1319
+ -- caller's signature for one of them.
1320
+ UPDATE semicolony.action_items
1321
+ SET meeting_id = v_meeting_id
1322
+ WHERE action_item_id = v_item_id;
1323
+ END LOOP;
1324
+
1325
+ -- Y1: publish the skip list on the meeting, inside the same IF that owns
1326
+ -- the delete-and-recreate. `||` replaces the whole meeting_import key
1327
+ -- rather than merging into it, so a re-record with the descriptions fixed
1328
+ -- writes back an empty array instead of leaving a stale accusation on a
1329
+ -- meeting whose follow-up list is now complete — the evidence follows the
1330
+ -- same idempotency rule as the items it describes. Keeping it inside the
1331
+ -- IF also means an empty submission leaves both the items and their
1332
+ -- evidence untouched, which is the behaviour 199 defined and verification
1333
+ -- confirmed.
1334
+ UPDATE semicolony.meetings m
1335
+ SET metadata = COALESCE(m.metadata, '{}'::jsonb)
1336
+ || jsonb_build_object('meeting_import', jsonb_build_object(
1337
+ 'skipped_action_items', v_skipped,
1338
+ 'skipped_count', jsonb_array_length(v_skipped),
1339
+ 'submitted_count', jsonb_array_length(p_action_items),
1340
+ 'recorded_at', now(),
1341
+ 'record_source', p_record_source
1342
+ )),
1343
+ updated_at = now()
1344
+ WHERE m.meeting_id = v_meeting_id;
1345
+ END IF;
1346
+
1347
+ RETURN QUERY
1348
+ SELECT v_meeting_id,
1349
+ v_kb_id,
1350
+ COALESCE(ARRAY(SELECT a.action_item_id
1351
+ FROM semicolony.action_items a
1352
+ WHERE a.meeting_id = v_meeting_id
1353
+ ORDER BY a.created_at), '{}'::uuid[]),
1354
+ (v_existing IS NOT NULL);
1355
+ END;
1356
+ $function$;
1357
+
1358
+ -- Restated from 199 unchanged. CREATE OR REPLACE preserves the existing ACL,
1359
+ -- so these are a no-op on the live database; they are kept so that a fresh
1360
+ -- install applying 203 without 199 having ever run ends with the same grant
1361
+ -- surface, and so the sc_app contract (EXECUTE only, still no DML anywhere)
1362
+ -- is legible in the file that now owns the function body.
1363
+ REVOKE ALL ON FUNCTION semicolony.record_meeting(
1364
+ text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
1365
+ ) FROM PUBLIC;
1366
+
1367
+ GRANT EXECUTE ON FUNCTION semicolony.record_meeting(
1368
+ text, date, text, text, text, text, jsonb, text, text, jsonb, text, text, text
1369
+ ) TO sc_app;