@consiliency/agent-board-schema 1.10.0 → 1.22.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,287 @@
1
+ -- OBSGATE-2 (#123) — extend the board posting rule to every thread-authoring RPC.
2
+ --
3
+ -- #78 (OBSGATE) closed the message-authoring door: an observer can no longer post a MESSAGE
4
+ -- into a board-linked thread (the gate _current_agent_can_post_to_thread is enforced in
5
+ -- post_message). But post_message is not the only way to author into a thread. These
6
+ -- authenticated RPCs author thread-scoped content and gated only on _current_agent_can_access_
7
+ -- thread — no posting-rule / send-role check — so an observer with board-thread access could
8
+ -- still author through them (verified live: create_task and delegate_task):
9
+ --
10
+ -- create_task -> a task + 'created' task_event
11
+ -- delegate_task -> a task (calls create_task) — closed TRANSITIVELY by gating create_task
12
+ -- add_artifact -> an artifact (targets thread / task / message / task_event)
13
+ -- raise_conflict -> a conflict record
14
+ -- raise_escalation -> an escalation record
15
+ --
16
+ -- Maintainer decision: an observer is PURE READ within a board (per-board role — the agent can
17
+ -- still participate on any OTHER board it belongs to, and board-less DM/multicast threads are
18
+ -- ungated), so ALL of the above are gated. The shared #78 predicate is reused: a board-linked
19
+ -- thread requires a non-observer board role; a board-less thread stays access-only; service_role
20
+ -- short-circuits.
21
+ --
22
+ -- Each function is CREATE OR REPLACE'd with the SAME body plus the gate after its existing
23
+ -- access check; grants are re-asserted. delegate_task is intentionally NOT modified — it reaches
24
+ -- create_task(v_parent.thread_id, ...), so the gate fires there.
25
+ --
26
+ -- Additive, forward-only. schema_version stays 8.
27
+
28
+ -- 1. create_task (also closes delegate_task transitively) --------------------
29
+ create or replace function agent_board.create_task(
30
+ p_thread_id uuid,
31
+ p_created_by_agent_id uuid,
32
+ p_title text,
33
+ p_details text default null,
34
+ p_assigned_agent_id uuid default null,
35
+ p_parent_task_id uuid default null,
36
+ p_priority text default 'medium',
37
+ p_orchestration_mode text default 'distributed',
38
+ p_metadata jsonb default '{}'::jsonb
39
+ )
40
+ returns table(task_id uuid, parent_task_id uuid, root_task_id uuid, created_event_id uuid, created_event_sequence bigint, status text, created_at timestamp with time zone, updated_at timestamp with time zone)
41
+ language plpgsql
42
+ security definer
43
+ set search_path to 'agent_board', 'public'
44
+ as $function$
45
+ declare
46
+ v_task_id uuid;
47
+ v_root_task_id uuid;
48
+ v_event_id uuid;
49
+ v_actor_agent_id uuid;
50
+ begin
51
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
52
+
53
+ if nullif(trim(p_title), '') is null then
54
+ raise exception 'task title is required' using errcode = '22023';
55
+ end if;
56
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
57
+ raise exception 'thread not found' using errcode = 'P0002';
58
+ end if;
59
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
60
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
61
+ end if;
62
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
63
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
64
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
65
+ end if;
66
+ if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
67
+ raise exception 'assigned agent not found' using errcode = 'P0002';
68
+ end if;
69
+
70
+ if p_parent_task_id is not null then
71
+ select coalesce(tasks.root_task_id, tasks.id)
72
+ into v_root_task_id
73
+ from agent_board.tasks
74
+ where id = p_parent_task_id and tasks.thread_id = p_thread_id;
75
+ if v_root_task_id is null then
76
+ raise exception 'parent task not found in thread' using errcode = '22023';
77
+ end if;
78
+ if not agent_board._current_agent_can_access_task(p_parent_task_id) then
79
+ raise exception 'parent task not accessible to current agent' using errcode = '42501';
80
+ end if;
81
+ end if;
82
+
83
+ v_task_id := gen_random_uuid();
84
+ if p_parent_task_id is null then
85
+ v_root_task_id := v_task_id;
86
+ end if;
87
+
88
+ insert into agent_board.tasks(id, thread_id, created_by_agent_id, assigned_agent_id, parent_task_id, root_task_id, title, details, priority, orchestration_mode, metadata)
89
+ values (v_task_id, p_thread_id, v_actor_agent_id, p_assigned_agent_id, p_parent_task_id, v_root_task_id, trim(p_title), p_details, coalesce(p_priority, 'medium'), coalesce(p_orchestration_mode, 'distributed'), coalesce(p_metadata, '{}'::jsonb));
90
+
91
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload)
92
+ values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)))
93
+ returning id into v_event_id;
94
+
95
+ return query
96
+ select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
97
+ from agent_board.tasks t
98
+ where t.id = v_task_id;
99
+ end;
100
+ $function$;
101
+ revoke all on function agent_board.create_task(uuid, uuid, text, text, uuid, uuid, text, text, jsonb) from public, anon;
102
+ grant execute on function agent_board.create_task(uuid, uuid, text, text, uuid, uuid, text, text, jsonb) to authenticated, service_role;
103
+
104
+ -- 2. add_artifact -----------------------------------------------------------
105
+ create or replace function agent_board.add_artifact(
106
+ p_created_by_agent_id uuid,
107
+ p_uri text,
108
+ p_artifact_type text default 'url',
109
+ p_storage_kind text default 'external_url',
110
+ p_thread_id uuid default null,
111
+ p_task_id uuid default null,
112
+ p_message_id uuid default null,
113
+ p_task_event_id uuid default null,
114
+ p_metadata jsonb default '{}'::jsonb
115
+ )
116
+ returns table(artifact_id uuid, task_event_sequence bigint, created_at timestamp with time zone)
117
+ language plpgsql
118
+ security definer
119
+ set search_path to 'agent_board', 'public'
120
+ as $function$
121
+ declare
122
+ v_artifact_id uuid;
123
+ v_event_sequence bigint;
124
+ v_actor_agent_id uuid;
125
+ v_thread_id uuid;
126
+ begin
127
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
128
+
129
+ if p_thread_id is null and p_task_id is null and p_message_id is null and p_task_event_id is null then
130
+ raise exception 'artifact target is required' using errcode = '22023';
131
+ end if;
132
+
133
+ if p_task_id is not null then
134
+ if not agent_board._current_agent_can_access_task(p_task_id) then
135
+ raise exception 'task not accessible to current agent' using errcode = '42501';
136
+ end if;
137
+ end if;
138
+
139
+ if p_thread_id is not null and not agent_board._current_agent_can_access_thread(p_thread_id) then
140
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
141
+ end if;
142
+
143
+ if p_message_id is not null then
144
+ select m.thread_id into v_thread_id from agent_board.messages m where m.id = p_message_id;
145
+ if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
146
+ raise exception 'message not accessible to current agent' using errcode = '42501';
147
+ end if;
148
+ end if;
149
+
150
+ if p_task_event_id is not null then
151
+ select te.thread_id into v_thread_id from agent_board.task_events te where te.id = p_task_event_id;
152
+ if v_thread_id is null or not agent_board._current_agent_can_access_thread(v_thread_id) then
153
+ raise exception 'task event not accessible to current agent' using errcode = '42501';
154
+ end if;
155
+ end if;
156
+
157
+ -- OBSGATE-2 (#123): apply the posting gate to the artifact's effective thread (resolved from
158
+ -- whichever target was supplied). A board-linked thread requires a non-observer board role.
159
+ v_thread_id := coalesce(
160
+ p_thread_id,
161
+ v_thread_id,
162
+ (select tk.thread_id from agent_board.tasks tk where tk.id = p_task_id)
163
+ );
164
+ if v_thread_id is not null and not agent_board._current_agent_can_post_to_thread(v_thread_id) then
165
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
166
+ end if;
167
+
168
+ insert into agent_board.artifacts(thread_id, task_id, message_id, task_event_id, created_by_agent_id, artifact_type, storage_kind, uri, metadata)
169
+ values (p_thread_id, p_task_id, p_message_id, p_task_event_id, v_actor_agent_id, coalesce(p_artifact_type, 'url'), coalesce(p_storage_kind, 'external_url'), p_uri, coalesce(p_metadata, '{}'::jsonb))
170
+ returning id into v_artifact_id;
171
+
172
+ -- v_event_sequence stays NULL when there is no task (a thread/message/task_event artifact).
173
+ -- The prior `v_event record` + unconditional `v_event.event_sequence` raised
174
+ -- "record not assigned yet" on that no-task path — a latent bug fixed here while these
175
+ -- functions are being rewritten for the gate.
176
+ if p_task_id is not null then
177
+ select event_sequence into v_event_sequence
178
+ from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'artifact_added', jsonb_build_object('artifact_id', v_artifact_id));
179
+ end if;
180
+
181
+ return query
182
+ select v_artifact_id, v_event_sequence, now();
183
+ end;
184
+ $function$;
185
+ revoke all on function agent_board.add_artifact(uuid, text, text, text, uuid, uuid, uuid, uuid, jsonb) from public, anon;
186
+ grant execute on function agent_board.add_artifact(uuid, text, text, text, uuid, uuid, uuid, uuid, jsonb) to authenticated, service_role;
187
+
188
+ -- 3. raise_conflict ---------------------------------------------------------
189
+ create or replace function agent_board.raise_conflict(
190
+ p_thread_id uuid,
191
+ p_raised_by_agent_id uuid,
192
+ p_summary text,
193
+ p_conflict_type text default 'other',
194
+ p_severity text default 'medium',
195
+ p_task_id uuid default null,
196
+ p_details jsonb default '{}'::jsonb
197
+ )
198
+ returns table(conflict_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
199
+ language plpgsql
200
+ security definer
201
+ set search_path to 'agent_board', 'public'
202
+ as $function$
203
+ declare
204
+ v_conflict_id uuid;
205
+ v_event_sequence bigint;
206
+ v_actor_agent_id uuid;
207
+ begin
208
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
209
+
210
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
211
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
212
+ end if;
213
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
214
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
215
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
216
+ end if;
217
+
218
+ insert into agent_board.conflicts(thread_id, task_id, raised_by_agent_id, summary, conflict_type, severity, details)
219
+ values (p_thread_id, p_task_id, v_actor_agent_id, p_summary, coalesce(p_conflict_type, 'other'), coalesce(p_severity, 'medium'), coalesce(p_details, '{}'::jsonb))
220
+ returning id into v_conflict_id;
221
+
222
+ -- v_event_sequence stays NULL for a thread-level conflict (no task) — fixes the same latent
223
+ -- "record not assigned yet" bug on the no-task path.
224
+ if p_task_id is not null then
225
+ select event_sequence into v_event_sequence
226
+ from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'conflict_raised', jsonb_build_object('conflict_id', v_conflict_id));
227
+ end if;
228
+
229
+ return query
230
+ select c.id, c.status, v_event_sequence, c.created_at, c.updated_at
231
+ from agent_board.conflicts c
232
+ where c.id = v_conflict_id;
233
+ end;
234
+ $function$;
235
+ revoke all on function agent_board.raise_conflict(uuid, uuid, text, text, text, uuid, jsonb) from public, anon;
236
+ grant execute on function agent_board.raise_conflict(uuid, uuid, text, text, text, uuid, jsonb) to authenticated, service_role;
237
+
238
+ -- 4. raise_escalation -------------------------------------------------------
239
+ create or replace function agent_board.raise_escalation(
240
+ p_thread_id uuid,
241
+ p_raised_by_agent_id uuid,
242
+ p_summary text,
243
+ p_escalation_type text default 'other',
244
+ p_severity text default 'medium',
245
+ p_task_id uuid default null,
246
+ p_assigned_agent_id uuid default null,
247
+ p_details jsonb default '{}'::jsonb
248
+ )
249
+ returns table(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
250
+ language plpgsql
251
+ security definer
252
+ set search_path to 'agent_board', 'public'
253
+ as $function$
254
+ declare
255
+ v_escalation_id uuid;
256
+ v_event_sequence bigint;
257
+ v_actor_agent_id uuid;
258
+ begin
259
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
260
+
261
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
262
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
263
+ end if;
264
+ -- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
265
+ if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
266
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
267
+ end if;
268
+
269
+ insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
270
+ values (p_thread_id, p_task_id, v_actor_agent_id, p_assigned_agent_id, p_summary, coalesce(p_escalation_type, 'other'), coalesce(p_severity, 'medium'), coalesce(p_details, '{}'::jsonb))
271
+ returning id into v_escalation_id;
272
+
273
+ -- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
274
+ -- "record not assigned yet" bug on the no-task path.
275
+ if p_task_id is not null then
276
+ select event_sequence into v_event_sequence
277
+ from agent_board.append_task_event(p_task_id, v_actor_agent_id, 'escalated', jsonb_build_object('escalation_id', v_escalation_id));
278
+ end if;
279
+
280
+ return query
281
+ select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
282
+ from agent_board.escalations e
283
+ where e.id = v_escalation_id;
284
+ end;
285
+ $function$;
286
+ revoke all on function agent_board.raise_escalation(uuid, uuid, text, text, text, uuid, uuid, jsonb) from public, anon;
287
+ grant execute on function agent_board.raise_escalation(uuid, uuid, text, text, text, uuid, uuid, jsonb) to authenticated, service_role;
@@ -0,0 +1,118 @@
1
+ -- OBSGATE-3 (#125) — gate task-lifecycle mutation so a board observer cannot mutate a
2
+ -- board-thread task, WITHOUT over-blocking a legitimate assignee/creator.
3
+ --
4
+ -- #123 (OBSGATE-2) gated content CREATION (create_task/add_artifact/raise_conflict/
5
+ -- raise_escalation). But task-lifecycle STATE mutation is a separate door: append_task_event
6
+ -- (the task_events chokepoint — authenticated + MCP-exposed) authors a task_events row carrying
7
+ -- the task's thread_id AND flips task status/assignment, gating ONLY on
8
+ -- _current_agent_can_access_task. A board observer could call it (and its callers acquire_claim /
9
+ -- resolve_conflict / resolve_escalation, which all append through it) on a board-thread task.
10
+ --
11
+ -- A blanket board-send-role gate is WRONG here: a board-thread task can be assigned to a
12
+ -- NON-board-member, who legitimately needs to work it (verified: such an assignee has
13
+ -- can_access_task=true but can_post_to_thread=false). So the rule is assignee-aware
14
+ -- (maintainer decision — assignee + creator carve-out):
15
+ --
16
+ -- allow mutation of a task iff:
17
+ -- service_role (operator; also the signed_* path)
18
+ -- OR _current_agent_can_post_to_thread(thread) (non-observer board member; or any
19
+ -- participant on a board-less thread)
20
+ -- OR current_agent_id() = task.assigned_agent_id (the assignee — even a non-member/observer)
21
+ -- OR current_agent_id() = task.created_by_agent_id (the creator)
22
+ -- deny otherwise — i.e. a board observer meddling with a task it is not a party to.
23
+ --
24
+ -- Enforced at the append_task_event CHOKEPOINT, so acquire_claim / resolve_conflict /
25
+ -- resolve_escalation are closed transitively and atomically (they mutate their own table then
26
+ -- append; a denied append rolls the whole call back). create_task does NOT use append_task_event
27
+ -- (it inserts task_events directly) and is already #123-gated, so it is unaffected. The #123
28
+ -- creation RPCs that DO append (add_artifact/raise_conflict/raise_escalation/delegate_task) have
29
+ -- already passed their own gate, so the mutate-task predicate is a consistent no-op for them.
30
+ --
31
+ -- The predicate is derived from the SESSION (current_agent_id / _is_service_role), never the
32
+ -- caller-supplied p_actor_agent_id. Additive, forward-only. schema_version stays 8.
33
+
34
+ -- 1. predicate --------------------------------------------------------------
35
+ create or replace function agent_board._current_agent_can_mutate_task(p_task_id uuid)
36
+ returns boolean
37
+ language sql
38
+ stable
39
+ security definer
40
+ set search_path = ''
41
+ as $$
42
+ select agent_board._is_service_role()
43
+ or exists (
44
+ select 1
45
+ from agent_board.tasks t
46
+ where t.id = p_task_id
47
+ and (
48
+ agent_board._current_agent_can_post_to_thread(t.thread_id)
49
+ or agent_board.current_agent_id() = t.assigned_agent_id
50
+ or agent_board.current_agent_id() = t.created_by_agent_id
51
+ )
52
+ )
53
+ $$;
54
+ comment on function agent_board._current_agent_can_mutate_task(uuid) is
55
+ 'OBSGATE-3 (#125): may the current agent mutate this task (append events / change status)? service_role, or a non-observer board sender on the task''s thread (board-less participant via the access fallback), or the task''s assignee, or its creator. A board observer that is not a party to the task is denied. Session-derived; internal-only, PUBLIC EXECUTE revoked.';
56
+ revoke all on function agent_board._current_agent_can_mutate_task(uuid) from public, anon, authenticated;
57
+
58
+ -- 2. gate the append_task_event chokepoint ----------------------------------
59
+ -- CREATE OR REPLACE with the SAME body plus the mutate-task gate after the access check; grants
60
+ -- re-asserted. The task row is already locked FOR UPDATE above, so the predicate's re-select is
61
+ -- consistent within the transaction.
62
+ create or replace function agent_board.append_task_event(
63
+ p_task_id uuid,
64
+ p_actor_agent_id uuid,
65
+ p_event_type text,
66
+ p_payload jsonb default '{}'::jsonb,
67
+ p_status text default null,
68
+ p_assigned_agent_id uuid default null,
69
+ p_completed_at timestamp with time zone default null
70
+ )
71
+ returns table(task_id uuid, thread_id uuid, event_id uuid, event_sequence bigint, task_status text, assigned_agent_id uuid, updated_at timestamp with time zone, completed_at timestamp with time zone)
72
+ language plpgsql
73
+ security definer
74
+ set search_path to 'agent_board', 'public'
75
+ as $function$
76
+ declare
77
+ v_thread_id uuid;
78
+ v_sequence bigint;
79
+ v_event_id uuid;
80
+ v_actor_agent_id uuid;
81
+ begin
82
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
83
+
84
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id for update;
85
+ if v_thread_id is null then
86
+ raise exception 'task not found' using errcode = 'P0002';
87
+ end if;
88
+ if not agent_board._current_agent_can_access_task(p_task_id) then
89
+ raise exception 'task not accessible to current agent' using errcode = '42501';
90
+ end if;
91
+ -- OBSGATE-3 (#125): mutating a board-thread task requires a non-observer board role, or being
92
+ -- the task's assignee/creator. Closes acquire_claim/resolve_conflict/resolve_escalation
93
+ -- transitively; the assignee/creator carve-out preserves a legitimate non-member worker.
94
+ if not agent_board._current_agent_can_mutate_task(p_task_id) then
95
+ raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
96
+ using errcode = '42501';
97
+ end if;
98
+
99
+ select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
100
+ insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload)
101
+ values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb))
102
+ returning id into v_event_id;
103
+
104
+ update agent_board.tasks
105
+ set status = coalesce(p_status, tasks.status),
106
+ assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
107
+ completed_at = coalesce(p_completed_at, tasks.completed_at),
108
+ updated_at = now()
109
+ where id = p_task_id;
110
+
111
+ return query
112
+ select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
113
+ from agent_board.tasks t
114
+ where t.id = p_task_id;
115
+ end;
116
+ $function$;
117
+ revoke all on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) from public, anon;
118
+ grant execute on function agent_board.append_task_event(uuid, uuid, text, jsonb, text, uuid, timestamp with time zone) to authenticated, service_role;
@@ -0,0 +1,250 @@
1
+ -- OBSGATE-4 (#127) — close the task-resource claim squat + the task-less resolve authz gap.
2
+ --
3
+ -- Found by the #126 (OBSGATE-3) panel. Two remaining task-lifecycle-adjacent doors that skip
4
+ -- the append_task_event chokepoint gated in #125:
5
+ --
6
+ -- (a) CLAIM SQUAT (DoS). acquire_claim on resource_kind='task' with resource_key = a task's id
7
+ -- performs NO task authz when p_task_id is null — verified: ANY authenticated agent (not
8
+ -- only a board observer, and even with no task/thread param) can acquire the claim and, via
9
+ -- the global active-resource uniqueness, block the legitimate claimant; release_claim is
10
+ -- ownership-only so the squat is sticky. Fix: a claim on a TASK resource requires ACCESS +
11
+ -- MUTATE rights (the same composition append_task_event uses) on the task named by
12
+ -- RESOURCE_KEY — the locked task the uniqueness index contends on, NOT p_task_id (whose
13
+ -- association is gated separately by the append below). This closes the squat for every
14
+ -- caller (a squat must key on the victim task's uuid to collide, and that uuid now gates on
15
+ -- access+mutate rights). Non-'task' resource kinds (file/machine/...) are a separate
16
+ -- coordination-authz concern, intentionally out of scope.
17
+ --
18
+ -- (b) TASK-LESS resolve_conflict / resolve_escalation. For a conflict/escalation WITH a task,
19
+ -- the append_task_event('status_changed') already applies the #125 mutate-task gate
20
+ -- (assignee-aware). For a TASK-LESS one, no append runs, so it gated only on
21
+ -- _current_agent_can_access_thread — an authz asymmetry that was fail-closed only by a
22
+ -- latent 55000 "record v_event is not assigned yet" bug (v_event referenced unconditionally
23
+ -- while assigned only on the task branch). Fix: gate the task-less path on the board posting
24
+ -- rule AND repair the v_event bug (nullable sequence, NULL when no task) so fixing the bug
25
+ -- can't silently open the path.
26
+ --
27
+ -- release_claim / renew_claim stay ownership-only (maintainer decision): the acquire gate closes
28
+ -- the squat at its source, so a demoted member retaining its own lease until TTL is a benign edge.
29
+ --
30
+ -- All gates are session-derived. Additive, forward-only. schema_version stays 8.
31
+
32
+ -- 1. acquire_claim: a task-resource claim requires task-mutate rights ---------
33
+ create or replace function agent_board.acquire_claim(
34
+ p_agent_id uuid,
35
+ p_resource_kind text,
36
+ p_resource_key text,
37
+ p_lease_expires_at timestamp with time zone,
38
+ p_task_id uuid default null,
39
+ p_thread_id uuid default null,
40
+ p_metadata jsonb default '{}'::jsonb
41
+ )
42
+ returns table(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamp with time zone, released_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone)
43
+ language plpgsql
44
+ security definer
45
+ set search_path to 'agent_board', 'public'
46
+ as $function$
47
+ declare
48
+ v_thread_id uuid;
49
+ v_claim_id uuid;
50
+ v_actor_agent_id uuid;
51
+ begin
52
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
53
+
54
+ if nullif(trim(p_resource_key), '') is null then
55
+ raise exception 'resource key is required' using errcode = '22023';
56
+ end if;
57
+
58
+ if p_task_id is not null then
59
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
60
+ if v_thread_id is null then
61
+ raise exception 'task not found' using errcode = 'P0002';
62
+ end if;
63
+ if not agent_board._current_agent_can_access_task(p_task_id) then
64
+ raise exception 'task not accessible to current agent' using errcode = '42501';
65
+ end if;
66
+ else
67
+ v_thread_id := p_thread_id;
68
+ end if;
69
+
70
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
71
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
72
+ end if;
73
+
74
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
75
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
76
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
77
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
78
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
79
+ -- with a uuid-keyed legitimate claim.
80
+ --
81
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
82
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
83
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
84
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
85
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
86
+ -- authorization a task-BOUND acquire already gets through append_task_event.
87
+ if p_resource_kind = 'task'
88
+ and p_resource_key ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then
89
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
90
+ raise exception 'task not accessible to current agent' using errcode = '42501';
91
+ end if;
92
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
93
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
94
+ using errcode = '42501';
95
+ end if;
96
+ end if;
97
+
98
+ update agent_board.claims c
99
+ set status = 'expired',
100
+ updated_at = now()
101
+ where c.resource_kind = p_resource_kind
102
+ and c.resource_key = p_resource_key
103
+ and c.status = 'active'
104
+ and c.released_at is null
105
+ and c.lease_expires_at <= now();
106
+
107
+ if exists (
108
+ select 1 from agent_board.claims
109
+ where claims.resource_kind = p_resource_kind
110
+ and claims.resource_key = p_resource_key
111
+ and claims.status = 'active'
112
+ and claims.released_at is null
113
+ and claims.lease_expires_at > now()
114
+ ) then
115
+ raise exception 'conflicting active claim' using errcode = '23505';
116
+ end if;
117
+
118
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
119
+ values (p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at, coalesce(p_metadata, '{}'::jsonb))
120
+ returning id into v_claim_id;
121
+
122
+ if p_task_id is not null then
123
+ perform agent_board.append_task_event(p_task_id, v_actor_agent_id, 'claimed', jsonb_build_object('claim_id', v_claim_id, 'resource_kind', p_resource_kind, 'resource_key', p_resource_key));
124
+ end if;
125
+
126
+ return query
127
+ select c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at
128
+ from agent_board.claims c
129
+ where c.id = v_claim_id;
130
+ end;
131
+ $function$;
132
+ revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
133
+ grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
134
+
135
+ -- 2. resolve_conflict: gate the task-less path + fix the v_event 55000 --------
136
+ create or replace function agent_board.resolve_conflict(
137
+ p_conflict_id uuid,
138
+ p_resolved_by_agent_id uuid,
139
+ p_details jsonb default '{}'::jsonb
140
+ )
141
+ returns table(conflict_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
142
+ language plpgsql
143
+ security definer
144
+ set search_path to 'agent_board', 'public'
145
+ as $function$
146
+ declare
147
+ v_conflict agent_board.conflicts%rowtype;
148
+ v_event_sequence bigint;
149
+ v_actor_agent_id uuid;
150
+ begin
151
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
152
+
153
+ select *
154
+ into v_conflict
155
+ from agent_board.conflicts c
156
+ where c.id = p_conflict_id
157
+ and c.status <> 'resolved';
158
+ if v_conflict.id is null then
159
+ raise exception 'conflict not found' using errcode = 'P0002';
160
+ end if;
161
+ if not agent_board._current_agent_can_access_thread(v_conflict.thread_id) then
162
+ raise exception 'conflict not accessible to current agent' using errcode = '42501';
163
+ end if;
164
+ -- OBSGATE-4 (#127): a task-bound resolve is gated by append_task_event (#125 mutate-task,
165
+ -- assignee-aware). A TASK-LESS conflict has no such chokepoint, so gate it on the board
166
+ -- posting rule here (a board observer may not resolve).
167
+ if v_conflict.task_id is null
168
+ and not agent_board._current_agent_can_post_to_thread(v_conflict.thread_id) then
169
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
170
+ end if;
171
+
172
+ update agent_board.conflicts
173
+ set status = 'resolved',
174
+ resolved_by_agent_id = v_actor_agent_id,
175
+ resolved_at = now(),
176
+ details = details || coalesce(p_details, '{}'::jsonb),
177
+ updated_at = now()
178
+ where id = p_conflict_id
179
+ returning * into v_conflict;
180
+
181
+ -- v_event_sequence stays NULL for a task-less conflict (fixes the latent "record not assigned"
182
+ -- bug; the unconditional v_event.event_sequence reference raised 55000 on this path).
183
+ if v_conflict.task_id is not null then
184
+ select event_sequence into v_event_sequence
185
+ from agent_board.append_task_event(v_conflict.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'conflict_resolved', 'conflict_id', p_conflict_id));
186
+ end if;
187
+
188
+ return query
189
+ select v_conflict.id, v_conflict.status, v_conflict.resolved_at, v_conflict.updated_at, v_event_sequence;
190
+ end;
191
+ $function$;
192
+ revoke all on function agent_board.resolve_conflict(uuid, uuid, jsonb) from public, anon;
193
+ grant execute on function agent_board.resolve_conflict(uuid, uuid, jsonb) to authenticated, service_role;
194
+
195
+ -- 3. resolve_escalation: same task-less gate + v_event fix -------------------
196
+ create or replace function agent_board.resolve_escalation(
197
+ p_escalation_id uuid,
198
+ p_resolved_by_agent_id uuid,
199
+ p_details jsonb default '{}'::jsonb
200
+ )
201
+ returns table(escalation_id uuid, status text, resolved_at timestamp with time zone, updated_at timestamp with time zone, task_event_sequence bigint)
202
+ language plpgsql
203
+ security definer
204
+ set search_path to 'agent_board', 'public'
205
+ as $function$
206
+ declare
207
+ v_escalation agent_board.escalations%rowtype;
208
+ v_event_sequence bigint;
209
+ v_actor_agent_id uuid;
210
+ begin
211
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_resolved_by_agent_id, 'p_resolved_by_agent_id');
212
+
213
+ select *
214
+ into v_escalation
215
+ from agent_board.escalations e
216
+ where e.id = p_escalation_id
217
+ and e.status <> 'resolved';
218
+ if v_escalation.id is null then
219
+ raise exception 'escalation not found' using errcode = 'P0002';
220
+ end if;
221
+ if not agent_board._current_agent_can_access_thread(v_escalation.thread_id) then
222
+ raise exception 'escalation not accessible to current agent' using errcode = '42501';
223
+ end if;
224
+ -- OBSGATE-4 (#127): task-less escalation gated on the board posting rule (task-bound is gated
225
+ -- by the append below).
226
+ if v_escalation.task_id is null
227
+ and not agent_board._current_agent_can_post_to_thread(v_escalation.thread_id) then
228
+ raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
229
+ end if;
230
+
231
+ update agent_board.escalations
232
+ set status = 'resolved',
233
+ resolved_by_agent_id = v_actor_agent_id,
234
+ resolved_at = now(),
235
+ details = details || coalesce(p_details, '{}'::jsonb),
236
+ updated_at = now()
237
+ where id = p_escalation_id
238
+ returning * into v_escalation;
239
+
240
+ if v_escalation.task_id is not null then
241
+ select event_sequence into v_event_sequence
242
+ from agent_board.append_task_event(v_escalation.task_id, v_actor_agent_id, 'status_changed', jsonb_build_object('kind', 'escalation_resolved', 'escalation_id', p_escalation_id));
243
+ end if;
244
+
245
+ return query
246
+ select v_escalation.id, v_escalation.status, v_escalation.resolved_at, v_escalation.updated_at, v_event_sequence;
247
+ end;
248
+ $function$;
249
+ revoke all on function agent_board.resolve_escalation(uuid, uuid, jsonb) from public, anon;
250
+ grant execute on function agent_board.resolve_escalation(uuid, uuid, jsonb) to authenticated, service_role;