writbase 0.1.0

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.
Files changed (30) hide show
  1. package/README.md +121 -0
  2. package/dist/writbase.js +707 -0
  3. package/migrations/00001_enums.sql +7 -0
  4. package/migrations/00002_core_tables.sql +105 -0
  5. package/migrations/00003_constraints_indexes.sql +69 -0
  6. package/migrations/00004_rls_policies.sql +169 -0
  7. package/migrations/00005_seed_app_settings.sql +2 -0
  8. package/migrations/00006_rate_limit_rpc.sql +14 -0
  9. package/migrations/00007_rate_limit_rpc_search_path.sql +14 -0
  10. package/migrations/00008_indexes_and_cleanup.sql +22 -0
  11. package/migrations/00009_atomic_mutations.sql +175 -0
  12. package/migrations/00010_user_rate_limits.sql +52 -0
  13. package/migrations/00011_atomic_permission_update.sql +31 -0
  14. package/migrations/00012_pg_cron_cleanup.sql +27 -0
  15. package/migrations/00013_max_agent_keys.sql +4 -0
  16. package/migrations/00014_event_log_indexes.sql +2 -0
  17. package/migrations/00015_auth_rate_limits.sql +69 -0
  18. package/migrations/00016_request_log.sql +42 -0
  19. package/migrations/00017_task_search.sql +58 -0
  20. package/migrations/00018_inter_agent_exchange.sql +423 -0
  21. package/migrations/00019_workspaces.sql +782 -0
  22. package/migrations/00020_can_comment.sql +46 -0
  23. package/migrations/00021_webhook_delivery.sql +125 -0
  24. package/migrations/00022_task_archive.sql +380 -0
  25. package/migrations/00023_get_top_tasks.sql +21 -0
  26. package/migrations/00024_agent_key_defaults.sql +55 -0
  27. package/migrations/00025_production_automation.sql +353 -0
  28. package/migrations/00026_top_tasks_exclude_terminal.sql +19 -0
  29. package/migrations/00027_rename_agent_key_defaults.sql +51 -0
  30. package/package.json +34 -0
