@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.
- package/dist/bundle.js +1195 -1177
- package/migrations/198_meeting_record_canonical.sql +85 -0
- package/migrations/199_meeting_record_capability.sql +164 -0
- package/migrations/200_meeting_credential_scope.sql +320 -0
- package/migrations/201_meeting_audio_data_drop.sql +61 -0
- package/migrations/202_meeting_record_discussion_provenance.sql +173 -0
- package/migrations/202_team_members_record_of_authority.sql +103 -0
- package/migrations/203_action_item_redesign_cutover.sql +1369 -0
- package/migrations/204_record_meeting_overload_repair.sql +463 -0
- package/migrations/205_action_item_credential_scope.sql +318 -0
- package/package.json +1 -1
|
@@ -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;
|