@@ -0,0 +1,27 @@
1
+ -- pg_cron scheduled cleanup for rate_limits table
2
+ --
3
+ -- PREREQUISITE: pg_cron must be enabled via the Supabase Dashboard
4
+ -- (Database → Extensions → pg_cron) before running this migration.
5
+ -- On local dev (where pg_cron is not available), this migration is a no-op.
6
+
7
+ DO $outer$
8
+ BEGIN
9
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
10
+ -- Run cleanup_rate_limits() every 5 minutes to purge expired windows
11
+ PERFORM cron.schedule(
12
+ 'cleanup-rate-limits',
13
+ '*/5 * * * *',
14
+ 'SELECT public.cleanup_rate_limits()'
15
+ );
16
+
17
+ -- Purge cron.job_run_details older than 7 days, daily at 03:00 UTC
18
+ PERFORM cron.schedule(
19
+ 'cleanup-cron-history',
20
+ '0 3 * * *',
21
+ $$DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days'$$
22
+ );
23
+ ELSE
24
+ RAISE NOTICE 'pg_cron not available — skipping cron schedule setup';
25
+ END IF;
26
+ END;
27
+ $outer$;
@@ -0,0 +1,4 @@
1
+ -- Add configurable limit for agent keys created by a single manager.
2
+ -- Default 20, NULL means unlimited.
3
+ ALTER TABLE app_settings
4
+ ADD COLUMN max_agent_keys_per_manager integer DEFAULT 20;
@@ -0,0 +1,2 @@
1
+ -- Index on event_log(event_type) for filtering queries
2
+ CREATE INDEX IF NOT EXISTS idx_event_log_event_type ON event_log (event_type);
@@ -0,0 +1,69 @@
1
+ -- Pre-auth rate limiting by IP address.
2
+ -- Tracks failed authentication attempts per IP per minute window.
3
+ -- Separate from rate_limits (which requires an existing agent_key FK).
4
+
5
+ CREATE TABLE auth_rate_limits (
6
+ ip_address text NOT NULL,
7
+ window_start timestamptz NOT NULL,
8
+ failure_count integer DEFAULT 1 NOT NULL,
9
+ PRIMARY KEY (ip_address, window_start)
10
+ );
11
+
12
+ ALTER TABLE auth_rate_limits ENABLE ROW LEVEL SECURITY;
13
+
14
+ -- No RLS SELECT policies — only accessed via SECURITY DEFINER RPCs.
15
+
16
+ -- Atomically increment the failure counter for an IP + minute window.
17
+ CREATE OR REPLACE FUNCTION increment_auth_rate_limit(p_ip text)
18
+ RETURNS integer
19
+ LANGUAGE sql
20
+ VOLATILE
21
+ SECURITY DEFINER
22
+ SET search_path = public
23
+ AS $$
24
+ INSERT INTO auth_rate_limits (ip_address, window_start, failure_count)
25
+ VALUES (p_ip, date_trunc('minute', now()), 1)
26
+ ON CONFLICT (ip_address, window_start)
27
+ DO UPDATE SET failure_count = auth_rate_limits.failure_count + 1
28
+ RETURNING failure_count;
29
+ $$;
30
+
31
+ -- Check the current failure count for an IP in the current minute window.
32
+ CREATE OR REPLACE FUNCTION check_auth_rate_limit(p_ip text)
33
+ RETURNS integer
34
+ LANGUAGE sql
35
+ STABLE
36
+ SECURITY DEFINER
37
+ SET search_path = public
38
+ AS $$
39
+ SELECT COALESCE(
40
+ (SELECT failure_count FROM auth_rate_limits
41
+ WHERE ip_address = p_ip AND window_start = date_trunc('minute', now())),
42
+ 0
43
+ );
44
+ $$;
45
+
46
+ -- Cleanup function for pg_cron: purge entries older than 1 hour
47
+ CREATE OR REPLACE FUNCTION cleanup_auth_rate_limits()
48
+ RETURNS void
49
+ LANGUAGE sql
50
+ SECURITY DEFINER
51
+ SET search_path = public
52
+ AS $$
53
+ DELETE FROM auth_rate_limits WHERE window_start < now() - interval '1 hour';
54
+ $$;
55
+
56
+ -- Schedule cleanup daily at 04:00 UTC (no-op if pg_cron not available)
57
+ DO $outer$
58
+ BEGIN
59
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
60
+ PERFORM cron.schedule(
61
+ 'cleanup-auth-rate-limits',
62
+ '0 4 * * *',
63
+ 'SELECT public.cleanup_auth_rate_limits()'
64
+ );
65
+ ELSE
66
+ RAISE NOTICE 'pg_cron not available — skipping auth rate limit cleanup schedule';
67
+ END IF;
68
+ END;
69
+ $outer$;
@@ -0,0 +1,42 @@
1
+ -- Request log for operational telemetry.
2
+ -- Separate from event_log (domain audit/provenance) — different shape and lifecycle.
3
+
4
+ CREATE TABLE request_log (
5
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
6
+ agent_key_id uuid NOT NULL REFERENCES agent_keys(id),
7
+ tool_name text NOT NULL,
8
+ project_id uuid REFERENCES projects(id),
9
+ latency_ms int,
10
+ status text NOT NULL,
11
+ error_code text,
12
+ created_at timestamptz DEFAULT now() NOT NULL
13
+ );
14
+
15
+ ALTER TABLE request_log ENABLE ROW LEVEL SECURITY;
16
+
17
+ CREATE POLICY "admin_select_request_log" ON request_log
18
+ FOR SELECT TO authenticated
19
+ USING (auth.uid() IN (SELECT user_id FROM admin_users));
20
+
21
+ -- BRIN index for time-range scans (append-only, monotonic timestamps)
22
+ CREATE INDEX idx_request_log_created_at ON request_log USING brin (created_at);
23
+ CREATE INDEX idx_request_log_agent_key_id ON request_log (agent_key_id);
24
+
25
+ -- Partial index for error investigation only
26
+ CREATE INDEX idx_request_log_errors ON request_log (created_at, error_code)
27
+ WHERE status != 'ok';
28
+
29
+ -- pg_cron cleanup: purge entries older than 30 days, daily at 04:00 UTC (no-op if pg_cron not available)
30
+ DO $outer$
31
+ BEGIN
32
+ IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'cron') THEN
33
+ PERFORM cron.schedule(
34
+ 'cleanup-request-log',
35
+ '0 4 * * *',
36
+ $$DELETE FROM request_log WHERE created_at < now() - interval '30 days'$$
37
+ );
38
+ ELSE
39
+ RAISE NOTICE 'pg_cron not available — skipping request log cleanup schedule';
40
+ END IF;
41
+ END;
42
+ $outer$;
@@ -0,0 +1,58 @@
1
+ -- Add tsvector column for full-text search
2
+ ALTER TABLE tasks ADD COLUMN search_vector tsvector
3
+ GENERATED ALWAYS AS (
4
+ to_tsvector('english', coalesce(description, '') || ' ' || coalesce(notes, ''))
5
+ ) STORED;
6
+
7
+ CREATE INDEX idx_tasks_search ON tasks USING gin (search_vector);
8
+
9
+ -- Update get_tasks_page RPC to support full-text search
10
+ CREATE OR REPLACE FUNCTION get_tasks_page(
11
+ p_project_id uuid,
12
+ p_department_id uuid DEFAULT NULL,
13
+ p_status status DEFAULT NULL,
14
+ p_priority priority DEFAULT NULL,
15
+ p_updated_after timestamptz DEFAULT NULL,
16
+ p_cursor_created_at timestamptz DEFAULT NULL,
17
+ p_cursor_id uuid DEFAULT NULL,
18
+ p_limit integer DEFAULT 20,
19
+ p_search text DEFAULT NULL
20
+ )
21
+ RETURNS TABLE (
22
+ id uuid,
23
+ project_id uuid,
24
+ department_id uuid,
25
+ priority priority,
26
+ description text,
27
+ notes text,
28
+ due_date timestamptz,
29
+ status status,
30
+ version integer,
31
+ created_at timestamptz,
32
+ updated_at timestamptz,
33
+ created_by_type actor_type,
34
+ created_by_id text,
35
+ updated_by_type actor_type,
36
+ updated_by_id text,
37
+ source source
38
+ )
39
+ LANGUAGE sql
40
+ STABLE
41
+ AS $$
42
+ SELECT t.id, t.project_id, t.department_id, t.priority, t.description,
43
+ t.notes, t.due_date, t.status, t.version, t.created_at, t.updated_at,
44
+ t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source
45
+ FROM tasks t
46
+ WHERE t.project_id = p_project_id
47
+ AND (p_department_id IS NULL OR t.department_id = p_department_id)
48
+ AND (p_status IS NULL OR t.status = p_status)
49
+ AND (p_priority IS NULL OR t.priority = p_priority)
50
+ AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
51
+ AND (
52
+ p_cursor_created_at IS NULL
53
+ OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
54
+ )
55
+ AND (p_search IS NULL OR t.search_vector @@ websearch_to_tsquery('english', p_search))
56
+ ORDER BY t.created_at ASC, t.id ASC
57
+ LIMIT LEAST(p_limit, 50);
58
+ $$;
@@ -0,0 +1,423 @@
1
+ -- Inter-Agent Task Exchange: schema additions for task assignment,
2
+ -- delegation safety, webhook notifications, and agent discovery.
3
+
4
+ -- ══════════════════════════════════════════════════════════════════════
5
+ -- 1. Add 'failed' to status enum (A2A alignment)
6
+ -- ══════════════════════════════════════════════════════════════════════
7
+ ALTER TYPE status ADD VALUE IF NOT EXISTS 'failed';
8
+
9
+ -- ══════════════════════════════════════════════════════════════════════
10
+ -- 2. Task assignment columns + delegation safety
11
+ -- ══════════════════════════════════════════════════════════════════════
12
+ ALTER TABLE tasks
13
+ ADD COLUMN assigned_to_agent_key_id uuid REFERENCES agent_keys(id),
14
+ ADD COLUMN requested_by_agent_key_id uuid REFERENCES agent_keys(id),
15
+ ADD COLUMN delegation_depth integer NOT NULL DEFAULT 0,
16
+ ADD COLUMN assignment_chain uuid[] NOT NULL DEFAULT '{}';
17
+
18
+ ALTER TABLE tasks
19
+ ADD CONSTRAINT chk_delegation_depth CHECK (delegation_depth <= 3);
20
+
21
+ CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to_agent_key_id)
22
+ WHERE assigned_to_agent_key_id IS NOT NULL;
23
+
24
+ CREATE INDEX idx_tasks_requested_by ON tasks(requested_by_agent_key_id)
25
+ WHERE requested_by_agent_key_id IS NOT NULL;
26
+
27
+ -- ══════════════════════════════════════════════════════════════════════
28
+ -- 3. Add can_assign permission to agent_permissions
29
+ -- ══════════════════════════════════════════════════════════════════════
30
+ ALTER TABLE agent_permissions
31
+ ADD COLUMN can_assign boolean NOT NULL DEFAULT false;
32
+
33
+ -- ══════════════════════════════════════════════════════════════════════
34
+ -- 4. Webhook subscriptions table
35
+ -- ══════════════════════════════════════════════════════════════════════
36
+ CREATE TABLE webhook_subscriptions (
37
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
38
+ agent_key_id uuid NOT NULL REFERENCES agent_keys(id) ON DELETE CASCADE,
39
+ project_id uuid NOT NULL REFERENCES projects(id),
40
+ event_types text[] NOT NULL DEFAULT '{task.completed}',
41
+ url text NOT NULL,
42
+ secret text NOT NULL,
43
+ is_active boolean NOT NULL DEFAULT true,
44
+ created_at timestamptz NOT NULL DEFAULT now(),
45
+ updated_at timestamptz NOT NULL DEFAULT now()
46
+ );
47
+
48
+ CREATE INDEX idx_webhook_subs_agent ON webhook_subscriptions(agent_key_id);
49
+ CREATE INDEX idx_webhook_subs_project ON webhook_subscriptions(project_id);
50
+ CREATE INDEX idx_webhook_subs_active ON webhook_subscriptions(project_id, is_active)
51
+ WHERE is_active = true;
52
+
53
+ -- ══════════════════════════════════════════════════════════════════════
54
+ -- 5. Agent capabilities table (A2A Agent Card shaped)
55
+ -- ══════════════════════════════════════════════════════════════════════
56
+ CREATE TABLE agent_capabilities (
57
+ agent_key_id uuid PRIMARY KEY REFERENCES agent_keys(id) ON DELETE CASCADE,
58
+ skills text[] NOT NULL DEFAULT '{}',
59
+ description text,
60
+ accepts_tasks boolean NOT NULL DEFAULT true,
61
+ created_at timestamptz NOT NULL DEFAULT now(),
62
+ updated_at timestamptz NOT NULL DEFAULT now()
63
+ );
64
+
65
+ -- ══════════════════════════════════════════════════════════════════════
66
+ -- 6. Update get_tasks_page to include new columns + assignment filters
67
+ -- ══════════════════════════════════════════════════════════════════════
68
+ CREATE OR REPLACE FUNCTION get_tasks_page(
69
+ p_project_id uuid,
70
+ p_department_id uuid DEFAULT NULL,
71
+ p_status status DEFAULT NULL,
72
+ p_priority priority DEFAULT NULL,
73
+ p_updated_after timestamptz DEFAULT NULL,
74
+ p_cursor_created_at timestamptz DEFAULT NULL,
75
+ p_cursor_id uuid DEFAULT NULL,
76
+ p_limit integer DEFAULT 20,
77
+ p_search text DEFAULT NULL,
78
+ p_assigned_to uuid DEFAULT NULL,
79
+ p_requested_by uuid DEFAULT NULL
80
+ )
81
+ RETURNS TABLE (
82
+ id uuid,
83
+ project_id uuid,
84
+ department_id uuid,
85
+ priority priority,
86
+ description text,
87
+ notes text,
88
+ due_date timestamptz,
89
+ status status,
90
+ version integer,
91
+ created_at timestamptz,
92
+ updated_at timestamptz,
93
+ created_by_type actor_type,
94
+ created_by_id text,
95
+ updated_by_type actor_type,
96
+ updated_by_id text,
97
+ source source,
98
+ assigned_to_agent_key_id uuid,
99
+ requested_by_agent_key_id uuid,
100
+ delegation_depth integer,
101
+ assignment_chain uuid[]
102
+ )
103
+ LANGUAGE sql
104
+ STABLE
105
+ AS $$
106
+ SELECT t.id, t.project_id, t.department_id, t.priority, t.description,
107
+ t.notes, t.due_date, t.status, t.version, t.created_at, t.updated_at,
108
+ t.created_by_type, t.created_by_id, t.updated_by_type, t.updated_by_id, t.source,
109
+ t.assigned_to_agent_key_id, t.requested_by_agent_key_id,
110
+ t.delegation_depth, t.assignment_chain
111
+ FROM tasks t
112
+ WHERE t.project_id = p_project_id
113
+ AND (p_department_id IS NULL OR t.department_id = p_department_id)
114
+ AND (p_status IS NULL OR t.status = p_status)
115
+ AND (p_priority IS NULL OR t.priority = p_priority)
116
+ AND (p_updated_after IS NULL OR t.updated_at > p_updated_after)
117
+ AND (p_search IS NULL OR t.search_vector @@ websearch_to_tsquery('english', p_search))
118
+ AND (p_assigned_to IS NULL OR t.assigned_to_agent_key_id = p_assigned_to)
119
+ AND (p_requested_by IS NULL OR t.requested_by_agent_key_id = p_requested_by)
120
+ AND (
121
+ p_cursor_created_at IS NULL
122
+ OR (t.created_at, t.id) > (p_cursor_created_at, p_cursor_id)
123
+ )
124
+ ORDER BY t.created_at ASC, t.id ASC
125
+ LIMIT LEAST(p_limit, 50);
126
+ $$;
127
+
128
+ -- ══════════════════════════════════════════════════════════════════════
129
+ -- 7. Update create_task_with_event to support assignment fields
130
+ -- ══════════════════════════════════════════════════════════════════════
131
+ CREATE OR REPLACE FUNCTION create_task_with_event(p_payload jsonb)
132
+ RETURNS tasks
133
+ LANGUAGE plpgsql
134
+ SECURITY DEFINER
135
+ SET search_path = public
136
+ AS $$
137
+ DECLARE
138
+ v_project projects%ROWTYPE;
139
+ v_dept departments%ROWTYPE;
140
+ v_task tasks%ROWTYPE;
141
+ v_dept_id uuid;
142
+ v_assigned_to uuid;
143
+ v_requested_by uuid;
144
+ BEGIN
145
+ -- 1. Validate project exists and is not archived
146
+ SELECT * INTO v_project
147
+ FROM projects
148
+ WHERE id = (p_payload->>'project_id')::uuid;
149
+
150
+ IF NOT FOUND THEN
151
+ RAISE EXCEPTION 'project_not_found:Project not found';
152
+ END IF;
153
+
154
+ IF v_project.is_archived THEN
155
+ RAISE EXCEPTION 'project_archived:Cannot create tasks in an archived project';
156
+ END IF;
157
+
158
+ -- 2. Validate department if provided
159
+ v_dept_id := (p_payload->>'department_id')::uuid;
160
+ IF v_dept_id IS NOT NULL THEN
161
+ SELECT * INTO v_dept
162
+ FROM departments
163
+ WHERE id = v_dept_id;
164
+
165
+ IF NOT FOUND THEN
166
+ RAISE EXCEPTION 'department_not_found:Department not found';
167
+ END IF;
168
+
169
+ IF v_dept.is_archived THEN
170
+ RAISE EXCEPTION 'department_archived:Cannot create tasks in an archived department';
171
+ END IF;
172
+ END IF;
173
+
174
+ -- 3. Resolve assignment fields
175
+ v_assigned_to := (p_payload->>'assigned_to_agent_key_id')::uuid;
176
+ v_requested_by := (p_payload->>'requested_by_agent_key_id')::uuid;
177
+
178
+ -- 4. Validate assigned agent exists and is active
179
+ IF v_assigned_to IS NOT NULL THEN
180
+ IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_assigned_to AND is_active = true) THEN
181
+ RAISE EXCEPTION 'invalid_assignee:Assigned agent key does not exist or is inactive';
182
+ END IF;
183
+ END IF;
184
+
185
+ -- 5. Insert task
186
+ INSERT INTO tasks (
187
+ project_id, department_id, priority, description, notes,
188
+ due_date, status, version,
189
+ created_by_type, created_by_id, updated_by_type, updated_by_id, source,
190
+ assigned_to_agent_key_id, requested_by_agent_key_id,
191
+ delegation_depth, assignment_chain
192
+ ) VALUES (
193
+ (p_payload->>'project_id')::uuid,
194
+ v_dept_id,
195
+ COALESCE((p_payload->>'priority')::priority, 'medium'),
196
+ p_payload->>'description',
197
+ p_payload->>'notes',
198
+ (p_payload->>'due_date')::timestamptz,
199
+ COALESCE((p_payload->>'status')::status, 'todo'),
200
+ 1,
201
+ (p_payload->>'created_by_type')::actor_type,
202
+ p_payload->>'created_by_id',
203
+ (p_payload->>'created_by_type')::actor_type,
204
+ p_payload->>'created_by_id',
205
+ (p_payload->>'source')::source,
206
+ v_assigned_to,
207
+ v_requested_by,
208
+ 0,
209
+ '{}'
210
+ )
211
+ RETURNING * INTO v_task;
212
+
213
+ -- 6. Insert event_log
214
+ INSERT INTO event_log (
215
+ event_category, target_type, target_id, event_type,
216
+ actor_type, actor_id, actor_label, source
217
+ ) VALUES (
218
+ 'task', 'task', v_task.id, 'task.created',
219
+ (p_payload->>'actor_type')::actor_type,
220
+ p_payload->>'actor_id',
221
+ p_payload->>'actor_label',
222
+ (p_payload->>'source')::source
223
+ );
224
+
225
+ -- 7. Log assignment if task was assigned
226
+ IF v_assigned_to IS NOT NULL THEN
227
+ INSERT INTO event_log (
228
+ event_category, target_type, target_id, event_type,
229
+ field_name, new_value,
230
+ actor_type, actor_id, actor_label, source
231
+ ) VALUES (
232
+ 'task', 'task', v_task.id, 'task.assigned',
233
+ 'assigned_to_agent_key_id', to_jsonb(v_assigned_to::text),
234
+ (p_payload->>'actor_type')::actor_type,
235
+ p_payload->>'actor_id',
236
+ p_payload->>'actor_label',
237
+ (p_payload->>'source')::source
238
+ );
239
+ END IF;
240
+
241
+ RETURN v_task;
242
+ END;
243
+ $$;
244
+
245
+ -- ══════════════════════════════════════════════════════════════════════
246
+ -- 8. Update update_task_with_events to track assignment changes
247
+ -- ══════════════════════════════════════════════════════════════════════
248
+ CREATE OR REPLACE FUNCTION update_task_with_events(p_payload jsonb)
249
+ RETURNS tasks
250
+ LANGUAGE plpgsql
251
+ SECURITY DEFINER
252
+ SET search_path = public
253
+ AS $$
254
+ DECLARE
255
+ v_old tasks%ROWTYPE;
256
+ v_new tasks%ROWTYPE;
257
+ v_task_id uuid;
258
+ v_version integer;
259
+ v_updates jsonb;
260
+ v_actor_type actor_type;
261
+ v_actor_id text;
262
+ v_actor_label text;
263
+ v_source source;
264
+ v_new_assignee uuid;
265
+ BEGIN
266
+ v_task_id := (p_payload->>'task_id')::uuid;
267
+ v_version := (p_payload->>'version')::integer;
268
+ v_updates := p_payload->'fields';
269
+ v_actor_type := (p_payload->>'actor_type')::actor_type;
270
+ v_actor_id := p_payload->>'actor_id';
271
+ v_actor_label := p_payload->>'actor_label';
272
+ v_source := (p_payload->>'source')::source;
273
+
274
+ -- 1. Fetch existing task
275
+ SELECT * INTO v_old FROM tasks WHERE id = v_task_id;
276
+
277
+ IF NOT FOUND THEN
278
+ RAISE EXCEPTION 'task_not_found:Task not found';
279
+ END IF;
280
+
281
+ -- 2. Version check
282
+ IF v_old.version <> v_version THEN
283
+ RAISE EXCEPTION 'version_conflict:Version conflict: expected %, current is %', v_version, v_old.version;
284
+ END IF;
285
+
286
+ -- 3. Delegation safety checks for reassignment
287
+ IF v_updates ? 'assigned_to_agent_key_id' THEN
288
+ v_new_assignee := (v_updates->>'assigned_to_agent_key_id')::uuid;
289
+
290
+ IF v_new_assignee IS NOT NULL THEN
291
+ -- Validate assignee exists and is active
292
+ IF NOT EXISTS (SELECT 1 FROM agent_keys WHERE id = v_new_assignee AND is_active = true) THEN
293
+ RAISE EXCEPTION 'invalid_assignee:Assigned agent key does not exist or is inactive';
294
+ END IF;
295
+
296
+ -- Prevent circular delegation: new assignee must not be in assignment_chain
297
+ IF v_new_assignee = ANY(v_old.assignment_chain) THEN
298
+ RAISE EXCEPTION 'circular_delegation:Agent has already been in the delegation chain for this task';
299
+ END IF;
300
+
301
+ -- Enforce delegation depth limit
302
+ IF v_old.delegation_depth >= 3 THEN
303
+ RAISE EXCEPTION 'delegation_depth_exceeded:Maximum delegation depth (3) reached';
304
+ END IF;
305
+ END IF;
306
+ END IF;
307
+
308
+ -- 4. Update task (only provided fields)
309
+ UPDATE tasks SET
310
+ priority = COALESCE((v_updates->>'priority')::priority, priority),
311
+ description = COALESCE(v_updates->>'description', description),
312
+ notes = CASE WHEN v_updates ? 'notes' THEN v_updates->>'notes' ELSE notes END,
313
+ department_id = CASE WHEN v_updates ? 'department_id' THEN (v_updates->>'department_id')::uuid ELSE department_id END,
314
+ due_date = CASE WHEN v_updates ? 'due_date' THEN (v_updates->>'due_date')::timestamptz ELSE due_date END,
315
+ status = COALESCE((v_updates->>'status')::status, status),
316
+ assigned_to_agent_key_id = CASE
317
+ WHEN v_updates ? 'assigned_to_agent_key_id' THEN (v_updates->>'assigned_to_agent_key_id')::uuid
318
+ ELSE assigned_to_agent_key_id
319
+ END,
320
+ delegation_depth = CASE
321
+ WHEN v_updates ? 'assigned_to_agent_key_id' AND (v_updates->>'assigned_to_agent_key_id')::uuid IS NOT NULL
322
+ AND (v_updates->>'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id
323
+ THEN delegation_depth + 1
324
+ ELSE delegation_depth
325
+ END,
326
+ assignment_chain = CASE
327
+ WHEN v_updates ? 'assigned_to_agent_key_id' AND (v_updates->>'assigned_to_agent_key_id')::uuid IS NOT NULL
328
+ AND (v_updates->>'assigned_to_agent_key_id')::uuid IS DISTINCT FROM assigned_to_agent_key_id
329
+ AND assigned_to_agent_key_id IS NOT NULL
330
+ THEN array_append(assignment_chain, assigned_to_agent_key_id)
331
+ ELSE assignment_chain
332
+ END,
333
+ version = version + 1,
334
+ updated_at = now(),
335
+ updated_by_type = v_actor_type,
336
+ updated_by_id = v_actor_id,
337
+ source = v_source
338
+ WHERE id = v_task_id AND version = v_version
339
+ RETURNING * INTO v_new;
340
+
341
+ IF NOT FOUND THEN
342
+ RAISE EXCEPTION 'version_conflict:Task was modified concurrently';
343
+ END IF;
344
+
345
+ -- 5. Field-level diff using IS DISTINCT FROM, insert event_log rows
346
+ IF v_new.priority IS DISTINCT FROM v_old.priority THEN
347
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
348
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'priority', to_jsonb(v_old.priority::text), to_jsonb(v_new.priority::text), v_actor_type, v_actor_id, v_actor_label, v_source);
349
+ END IF;
350
+
351
+ IF v_new.description IS DISTINCT FROM v_old.description THEN
352
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
353
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'description', to_jsonb(v_old.description), to_jsonb(v_new.description), v_actor_type, v_actor_id, v_actor_label, v_source);
354
+ END IF;
355
+
356
+ IF v_new.notes IS DISTINCT FROM v_old.notes THEN
357
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
358
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'notes', to_jsonb(v_old.notes), to_jsonb(v_new.notes), v_actor_type, v_actor_id, v_actor_label, v_source);
359
+ END IF;
360
+
361
+ IF v_new.department_id IS DISTINCT FROM v_old.department_id THEN
362
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
363
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'department_id', to_jsonb(v_old.department_id), to_jsonb(v_new.department_id), v_actor_type, v_actor_id, v_actor_label, v_source);
364
+ END IF;
365
+
366
+ IF v_new.due_date IS DISTINCT FROM v_old.due_date THEN
367
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
368
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'due_date', to_jsonb(v_old.due_date), to_jsonb(v_new.due_date), v_actor_type, v_actor_id, v_actor_label, v_source);
369
+ END IF;
370
+
371
+ IF v_new.status IS DISTINCT FROM v_old.status THEN
372
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
373
+ VALUES ('task', 'task', v_task_id, 'task.updated', 'status', to_jsonb(v_old.status::text), to_jsonb(v_new.status::text), v_actor_type, v_actor_id, v_actor_label, v_source);
374
+ END IF;
375
+
376
+ IF v_new.assigned_to_agent_key_id IS DISTINCT FROM v_old.assigned_to_agent_key_id THEN
377
+ INSERT INTO event_log (event_category, target_type, target_id, event_type, field_name, old_value, new_value, actor_type, actor_id, actor_label, source)
378
+ VALUES ('task', 'task', v_task_id, 'task.reassigned', 'assigned_to_agent_key_id',
379
+ to_jsonb(v_old.assigned_to_agent_key_id::text), to_jsonb(v_new.assigned_to_agent_key_id::text),
380
+ v_actor_type, v_actor_id, v_actor_label, v_source);
381
+ END IF;
382
+
383
+ RETURN v_new;
384
+ END;
385
+ $$;
386
+
387
+ -- ══════════════════════════════════════════════════════════════════════
388
+ -- 9. Update update_agent_permissions to support can_assign
389
+ -- ══════════════════════════════════════════════════════════════════════
390
+ CREATE OR REPLACE FUNCTION update_agent_permissions(p_key_id uuid, p_rows jsonb)
391
+ RETURNS void
392
+ LANGUAGE plpgsql
393
+ SECURITY DEFINER
394
+ SET search_path = public
395
+ AS $$
396
+ DECLARE
397
+ v_row jsonb;
398
+ BEGIN
399
+ -- Ensure caller is admin
400
+ IF NOT EXISTS (SELECT 1 FROM admin_users WHERE user_id = auth.uid()) THEN
401
+ RAISE EXCEPTION 'unauthorized:Only admins can update agent permissions';
402
+ END IF;
403
+
404
+ -- Delete existing permissions for this key
405
+ DELETE FROM agent_permissions WHERE agent_key_id = p_key_id;
406
+
407
+ -- Insert new permissions
408
+ FOR v_row IN SELECT * FROM jsonb_array_elements(p_rows) LOOP
409
+ INSERT INTO agent_permissions (
410
+ agent_key_id, project_id, department_id,
411
+ can_read, can_create, can_update, can_assign
412
+ ) VALUES (
413
+ p_key_id,
414
+ (v_row->>'project_id')::uuid,
415
+ (v_row->>'department_id')::uuid,
416
+ COALESCE((v_row->>'can_read')::boolean, false),
417
+ COALESCE((v_row->>'can_create')::boolean, false),
418
+ COALESCE((v_row->>'can_update')::boolean, false),
419
+ COALESCE((v_row->>'can_assign')::boolean, false)
420
+ );
421
+ END LOOP;
422
+ END;
423
+ $$;