@consiliency/agent-board-schema 1.57.0 → 1.59.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.
- package/fixtures/already-applied.json +8 -0
- package/migrations/20261008000100_agent_board_hosted_delegates.sql +3152 -0
- package/migrations/20261009000100_agent_board_hosted_followups.sql +665 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +9 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +13 -0
- package/tests/agent_board_consent_emission.test.sql +4 -2
- package/tests/agent_board_delegate_role_class.test.sql +736 -0
- package/tests/agent_board_federated_identity_injective_slug.test.sql +5 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +71 -14
- package/tests/agent_board_fk_lock_edges.test.sql +9 -0
- package/tests/agent_board_hosted_delegates.test.sql +1278 -0
- package/tests/agent_board_ledger_authority.test.sql +34 -5
- package/tests/agent_board_signer_lifecycle.test.sql +3 -0
- package/tests/agent_board_write_enforcement_class.test.sql +8 -3
- package/tests/catalog/function_execute_privileges.json +269 -2
- package/tests/catalog/p_metadata_parameters.json +44 -0
- package/tests/isolation/expected/hosted_delegate_add_tombstone_race.out +105 -0
- package/tests/isolation/expected/hosted_delegate_exchange_agent_revoke_race.out +66 -0
- package/tests/isolation/expected/hosted_delegate_exchange_race.out +93 -0
- package/tests/isolation/expected/hosted_delegate_first_exchange_race.out +129 -0
- package/tests/isolation/expected/hosted_delegate_owner_provisioning_race.out +91 -0
- package/tests/isolation/expected/hosted_delegate_owner_revoke_race.out +137 -0
- package/tests/isolation/expected/hosted_delegate_revoke_race.out +87 -0
- package/tests/isolation/expected/hosted_delegate_tombstone_revoke_race.out +170 -0
- package/tests/isolation/expected/hosted_delegate_withdraw_evict_race.out +235 -0
- package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +102 -0
- package/tests/isolation/specs/hosted_delegate_exchange_agent_revoke_race.spec +92 -0
- package/tests/isolation/specs/hosted_delegate_exchange_race.spec +64 -0
- package/tests/isolation/specs/hosted_delegate_first_exchange_race.spec +72 -0
- package/tests/isolation/specs/hosted_delegate_owner_provisioning_race.spec +73 -0
- package/tests/isolation/specs/hosted_delegate_owner_revoke_race.spec +93 -0
- package/tests/isolation/specs/hosted_delegate_revoke_race.spec +59 -0
- package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +158 -0
- package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +145 -0
|
@@ -0,0 +1,3152 @@
|
|
|
1
|
+
-- HOSTED (v11) SL-2: hosted board delegates -- the delegate database role, delegate rows and
|
|
2
|
+
-- instances, the exchange and tombstone RPCs, add_delegate_to_board, the delegate read
|
|
3
|
+
-- predicates and policies, and the addressing refusals. schema_version stays 8.
|
|
4
|
+
--
|
|
5
|
+
-- THE CONTRACT is the "Hosted board MCP server and delegates (HOSTED)" section of
|
|
6
|
+
-- specs/agent-client-contract.md, accepted by the owner on 2026-10-01 (EC-HOSTED-1,
|
|
7
|
+
-- plans/evidence/hosted/contract-acceptance.md). Every rule below cites it by its decision
|
|
8
|
+
-- number (D1-D16) or section. Where this file and the contract differ, the contract wins and
|
|
9
|
+
-- the difference is a defect here.
|
|
10
|
+
--
|
|
11
|
+
-- Sources read, each at its LATEST definition (found from the catalog of a replay of this
|
|
12
|
+
-- tree, pg_get_functiondef, never from a migration file alone -- several are uppercase):
|
|
13
|
+
-- * current_agent_id -- 20260920000100
|
|
14
|
+
-- * _upsert_board_membership_state -- 20260917000100
|
|
15
|
+
-- * _emit_membership_event -- 20260807000100
|
|
16
|
+
-- * _board_membership_events_provenance_guard, _membership_consent_present,
|
|
17
|
+
-- _membership_consent_kind, board_membership_events -- 20260806000100
|
|
18
|
+
-- * update_board_membership -- 20260813000100
|
|
19
|
+
-- * post_message -- 20260814000100
|
|
20
|
+
-- * grant_board_role, create_board, bootstrap_board_owner_memberships,
|
|
21
|
+
-- _fedsession_autojoin_default_board, _redeem_unbounded_invite_for_agent,
|
|
22
|
+
-- create_task, _append_task_event, delegate_task, raise_escalation,
|
|
23
|
+
-- _upsert_delivery_recipient, _upsert_inbox_item, _resolve_addressing_recipient_rows,
|
|
24
|
+
-- _addressing_agent_ids_or_raise, get_bundle_compatibility -- their latest definitions,
|
|
25
|
+
-- each redefined below VERBATIM from the catalog plus the marked HOSTED lines.
|
|
26
|
+
-- * _federated_identity_slug, enroll_federated_human -- 20260916000100 (the owner uid).
|
|
27
|
+
-- * _ensure_agent_auth_user -- 20260512000000 (redefined in section 5, fix lane:
|
|
28
|
+
-- the miss path serialised per slug).
|
|
29
|
+
-- * revoke_human_subtree, revoke_agent -- read for the exchange's owner-liveness lock order;
|
|
30
|
+
-- not redefined.
|
|
31
|
+
--
|
|
32
|
+
-- WHAT IS DELIBERATELY NOT HERE: the Edge Functions (SL-4), the strict audience helper (SL-3),
|
|
33
|
+
-- the hosted handler (SL-6). Rate limiting is in the exchange Edge Function, before this RPC
|
|
34
|
+
-- (D9), because a rate bump inside a refused RPC would roll back with it.
|
|
35
|
+
--
|
|
36
|
+
-- REDEFINITIONS AND ACLs. Every redefined function is `create or replace`, which keeps its ACL
|
|
37
|
+
-- and COMMENT (AGENTS.md: only DROP discards them). No function is dropped here. Every NEW
|
|
38
|
+
-- function has its EXECUTE stated below: revoked from PUBLIC (agent_board_pubexec_class), and
|
|
39
|
+
-- granted to exactly the role the contract names.
|
|
40
|
+
|
|
41
|
+
-- =============================================================================
|
|
42
|
+
-- 1. The delegate database role (IF-0-HOSTED-5, §The delegate database role)
|
|
43
|
+
-- =============================================================================
|
|
44
|
+
-- Roles are CLUSTER-level, so two databases on one cluster share it: create it only if
|
|
45
|
+
-- absent, and grant it to `authenticator` only if that role exists and is not already a
|
|
46
|
+
-- member. Plain PostgreSQL, the pgTAP replay and an adopter without Supabase roles therefore
|
|
47
|
+
-- apply cleanly, and a second database on the same cluster skips both steps. The migration
|
|
48
|
+
-- role needs CREATEROLE the first time the role is created on a cluster (measured, contract).
|
|
49
|
+
--
|
|
50
|
+
-- NOLOGIN, NOINHERIT, no superuser, no BYPASSRLS, a member of NO role (never `authenticated`,
|
|
51
|
+
-- which would inherit every authenticated grant). PostgREST switches to it from the board
|
|
52
|
+
-- credential's `role` claim.
|
|
53
|
+
do $role$
|
|
54
|
+
begin
|
|
55
|
+
if not exists (select 1 from pg_catalog.pg_roles where rolname = 'agent_board_delegate') then
|
|
56
|
+
create role agent_board_delegate nologin noinherit nosuperuser nobypassrls nocreatedb nocreaterole;
|
|
57
|
+
end if;
|
|
58
|
+
if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticator')
|
|
59
|
+
and not pg_catalog.pg_has_role('authenticator', 'agent_board_delegate', 'MEMBER') then
|
|
60
|
+
grant agent_board_delegate to authenticator;
|
|
61
|
+
end if;
|
|
62
|
+
end
|
|
63
|
+
$role$;
|
|
64
|
+
|
|
65
|
+
grant usage on schema agent_board to agent_board_delegate;
|
|
66
|
+
|
|
67
|
+
-- =============================================================================
|
|
68
|
+
-- 2. The delegate fact (D11, §Delegate rows and instances)
|
|
69
|
+
-- =============================================================================
|
|
70
|
+
-- A separate boolean, not a new agent_kind: a new enum value cannot be used in the
|
|
71
|
+
-- transaction that adds it, and kind is coupled to agent_type by a trigger and a CHECK. A
|
|
72
|
+
-- delegate row keeps kind = 'agent' and agent_type = 'agent'.
|
|
73
|
+
alter table agent_board.agents
|
|
74
|
+
add column if not exists is_delegate boolean not null default false;
|
|
75
|
+
|
|
76
|
+
-- Every existing row is a non-delegate. The default does the backfill; this asserts it.
|
|
77
|
+
do $backfill$
|
|
78
|
+
begin
|
|
79
|
+
if exists (select 1 from agent_board.agents where is_delegate) then
|
|
80
|
+
raise exception 'HOSTED backfill: an existing agents row has is_delegate = true';
|
|
81
|
+
end if;
|
|
82
|
+
end
|
|
83
|
+
$backfill$;
|
|
84
|
+
|
|
85
|
+
comment on column agent_board.agents.is_delegate is
|
|
86
|
+
'HOSTED (D11): the delegate fact. True only for a hosted delegate instance''s agent, set by exchange_delegate_credential''s first-use insert and never changed afterwards (trg_agents_is_delegate_immutable). Not granted to authenticated or agent_board_delegate.';
|
|
87
|
+
|
|
88
|
+
-- It never changes on an existing row.
|
|
89
|
+
create or replace function agent_board._agents_is_delegate_immutable()
|
|
90
|
+
returns trigger
|
|
91
|
+
language plpgsql
|
|
92
|
+
set search_path = ''
|
|
93
|
+
as $fn$
|
|
94
|
+
begin
|
|
95
|
+
if new.is_delegate is distinct from old.is_delegate then
|
|
96
|
+
raise exception 'agents.is_delegate never changes on an existing row'
|
|
97
|
+
using errcode = '23514';
|
|
98
|
+
end if;
|
|
99
|
+
return new;
|
|
100
|
+
end;
|
|
101
|
+
$fn$;
|
|
102
|
+
|
|
103
|
+
revoke all on function agent_board._agents_is_delegate_immutable() from public;
|
|
104
|
+
|
|
105
|
+
comment on function agent_board._agents_is_delegate_immutable() is
|
|
106
|
+
'HOSTED (D11): trigger function refusing any UPDATE that changes agents.is_delegate. A delegate never becomes a keyed agent and a keyed agent never becomes a delegate. EXECUTE revoked from PUBLIC; a trigger function needs none at fire time.';
|
|
107
|
+
|
|
108
|
+
drop trigger if exists trg_agents_is_delegate_immutable on agent_board.agents;
|
|
109
|
+
create trigger trg_agents_is_delegate_immutable
|
|
110
|
+
before update of is_delegate on agent_board.agents
|
|
111
|
+
for each row execute function agent_board._agents_is_delegate_immutable();
|
|
112
|
+
|
|
113
|
+
-- =============================================================================
|
|
114
|
+
-- 3. delegate_instances and the credential columns (IF-0-HOSTED-6)
|
|
115
|
+
-- =============================================================================
|
|
116
|
+
create table if not exists agent_board.delegate_instances (
|
|
117
|
+
instance_id text primary key
|
|
118
|
+
check (instance_id ~ '^[A-Za-z0-9_-]{16,128}$'),
|
|
119
|
+
-- The auth user id of the owner's human principal, derived exactly as
|
|
120
|
+
-- enroll_federated_human derives it (never the raw Portal sub). Never changes.
|
|
121
|
+
owner_user_id uuid not null,
|
|
122
|
+
-- Null until the first exchange; then never changes.
|
|
123
|
+
agent_id uuid unique references agent_board.agents(id),
|
|
124
|
+
client_id text check (client_id is null or char_length(client_id) between 1 and 512),
|
|
125
|
+
grant_id text check (grant_id is null or grant_id ~ '^[A-Za-z0-9_-]{16,128}$'),
|
|
126
|
+
generation bigint check (generation is null or generation between 1 and 9007199254740991),
|
|
127
|
+
-- The canonical slot URL, or null for the base URL (D8).
|
|
128
|
+
slot_resource text,
|
|
129
|
+
client_change_generation bigint
|
|
130
|
+
check (client_change_generation is null or client_change_generation between 1 and 9007199254740991),
|
|
131
|
+
tombstoned_at timestamptz,
|
|
132
|
+
tombstone_reason text
|
|
133
|
+
check (tombstone_reason is null
|
|
134
|
+
or tombstone_reason in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin')),
|
|
135
|
+
created_at timestamptz not null default now(),
|
|
136
|
+
updated_at timestamptz not null default now(),
|
|
137
|
+
constraint delegate_instances_tombstone_shape
|
|
138
|
+
check ((tombstoned_at is null) = (tombstone_reason is null))
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
-- D8: a slot belongs to one instance.
|
|
142
|
+
create unique index if not exists idx_agent_board_delegate_instances_slot_unique
|
|
143
|
+
on agent_board.delegate_instances (slot_resource)
|
|
144
|
+
where slot_resource is not null;
|
|
145
|
+
|
|
146
|
+
-- D13: the per-owner live-instance count.
|
|
147
|
+
create index if not exists idx_agent_board_delegate_instances_owner_live
|
|
148
|
+
on agent_board.delegate_instances (owner_user_id)
|
|
149
|
+
where tombstoned_at is null;
|
|
150
|
+
|
|
151
|
+
alter table agent_board.delegate_instances enable row level security;
|
|
152
|
+
revoke all on table agent_board.delegate_instances from public, anon, authenticated, service_role, agent_board_delegate;
|
|
153
|
+
-- READ for service_role only, so the operator check (scripts/check-delegate-tombstones.mjs,
|
|
154
|
+
-- SL-4) can list which instances the board has tombstoned. Never a write: the two RPCs are the
|
|
155
|
+
-- only writers. service_role has BYPASSRLS, so no policy is needed.
|
|
156
|
+
grant select on table agent_board.delegate_instances to service_role;
|
|
157
|
+
|
|
158
|
+
comment on table agent_board.delegate_instances is
|
|
159
|
+
'HOSTED (IF-0-HOSTED-6): one row per hosted delegate instance, keyed by the Portal''s instance_id. Written ONLY by exchange_delegate_credential and tombstone_delegate_instance (both service_role-only SECURITY DEFINER). RLS on with zero policies; SELECT for service_role only (the operator check), no other API-role grant. A tombstone (tombstoned_at) is set once and never cleared: the exchange refuses the id forever.';
|
|
160
|
+
|
|
161
|
+
alter table agent_board.agent_credentials
|
|
162
|
+
add column if not exists grant_id text,
|
|
163
|
+
add column if not exists generation bigint;
|
|
164
|
+
|
|
165
|
+
comment on column agent_board.agent_credentials.grant_id is
|
|
166
|
+
'HOSTED (IF-0-HOSTED-6): for a hosted delegate''s credential, the Portal grant it was issued under. Null for every other credential.';
|
|
167
|
+
comment on column agent_board.agent_credentials.generation is
|
|
168
|
+
'HOSTED (IF-0-HOSTED-6): for a hosted delegate''s credential, the grant generation it was issued at. A higher generation''s exchange revokes every lower one. Null for every other credential.';
|
|
169
|
+
|
|
170
|
+
-- =============================================================================
|
|
171
|
+
-- 4. The allow set (§The allow set) -- ONE SQL-visible list
|
|
172
|
+
-- =============================================================================
|
|
173
|
+
-- The class tests read this table; nothing else is the list. Section 13 GRANTS FROM it, so a
|
|
174
|
+
-- grant cannot exist without a row here unless someone adds it by hand, which the class
|
|
175
|
+
-- assertion (agent_board_delegate_role_class.test.sql) names.
|
|
176
|
+
-- kind : rpc | ambient_rpc | predicate | read_view | read_table
|
|
177
|
+
-- name : the function or relation name, unqualified (schema agent_board)
|
|
178
|
+
-- signature : for a function, name(oidvectortypes(proargtypes)); for a relation, the name
|
|
179
|
+
create table if not exists agent_board.delegate_allowed_operations (
|
|
180
|
+
kind text not null check (kind in ('rpc', 'ambient_rpc', 'predicate', 'read_view', 'read_table')),
|
|
181
|
+
name text not null,
|
|
182
|
+
signature text not null,
|
|
183
|
+
reason text not null check (char_length(btrim(reason)) > 0),
|
|
184
|
+
primary key (kind, signature)
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
alter table agent_board.delegate_allowed_operations enable row level security;
|
|
188
|
+
revoke all on table agent_board.delegate_allowed_operations from public, anon, authenticated, service_role, agent_board_delegate;
|
|
189
|
+
|
|
190
|
+
comment on table agent_board.delegate_allowed_operations is
|
|
191
|
+
'HOSTED (IF-0-HOSTED-5): the delegate allow set, the ONE list of what agent_board_delegate may execute (rpc, ambient_rpc, predicate) and select (read_view, read_table). The migration grants from it; agent_board_delegate_role_class asserts has_function_privilege / has_table_privilege iff listed. Widening it is an additive contract change. RLS on, no API-role grants.';
|
|
192
|
+
|
|
193
|
+
insert into agent_board.delegate_allowed_operations(kind, name, signature, reason) values
|
|
194
|
+
('rpc', 'post_board_message',
|
|
195
|
+
'post_board_message(uuid, uuid, uuid, text, text, text, text, text, jsonb)',
|
|
196
|
+
'post to a board by id (tools post_board_message, post_board)'),
|
|
197
|
+
('rpc', 'post_board_message',
|
|
198
|
+
'post_board_message(uuid, uuid, text, text, text, text, text, text, text, jsonb)',
|
|
199
|
+
'the by-slug overload; both overloads must agree, or the boundary test refuses the name'),
|
|
200
|
+
('rpc', 'post_message',
|
|
201
|
+
'post_message(uuid, uuid, text, text, uuid, jsonb)',
|
|
202
|
+
'reply in a thread (tool post_message)'),
|
|
203
|
+
('rpc', 'create_or_find_thread',
|
|
204
|
+
'create_or_find_thread(uuid, text, text, text, jsonb)',
|
|
205
|
+
'the thread a board post needs (tools create_or_find_thread, post_board)'),
|
|
206
|
+
('ambient_rpc', 'get_schema_version',
|
|
207
|
+
'get_schema_version()',
|
|
208
|
+
'the schema preflight state() runs before every client operation'),
|
|
209
|
+
('predicate', 'current_agent_id',
|
|
210
|
+
'current_agent_id()',
|
|
211
|
+
'invoked by the delegate predicates and the board_memberships delegate policy'),
|
|
212
|
+
('predicate', '_delegate_visible_board_ids',
|
|
213
|
+
'_delegate_visible_board_ids()',
|
|
214
|
+
'D14: the boards the caller is joined or subscribed on, computed once per query'),
|
|
215
|
+
('predicate', '_delegate_visible_agent',
|
|
216
|
+
'_delegate_visible_agent(uuid)',
|
|
217
|
+
'invoked by the delegate agents policy'),
|
|
218
|
+
('read_view', 'v_agent_dashboard', 'v_agent_dashboard',
|
|
219
|
+
'ambient: resolveActingAgentId reads it on every session refresh'),
|
|
220
|
+
('read_view', 'v_board_feed', 'v_board_feed', 'read the board feed'),
|
|
221
|
+
('read_view', 'v_thread_feed', 'v_thread_feed', 'read a thread'),
|
|
222
|
+
('read_view', 'v_agent_inbox', 'v_agent_inbox', 'read its own inbox'),
|
|
223
|
+
('read_view', 'v_board_memberships', 'v_board_memberships', 'read its own memberships'),
|
|
224
|
+
('read_table', 'boards', 'boards', 'read closure: v_board_feed, v_agent_inbox, v_board_memberships'),
|
|
225
|
+
('read_table', 'board_memberships', 'board_memberships', 'read closure: v_board_memberships'),
|
|
226
|
+
('read_table', 'agents', 'agents', 'read closure: every view (the eight authenticated columns only)'),
|
|
227
|
+
('read_table', 'messages', 'messages', 'read closure: v_board_feed, v_thread_feed'),
|
|
228
|
+
('read_table', 'delivery_recipients', 'delivery_recipients', 'read closure: v_board_feed'),
|
|
229
|
+
('read_table', 'inbox_items', 'inbox_items', 'read closure: v_board_feed, v_agent_inbox, v_agent_dashboard'),
|
|
230
|
+
('read_table', 'tasks', 'tasks', 'read closure: v_agent_dashboard'),
|
|
231
|
+
('read_table', 'task_events', 'task_events', 'read closure: v_agent_dashboard'),
|
|
232
|
+
('read_table', 'claims', 'claims', 'read closure: v_agent_dashboard'),
|
|
233
|
+
('read_table', 'escalations', 'escalations', 'read closure: v_agent_dashboard'),
|
|
234
|
+
('read_table', 'signature_evidence', 'signature_evidence', 'read closure: v_thread_feed, v_agent_dashboard'),
|
|
235
|
+
('read_table', 'presence', 'presence', 'read closure: v_agent_dashboard (left join; policy is false)'),
|
|
236
|
+
('read_table', 'agent_signing_keys', 'agent_signing_keys', 'read closure: v_thread_feed, v_agent_dashboard (left joins; policy is false)')
|
|
237
|
+
on conflict do nothing;
|
|
238
|
+
|
|
239
|
+
-- =============================================================================
|
|
240
|
+
-- 5. Identity helpers
|
|
241
|
+
-- =============================================================================
|
|
242
|
+
-- One auth user per instance: 'delegate-' || the first 32 hex characters of sha256 over the
|
|
243
|
+
-- netstring <len>:<iss>:<len>:<instance_id>, the _federated_identity_slug construction with a
|
|
244
|
+
-- different prefix. Injective, and never equal to a human's 'fed-human-' handle.
|
|
245
|
+
create or replace function agent_board._delegate_instance_slug(p_issuer text, p_instance_id text)
|
|
246
|
+
returns text
|
|
247
|
+
language plpgsql
|
|
248
|
+
immutable
|
|
249
|
+
set search_path = ''
|
|
250
|
+
as $fn$
|
|
251
|
+
begin
|
|
252
|
+
if p_issuer is null or p_issuer = '' or p_instance_id is null or p_instance_id = '' then
|
|
253
|
+
raise exception 'delegate instance slug requires a non-empty issuer and instance id'
|
|
254
|
+
using errcode = '22023';
|
|
255
|
+
end if;
|
|
256
|
+
return 'delegate-' || substr(
|
|
257
|
+
encode(
|
|
258
|
+
extensions.digest(
|
|
259
|
+
convert_to(
|
|
260
|
+
length(p_issuer)::text || ':' || p_issuer || ':' ||
|
|
261
|
+
length(p_instance_id)::text || ':' || p_instance_id,
|
|
262
|
+
'UTF8'),
|
|
263
|
+
'sha256'),
|
|
264
|
+
'hex'),
|
|
265
|
+
1, 32);
|
|
266
|
+
end;
|
|
267
|
+
$fn$;
|
|
268
|
+
|
|
269
|
+
revoke all on function agent_board._delegate_instance_slug(text, text) from public;
|
|
270
|
+
|
|
271
|
+
comment on function agent_board._delegate_instance_slug(text, text) is
|
|
272
|
+
'HOSTED: the handle and auth-user slug of a hosted delegate instance: ''delegate-'' || first 32 hex of sha256(netstring(iss, instance_id)). Injective; never a fed-human- handle. EXECUTE to no role; definers reach it as owner.';
|
|
273
|
+
|
|
274
|
+
-- ONE AUTH USER PER SLUG, for every caller. _ensure_agent_auth_user (latest definition
|
|
275
|
+
-- 20260512000000, unchanged since) looked up by email and then inserted, so two transactions
|
|
276
|
+
-- provisioning one slug at once each created an auth user: measured for two first exchanges of
|
|
277
|
+
-- one owner (hosted_delegate_first_exchange_race, SL-2), and the same shape exists for every
|
|
278
|
+
-- one of its callers, found from the catalog (pg_proc.prosrc), not from memory:
|
|
279
|
+
-- provision_agent, mint_delegated_child, enroll_federated_human and its
|
|
280
|
+
-- _enroll_federated_human_after_revocation_gate, enroll_federated_identity and
|
|
281
|
+
-- _enroll_federated_identity_once, onboard_federated_fleet_agent, exchange_delegate_credential
|
|
282
|
+
-- and tombstone_delegate_instance. The exchange's owner advisory lock covered only the last
|
|
283
|
+
-- two, so a first human enroll racing a first delegate exchange of the same (iss, sub) still
|
|
284
|
+
-- produced two owners (hosted_delegate_owner_provisioning_race). The class is closed here,
|
|
285
|
+
-- once, for all of them.
|
|
286
|
+
--
|
|
287
|
+
-- LOCK ON MISS, not before the lookup. A transaction-scoped advisory lock keyed by the slug is
|
|
288
|
+
-- taken only when the first lookup finds no user; the lookup is then repeated (a new statement,
|
|
289
|
+
-- so under READ COMMITTED a new snapshot that sees a user committed while this waited) before
|
|
290
|
+
-- inserting. Taking it before every lookup would invert against callers that already hold the
|
|
291
|
+
-- user's auth.users row FOR UPDATE and then reach this function again for the SAME slug
|
|
292
|
+
-- (enroll_federated_identity locks the owner's row and then calls
|
|
293
|
+
-- _enroll_federated_identity_once, which provisions the owner slug again), while the exchange
|
|
294
|
+
-- holds the slug lock and then wants that row (its own FOR KEY SHARE, and the agents FK). On a
|
|
295
|
+
-- hit nothing is locked, and a user that exists cannot be in anyone's miss path, so a holder of
|
|
296
|
+
-- the row never waits here. The one wait this adds is a second provisioner of a slug whose
|
|
297
|
+
-- first provisioner has not committed -- where a unique index on email, if the auth schema has
|
|
298
|
+
-- one, already waits today.
|
|
299
|
+
--
|
|
300
|
+
-- create or replace keeps the ACL ({postgres=X, service_role=X}) and the (absent) COMMENT; the
|
|
301
|
+
-- revoke and grant below restate the ACL so this file says what it is.
|
|
302
|
+
create or replace function agent_board._ensure_agent_auth_user(p_slug text)
|
|
303
|
+
returns uuid
|
|
304
|
+
language plpgsql
|
|
305
|
+
security definer
|
|
306
|
+
set search_path = ''
|
|
307
|
+
as $function$
|
|
308
|
+
declare
|
|
309
|
+
v_email text := format('agent+%s@agent-board.local', p_slug);
|
|
310
|
+
v_user_id uuid;
|
|
311
|
+
begin
|
|
312
|
+
select u.id
|
|
313
|
+
into v_user_id
|
|
314
|
+
from auth.users u
|
|
315
|
+
where lower(u.email) = v_email
|
|
316
|
+
limit 1;
|
|
317
|
+
|
|
318
|
+
if v_user_id is not null then
|
|
319
|
+
return v_user_id;
|
|
320
|
+
end if;
|
|
321
|
+
|
|
322
|
+
-- HOSTED: the miss path is serialised per slug, and the lookup repeated under the lock.
|
|
323
|
+
perform pg_catalog.pg_advisory_xact_lock(
|
|
324
|
+
pg_catalog.hashtextextended('agent_board.ensure_agent_auth_user:' || v_email, 0));
|
|
325
|
+
|
|
326
|
+
select u.id
|
|
327
|
+
into v_user_id
|
|
328
|
+
from auth.users u
|
|
329
|
+
where lower(u.email) = v_email
|
|
330
|
+
limit 1;
|
|
331
|
+
|
|
332
|
+
if v_user_id is not null then
|
|
333
|
+
return v_user_id;
|
|
334
|
+
end if;
|
|
335
|
+
|
|
336
|
+
v_user_id := gen_random_uuid();
|
|
337
|
+
|
|
338
|
+
insert into auth.users (
|
|
339
|
+
id,
|
|
340
|
+
instance_id,
|
|
341
|
+
email,
|
|
342
|
+
email_confirmed_at,
|
|
343
|
+
encrypted_password,
|
|
344
|
+
created_at,
|
|
345
|
+
updated_at,
|
|
346
|
+
raw_app_meta_data,
|
|
347
|
+
raw_user_meta_data,
|
|
348
|
+
aud,
|
|
349
|
+
role
|
|
350
|
+
)
|
|
351
|
+
values (
|
|
352
|
+
v_user_id,
|
|
353
|
+
'00000000-0000-0000-0000-000000000000',
|
|
354
|
+
v_email,
|
|
355
|
+
now(),
|
|
356
|
+
'',
|
|
357
|
+
now(),
|
|
358
|
+
now(),
|
|
359
|
+
jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
|
|
360
|
+
jsonb_build_object('slug', p_slug, 'issuer', 'agent_board.provision_agent'),
|
|
361
|
+
'authenticated',
|
|
362
|
+
'authenticated'
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
insert into auth.identities (
|
|
366
|
+
id,
|
|
367
|
+
user_id,
|
|
368
|
+
provider_id,
|
|
369
|
+
provider,
|
|
370
|
+
identity_data,
|
|
371
|
+
last_sign_in_at,
|
|
372
|
+
created_at,
|
|
373
|
+
updated_at
|
|
374
|
+
)
|
|
375
|
+
values (
|
|
376
|
+
gen_random_uuid(),
|
|
377
|
+
v_user_id,
|
|
378
|
+
p_slug,
|
|
379
|
+
'agent_board',
|
|
380
|
+
jsonb_build_object('sub', p_slug, 'slug', p_slug),
|
|
381
|
+
now(),
|
|
382
|
+
now(),
|
|
383
|
+
now()
|
|
384
|
+
)
|
|
385
|
+
on conflict do nothing;
|
|
386
|
+
|
|
387
|
+
return v_user_id;
|
|
388
|
+
end;
|
|
389
|
+
$function$;
|
|
390
|
+
|
|
391
|
+
revoke all on function agent_board._ensure_agent_auth_user(text) from public, anon, authenticated;
|
|
392
|
+
grant execute on function agent_board._ensure_agent_auth_user(text) to service_role;
|
|
393
|
+
|
|
394
|
+
-- The delegate's principal: the agent whose auth user owns the subject's instance. NULL for a
|
|
395
|
+
-- non-delegate. A definer because delegate_instances has RLS on and no grants; EXECUTE goes to
|
|
396
|
+
-- no role (the guard trigger runs it in its definer caller's context, as owner).
|
|
397
|
+
create or replace function agent_board._delegate_principal_agent_id(p_agent_id uuid)
|
|
398
|
+
returns uuid
|
|
399
|
+
language sql
|
|
400
|
+
stable
|
|
401
|
+
security definer
|
|
402
|
+
set search_path = ''
|
|
403
|
+
as $fn$
|
|
404
|
+
select owner_agent.id
|
|
405
|
+
from agent_board.delegate_instances di
|
|
406
|
+
join agent_board.agents d on d.id = di.agent_id and d.is_delegate
|
|
407
|
+
join agent_board.agents owner_agent on owner_agent.auth_user_id = di.owner_user_id
|
|
408
|
+
where di.agent_id = p_agent_id
|
|
409
|
+
limit 1
|
|
410
|
+
$fn$;
|
|
411
|
+
|
|
412
|
+
revoke all on function agent_board._delegate_principal_agent_id(uuid) from public, anon, authenticated, service_role;
|
|
413
|
+
|
|
414
|
+
comment on function agent_board._delegate_principal_agent_id(uuid) is
|
|
415
|
+
'HOSTED (D2, D15): the agent id of a hosted delegate''s principal -- the agent whose auth_user_id is the delegate instance''s owner_user_id -- or NULL for a non-delegate. Read by the emitter, the provenance guard, the membership sink, update_board_membership and post_message. EXECUTE to no role.';
|
|
416
|
+
|
|
417
|
+
-- The placement / role-writer class refusal, for every direct board_memberships writer that
|
|
418
|
+
-- bypasses the sink (§What stays refused for a delegate). One shared helper so the class
|
|
419
|
+
-- assertion has a single schema-qualified marker to look for.
|
|
420
|
+
create or replace function agent_board._refuse_delegate_membership_target(p_agent_id uuid, p_operation text)
|
|
421
|
+
returns void
|
|
422
|
+
language plpgsql
|
|
423
|
+
stable
|
|
424
|
+
security definer
|
|
425
|
+
set search_path = ''
|
|
426
|
+
as $fn$
|
|
427
|
+
begin
|
|
428
|
+
if exists (select 1 from agent_board.agents a where a.id = p_agent_id and a.is_delegate) then
|
|
429
|
+
raise exception '% cannot place or raise a hosted delegate; add_delegate_to_board is its only way onto a board',
|
|
430
|
+
coalesce(nullif(btrim(p_operation), ''), 'this operation')
|
|
431
|
+
using errcode = '42501';
|
|
432
|
+
end if;
|
|
433
|
+
end;
|
|
434
|
+
$fn$;
|
|
435
|
+
|
|
436
|
+
revoke all on function agent_board._refuse_delegate_membership_target(uuid, text) from public, anon, authenticated, service_role;
|
|
437
|
+
|
|
438
|
+
comment on function agent_board._refuse_delegate_membership_target(uuid, text) is
|
|
439
|
+
'HOSTED (placement and role-writer classes): raises 42501 when the target is a hosted delegate. Called by every direct board_memberships writer other than the sink (whose own rule is D15). is_delegate never changes, so the unlocked read has no TOCTOU. EXECUTE to no role.';
|
|
440
|
+
|
|
441
|
+
-- The D16 addressing refusal, for every writer of an addressee column.
|
|
442
|
+
create or replace function agent_board._refuse_delegate_addressee(p_agent_id uuid, p_board_id uuid, p_what text)
|
|
443
|
+
returns void
|
|
444
|
+
language plpgsql
|
|
445
|
+
stable
|
|
446
|
+
security definer
|
|
447
|
+
set search_path = ''
|
|
448
|
+
as $fn$
|
|
449
|
+
begin
|
|
450
|
+
if p_agent_id is null then
|
|
451
|
+
return;
|
|
452
|
+
end if;
|
|
453
|
+
if exists (select 1 from agent_board.agents a where a.id = p_agent_id and a.is_delegate)
|
|
454
|
+
and (
|
|
455
|
+
p_board_id is null
|
|
456
|
+
or not exists (
|
|
457
|
+
select 1
|
|
458
|
+
from agent_board.board_memberships bm
|
|
459
|
+
where bm.board_id = p_board_id
|
|
460
|
+
and bm.agent_id = p_agent_id
|
|
461
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
462
|
+
)
|
|
463
|
+
) then
|
|
464
|
+
raise exception 'a hosted delegate is addressed only through a board it is active on (%)',
|
|
465
|
+
coalesce(nullif(btrim(p_what), ''), 'addressing')
|
|
466
|
+
using errcode = '42501';
|
|
467
|
+
end if;
|
|
468
|
+
end;
|
|
469
|
+
$fn$;
|
|
470
|
+
|
|
471
|
+
revoke all on function agent_board._refuse_delegate_addressee(uuid, uuid, text) from public, anon, authenticated, service_role;
|
|
472
|
+
|
|
473
|
+
comment on function agent_board._refuse_delegate_addressee(uuid, uuid, text) is
|
|
474
|
+
'HOSTED (D16): raises 42501 when p_agent_id is a hosted delegate and p_board_id is null or a board it is not joined/subscribed on. Task and escalation assignment and task delegation pass a null board, so they are always refused for a delegate. Called by every writer of tasks.assigned_agent_id, escalations.assigned_agent_id, delegations.delegated_to_agent_id, delivery_recipients.recipient_agent_id and inbox_items.recipient_agent_id. EXECUTE to no role.';
|
|
475
|
+
|
|
476
|
+
-- =============================================================================
|
|
477
|
+
-- 6. The delegate read predicates (D14, §The read closure)
|
|
478
|
+
-- =============================================================================
|
|
479
|
+
-- Every delegate read requires the delegate's CURRENT active membership of the row's board,
|
|
480
|
+
-- and a row's board comes ONLY from delivery_recipients.board_id, never from the thread's
|
|
481
|
+
-- first-board-wins stamp. Authorship, recipiency, assignment and creation grant nothing.
|
|
482
|
+
-- SECURITY DEFINER (the caller sees only its own board_memberships rows), STABLE, EXECUTE to
|
|
483
|
+
-- agent_board_delegate only. Called directly, each answers only what selecting the rows already
|
|
484
|
+
-- answers.
|
|
485
|
+
-- THE BOARD SET, ONCE PER QUERY. The policies compare a row's board with this array through
|
|
486
|
+
-- an uncorrelated scalar subquery, `coalesce((select agent_board._delegate_visible_board_ids()),
|
|
487
|
+
-- '{}'::uuid[])`, which the planner evaluates ONCE per query (an InitPlan). The contract's first
|
|
488
|
+
-- form called a per-row definer predicate for every row, re-resolving current_agent_id() each
|
|
489
|
+
-- time: 7.7 s for one page of a 500-post board, against 234 ms for this form, with the same
|
|
490
|
+
-- meaning (plans/evidence/hosted/sl2-schema-evidence.md; the differential test is
|
|
491
|
+
-- agent_board_delegate_role_class.test.sql). Authorised by the team lead; SL-8 amends the
|
|
492
|
+
-- contract text.
|
|
493
|
+
create or replace function agent_board._delegate_visible_board_ids()
|
|
494
|
+
returns uuid[]
|
|
495
|
+
language sql
|
|
496
|
+
stable
|
|
497
|
+
security definer
|
|
498
|
+
set search_path = ''
|
|
499
|
+
as $fn$
|
|
500
|
+
select coalesce(array_agg(bm.board_id order by bm.board_id), '{}'::uuid[])
|
|
501
|
+
from agent_board.board_memberships bm
|
|
502
|
+
where bm.agent_id = agent_board.current_agent_id()
|
|
503
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
504
|
+
$fn$;
|
|
505
|
+
|
|
506
|
+
-- agents: the caller itself; a CURRENT co-member (joined/subscribed on a board the caller is
|
|
507
|
+
-- active on); or the author of a message already visible to the caller (v_board_feed and
|
|
508
|
+
-- v_thread_feed inner-join the author, so a departed author would otherwise drop the message).
|
|
509
|
+
create or replace function agent_board._delegate_visible_agent(p_agent_id uuid)
|
|
510
|
+
returns boolean
|
|
511
|
+
language sql
|
|
512
|
+
stable
|
|
513
|
+
security definer
|
|
514
|
+
set search_path = ''
|
|
515
|
+
as $fn$
|
|
516
|
+
with me as (select agent_board.current_agent_id() as id)
|
|
517
|
+
select exists (select 1 from me where me.id = p_agent_id)
|
|
518
|
+
or exists (
|
|
519
|
+
select 1
|
|
520
|
+
from me
|
|
521
|
+
join agent_board.board_memberships mine
|
|
522
|
+
on mine.agent_id = me.id
|
|
523
|
+
and mine.membership_state in ('joined', 'subscribed')
|
|
524
|
+
join agent_board.board_memberships other
|
|
525
|
+
on other.board_id = mine.board_id
|
|
526
|
+
and other.agent_id = p_agent_id
|
|
527
|
+
and other.membership_state in ('joined', 'subscribed')
|
|
528
|
+
)
|
|
529
|
+
or exists (
|
|
530
|
+
select 1
|
|
531
|
+
from me
|
|
532
|
+
join agent_board.board_memberships mine
|
|
533
|
+
on mine.agent_id = me.id
|
|
534
|
+
and mine.membership_state in ('joined', 'subscribed')
|
|
535
|
+
join agent_board.delivery_recipients dr
|
|
536
|
+
on dr.board_id = mine.board_id
|
|
537
|
+
join agent_board.messages m
|
|
538
|
+
on m.id = dr.source_message_id
|
|
539
|
+
where m.author_agent_id = p_agent_id
|
|
540
|
+
)
|
|
541
|
+
$fn$;
|
|
542
|
+
|
|
543
|
+
revoke all on function agent_board._delegate_visible_board_ids() from public, anon, authenticated, service_role;
|
|
544
|
+
revoke all on function agent_board._delegate_visible_agent(uuid) from public, anon, authenticated, service_role;
|
|
545
|
+
|
|
546
|
+
comment on function agent_board._delegate_visible_board_ids() is
|
|
547
|
+
'HOSTED (D14) delegate predicate: the boards the caller is joined or subscribed on, as an array the delegate policies read once per query through an uncorrelated subquery. EXECUTE to agent_board_delegate only (allow set, kind predicate).';
|
|
548
|
+
comment on function agent_board._delegate_visible_agent(uuid) is
|
|
549
|
+
'HOSTED (D14) delegate predicate for agents: the caller itself, a current co-member on a board the caller is active on, or the author of a message visible to the caller. EXECUTE to agent_board_delegate only (allow set, kind predicate).';
|
|
550
|
+
|
|
551
|
+
-- =============================================================================
|
|
552
|
+
-- 7. exchange_delegate_credential (IF-0-HOSTED-7, §The exchange; D8, D13)
|
|
553
|
+
-- =============================================================================
|
|
554
|
+
-- Called by the delegate-exchange Edge Function AFTER it has verified the Portal token (RS256,
|
|
555
|
+
-- iss, the strict audience helper, lifetime, token class, resource and instance claim) and
|
|
556
|
+
-- rate-limited per owner and per instance (D9). service_role only. Its arguments come from
|
|
557
|
+
-- that JWKS-verified token, not from any user: the enroll_federated_human trust shape.
|
|
558
|
+
--
|
|
559
|
+
-- p_claims, the shape this RPC accepts (frozen here for SL-4):
|
|
560
|
+
-- {
|
|
561
|
+
-- "iss": <the verified issuer>,
|
|
562
|
+
-- "sub": <the verified owner subject>,
|
|
563
|
+
-- "slot_resource": <the token's canonical resource when it is a SLOT, or null when it is
|
|
564
|
+
-- the base URL; the Edge Function has already refused anything else>,
|
|
565
|
+
-- "agent_board_delegate_instance": <the instance claim, verbatim>
|
|
566
|
+
-- }
|
|
567
|
+
-- Every refusal raises with the refusal CODE as the message (the Edge Function maps it to
|
|
568
|
+
-- `403 {"error": <code>}`): instance_revoked, owner_inactive, owner_mismatch, agent_inactive,
|
|
569
|
+
-- instance_cap, resource_mismatch, slot_in_use, generation_superseded, grant_conflict,
|
|
570
|
+
-- client_mismatch (errcode 42501), and claims_malformed (22023) for a shape the Edge Function
|
|
571
|
+
-- should already have refused.
|
|
572
|
+
create or replace function agent_board.exchange_delegate_credential(p_claims jsonb)
|
|
573
|
+
returns table(agent_id uuid, auth_user_id uuid, credential_id uuid, expires_at timestamptz)
|
|
574
|
+
language plpgsql
|
|
575
|
+
security definer
|
|
576
|
+
set search_path = ''
|
|
577
|
+
as $fn$
|
|
578
|
+
#variable_conflict use_column
|
|
579
|
+
declare
|
|
580
|
+
v_iss text;
|
|
581
|
+
v_sub text;
|
|
582
|
+
v_slot text;
|
|
583
|
+
v_claim jsonb;
|
|
584
|
+
v_instance_id text;
|
|
585
|
+
v_grant_id text;
|
|
586
|
+
v_generation bigint;
|
|
587
|
+
v_client_id text;
|
|
588
|
+
v_change jsonb;
|
|
589
|
+
v_change_gen bigint;
|
|
590
|
+
v_owner uuid;
|
|
591
|
+
v_owner_slug text;
|
|
592
|
+
v_row agent_board.delegate_instances%rowtype;
|
|
593
|
+
v_first boolean;
|
|
594
|
+
v_live integer;
|
|
595
|
+
v_slug text;
|
|
596
|
+
v_auth uuid;
|
|
597
|
+
v_agent uuid;
|
|
598
|
+
v_status text;
|
|
599
|
+
v_credential uuid;
|
|
600
|
+
v_expires timestamptz;
|
|
601
|
+
begin
|
|
602
|
+
if not agent_board._is_service_role() then
|
|
603
|
+
raise exception 'exchange_delegate_credential is service_role-only' using errcode = '42501';
|
|
604
|
+
end if;
|
|
605
|
+
|
|
606
|
+
-- SHAPE. The Edge Function refuses every malformed token first; this is the database's own
|
|
607
|
+
-- refusal of the same shape, so a caller that skips it cannot write a malformed instance.
|
|
608
|
+
if jsonb_typeof(p_claims) is distinct from 'object'
|
|
609
|
+
or jsonb_typeof(p_claims -> 'iss') is distinct from 'string'
|
|
610
|
+
or jsonb_typeof(p_claims -> 'sub') is distinct from 'string'
|
|
611
|
+
or coalesce(p_claims ->> 'iss', '') = ''
|
|
612
|
+
or coalesce(p_claims ->> 'sub', '') = ''
|
|
613
|
+
or jsonb_typeof(p_claims -> 'slot_resource') not in ('string', 'null')
|
|
614
|
+
or jsonb_typeof(p_claims -> 'agent_board_delegate_instance') is distinct from 'object' then
|
|
615
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
616
|
+
end if;
|
|
617
|
+
v_iss := p_claims ->> 'iss';
|
|
618
|
+
v_sub := p_claims ->> 'sub';
|
|
619
|
+
v_slot := p_claims ->> 'slot_resource';
|
|
620
|
+
v_claim := p_claims -> 'agent_board_delegate_instance';
|
|
621
|
+
|
|
622
|
+
-- The instance claim: exactly these seven fields, each of its type.
|
|
623
|
+
if (select array_agg(k order by k) from jsonb_object_keys(v_claim) k)
|
|
624
|
+
is distinct from array['client_change', 'client_id', 'generation', 'grant_id', 'instance_id', 'owner_sub', 'status']
|
|
625
|
+
or jsonb_typeof(v_claim -> 'instance_id') is distinct from 'string'
|
|
626
|
+
or (v_claim ->> 'instance_id') !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
627
|
+
or jsonb_typeof(v_claim -> 'grant_id') is distinct from 'string'
|
|
628
|
+
or (v_claim ->> 'grant_id') !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
629
|
+
or jsonb_typeof(v_claim -> 'generation') is distinct from 'number'
|
|
630
|
+
or (v_claim ->> 'generation') !~ '^[1-9][0-9]{0,15}$'
|
|
631
|
+
or jsonb_typeof(v_claim -> 'owner_sub') is distinct from 'string'
|
|
632
|
+
or (v_claim ->> 'owner_sub') is distinct from v_sub
|
|
633
|
+
or jsonb_typeof(v_claim -> 'client_id') is distinct from 'string'
|
|
634
|
+
or char_length(v_claim ->> 'client_id') not between 1 and 512
|
|
635
|
+
or jsonb_typeof(v_claim -> 'status') is distinct from 'string'
|
|
636
|
+
or (v_claim ->> 'status') not in ('active', 'revoked')
|
|
637
|
+
or jsonb_typeof(v_claim -> 'client_change') not in ('object', 'null') then
|
|
638
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
639
|
+
end if;
|
|
640
|
+
v_generation := (v_claim ->> 'generation')::bigint;
|
|
641
|
+
if v_generation > 9007199254740991 then
|
|
642
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
643
|
+
end if;
|
|
644
|
+
v_instance_id := v_claim ->> 'instance_id';
|
|
645
|
+
v_grant_id := v_claim ->> 'grant_id';
|
|
646
|
+
v_client_id := v_claim ->> 'client_id';
|
|
647
|
+
|
|
648
|
+
v_change := case when jsonb_typeof(v_claim -> 'client_change') = 'object' then v_claim -> 'client_change' end;
|
|
649
|
+
if v_change is not null then
|
|
650
|
+
if (select array_agg(k order by k) from jsonb_object_keys(v_change) k)
|
|
651
|
+
is distinct from array['confirmed_at', 'confirmed_generation', 'from_client_id']
|
|
652
|
+
or jsonb_typeof(v_change -> 'from_client_id') is distinct from 'string'
|
|
653
|
+
or jsonb_typeof(v_change -> 'confirmed_at') is distinct from 'number'
|
|
654
|
+
or (v_change ->> 'confirmed_at') !~ '^[0-9]{1,16}$'
|
|
655
|
+
or jsonb_typeof(v_change -> 'confirmed_generation') is distinct from 'number'
|
|
656
|
+
or (v_change ->> 'confirmed_generation') !~ '^[1-9][0-9]{0,15}$'
|
|
657
|
+
or (v_change ->> 'confirmed_generation')::bigint > 9007199254740991 then
|
|
658
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
659
|
+
end if;
|
|
660
|
+
v_change_gen := (v_change ->> 'confirmed_generation')::bigint;
|
|
661
|
+
end if;
|
|
662
|
+
|
|
663
|
+
if v_slot is not null and v_slot !~ '^https://[^/?#@[:space:]]+(/[^?#[:space:]]*)?/s/[A-Za-z0-9_-]{22,64}$' then
|
|
664
|
+
raise exception 'claims_malformed' using errcode = '22023';
|
|
665
|
+
end if;
|
|
666
|
+
|
|
667
|
+
-- A token the Portal itself reports revoked is refused, and changes nothing here: only a
|
|
668
|
+
-- Portal revocation (tombstone_delegate_instance) tombstones.
|
|
669
|
+
if v_claim ->> 'status' <> 'active' then
|
|
670
|
+
raise exception 'instance_revoked' using errcode = '42501';
|
|
671
|
+
end if;
|
|
672
|
+
|
|
673
|
+
-- THE OWNER LOCK, before anything else. A transaction-scoped advisory lock keyed by the
|
|
674
|
+
-- owner's identity serialises every exchange of one owner: it is what serialises D13's count
|
|
675
|
+
-- below, and tombstone_delegate_instance takes it too, in the same position, so the order is
|
|
676
|
+
-- always: owner lock, then the owner's auth.users row, then the instance row. It was first
|
|
677
|
+
-- added for the owner-provisioning race (two first exchanges each creating an auth user for
|
|
678
|
+
-- one owner, measured by hosted_delegate_first_exchange_race); that class is now closed for
|
|
679
|
+
-- every caller inside _ensure_agent_auth_user itself (section 5), which this lock alone could
|
|
680
|
+
-- not do: a first human enroll never takes it.
|
|
681
|
+
--
|
|
682
|
+
-- THE OWNER, derived exactly as enroll_federated_human derives the human's auth user, from
|
|
683
|
+
-- the verified issuer and subject. The raw sub is never stored or compared.
|
|
684
|
+
v_owner_slug := agent_board._federated_identity_slug(v_iss, v_sub);
|
|
685
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
|
|
686
|
+
v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
|
|
687
|
+
|
|
688
|
+
-- THE OWNER MUST BE LIVE (fix N2). enroll_federated_human refuses a durably revoked human
|
|
689
|
+
-- (fleet_human_revocations (iss, sub)) and a non-active human row, and so does this: a
|
|
690
|
+
-- revoked human whose Portal still signs tokens must not bring up a NEW delegate instance, and
|
|
691
|
+
-- a suspended owner's existing delegates must not keep exchanging. Refused owner_inactive,
|
|
692
|
+
-- before the instance row is touched (so it wins over every instance-level code, including a
|
|
693
|
+
-- tombstone), and nothing is tombstoned.
|
|
694
|
+
--
|
|
695
|
+
-- UNDER THE OWNER'S auth.users ROW LOCK, which revoke_human_subtree takes FOR UPDATE first and
|
|
696
|
+
-- holds while it writes the marker and revokes every agent it then lists. FOR KEY SHARE
|
|
697
|
+
-- conflicts with that and with nothing an ordinary auth.users update takes. Without it an
|
|
698
|
+
-- exchange reads the marker before the revocation commits, and the revocation's agent list
|
|
699
|
+
-- (read once, at its loop) misses the delegate this exchange creates: an active delegate of a
|
|
700
|
+
-- revoked human, or a fresh credential for a delegate the revocation just revoked
|
|
701
|
+
-- (hosted_delegate_owner_revoke_race). revoke_human_subtree never takes the owner advisory
|
|
702
|
+
-- lock or an instance row, so no cycle closes. revoke_agent (suspension) does not take this
|
|
703
|
+
-- row and does not touch delegate credentials; an exchange racing it is simply ordered before
|
|
704
|
+
-- it.
|
|
705
|
+
--
|
|
706
|
+
-- The owner's human row is the agents row whose auth_user_id is the owner uid
|
|
707
|
+
-- (idx_agent_board_agents_auth_user_id_unique: at most one), the row enroll_federated_human
|
|
708
|
+
-- creates. None is not a refusal: an owner the board has never enrolled as a human is allowed
|
|
709
|
+
-- here, as before (it cannot add a delegate to a board without a session of its own).
|
|
710
|
+
perform 1 from auth.users u where u.id = v_owner for key share;
|
|
711
|
+
if exists (
|
|
712
|
+
select 1 from agent_board.fleet_human_revocations r
|
|
713
|
+
where r.issuer = v_iss and r.subject = v_sub
|
|
714
|
+
)
|
|
715
|
+
or exists (
|
|
716
|
+
select 1 from agent_board.agents a
|
|
717
|
+
where a.auth_user_id = v_owner and a.status <> 'active'
|
|
718
|
+
) then
|
|
719
|
+
raise exception 'owner_inactive' using errcode = '42501';
|
|
720
|
+
end if;
|
|
721
|
+
|
|
722
|
+
-- INSERT-THEN-LOCK. A bare `select ... for update` locks nothing when the row does not yet
|
|
723
|
+
-- exist, so two first exchanges would both create an agent. Inserting first makes the
|
|
724
|
+
-- second block on the primary key until the first commits; both then lock the same row.
|
|
725
|
+
insert into agent_board.delegate_instances(instance_id, owner_user_id)
|
|
726
|
+
values (v_instance_id, v_owner)
|
|
727
|
+
on conflict (instance_id) do nothing;
|
|
728
|
+
|
|
729
|
+
select * into v_row
|
|
730
|
+
from agent_board.delegate_instances di
|
|
731
|
+
where di.instance_id = v_instance_id
|
|
732
|
+
for update;
|
|
733
|
+
|
|
734
|
+
-- A tombstone is permanent, whatever else the token says.
|
|
735
|
+
if v_row.tombstoned_at is not null then
|
|
736
|
+
raise exception 'instance_revoked' using errcode = '42501';
|
|
737
|
+
end if;
|
|
738
|
+
if v_row.owner_user_id is distinct from v_owner then
|
|
739
|
+
raise exception 'owner_mismatch' using errcode = '42501';
|
|
740
|
+
end if;
|
|
741
|
+
|
|
742
|
+
-- FIRST EXCHANGE is decided UNDER the lock, by whether the row has an agent yet -- never by
|
|
743
|
+
-- whether this transaction's insert happened: the loser of two concurrent first exchanges
|
|
744
|
+
-- finds the winner's agent after it blocks, and takes the ordinary path.
|
|
745
|
+
v_first := v_row.agent_id is null;
|
|
746
|
+
|
|
747
|
+
if not v_first then
|
|
748
|
+
select a.status into v_status from agent_board.agents a where a.id = v_row.agent_id;
|
|
749
|
+
if v_status is distinct from 'active' then
|
|
750
|
+
-- Revoked by revoke_agent_subtree or revoke_human_subtree, or suspended. Refused, and
|
|
751
|
+
-- NOT tombstoned: only a Portal revocation tombstones.
|
|
752
|
+
raise exception 'agent_inactive' using errcode = '42501';
|
|
753
|
+
end if;
|
|
754
|
+
else
|
|
755
|
+
-- D13: at most 20 live instances per owner. The owner lock taken at the top serialises two
|
|
756
|
+
-- first exchanges of DIFFERENT instances of one owner (their instance rows are different
|
|
757
|
+
-- rows), so the count cannot be passed twice.
|
|
758
|
+
select count(*) into v_live
|
|
759
|
+
from agent_board.delegate_instances di
|
|
760
|
+
where di.owner_user_id = v_owner
|
|
761
|
+
and di.tombstoned_at is null
|
|
762
|
+
and di.instance_id <> v_instance_id;
|
|
763
|
+
if v_live >= 20 then
|
|
764
|
+
raise exception 'instance_cap' using errcode = '42501';
|
|
765
|
+
end if;
|
|
766
|
+
end if;
|
|
767
|
+
|
|
768
|
+
-- THE RESOURCE (D8), S = the recorded slot_resource.
|
|
769
|
+
if not v_first then
|
|
770
|
+
if v_row.slot_resource is not null then
|
|
771
|
+
if v_slot is distinct from v_row.slot_resource then
|
|
772
|
+
raise exception 'resource_mismatch' using errcode = '42501';
|
|
773
|
+
end if;
|
|
774
|
+
elsif v_slot is not null and not (v_generation > v_row.generation) then
|
|
775
|
+
-- The one move, base URL -> slot, needs a new grant (a strictly higher generation).
|
|
776
|
+
raise exception 'resource_mismatch' using errcode = '42501';
|
|
777
|
+
end if;
|
|
778
|
+
end if;
|
|
779
|
+
|
|
780
|
+
-- THE CLIENT AND GENERATION, r = recorded generation, g = the token's, C = the recorded
|
|
781
|
+
-- client_change_generation.
|
|
782
|
+
if not v_first then
|
|
783
|
+
if v_generation < v_row.generation then
|
|
784
|
+
raise exception 'generation_superseded' using errcode = '42501';
|
|
785
|
+
elsif v_generation = v_row.generation then
|
|
786
|
+
if v_grant_id is distinct from v_row.grant_id then
|
|
787
|
+
raise exception 'grant_conflict' using errcode = '42501';
|
|
788
|
+
end if;
|
|
789
|
+
if v_client_id is distinct from v_row.client_id
|
|
790
|
+
or v_change_gen is distinct from v_row.client_change_generation then
|
|
791
|
+
raise exception 'client_mismatch' using errcode = '42501';
|
|
792
|
+
end if;
|
|
793
|
+
elsif v_client_id is distinct from v_row.client_id then
|
|
794
|
+
-- g > r from another client: only with the owner's confirmation of a client change made
|
|
795
|
+
-- AFTER the last binding the board saw (r < confirmed_generation <= g). from_client_id
|
|
796
|
+
-- is recorded for audit but not compared: the board may have missed an intermediate
|
|
797
|
+
-- reconnect.
|
|
798
|
+
if v_change_gen is null
|
|
799
|
+
or not (v_row.generation < v_change_gen and v_change_gen <= v_generation) then
|
|
800
|
+
raise exception 'client_mismatch' using errcode = '42501';
|
|
801
|
+
end if;
|
|
802
|
+
end if;
|
|
803
|
+
end if;
|
|
804
|
+
|
|
805
|
+
-- FIRST USE: the instance's own auth user and agents row. One auth user per instance.
|
|
806
|
+
if v_first then
|
|
807
|
+
v_slug := agent_board._delegate_instance_slug(v_iss, v_instance_id);
|
|
808
|
+
-- A second agent on this auth user is impossible: idx_agent_board_agents_auth_user_id_unique
|
|
809
|
+
-- refuses the insert (23505), and the whole exchange with it.
|
|
810
|
+
v_auth := agent_board._ensure_agent_auth_user(v_slug);
|
|
811
|
+
insert into agent_board.agents(
|
|
812
|
+
agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id, is_delegate, metadata)
|
|
813
|
+
values (
|
|
814
|
+
'agent', 'agent', v_slug, 'Hosted assistant ' || left(v_instance_id, 8), 'active',
|
|
815
|
+
v_auth, v_owner, true,
|
|
816
|
+
jsonb_build_object('hosted_delegate', jsonb_build_object('instance_id', v_instance_id)))
|
|
817
|
+
returning id into v_agent;
|
|
818
|
+
else
|
|
819
|
+
v_agent := v_row.agent_id;
|
|
820
|
+
select a.auth_user_id into v_auth from agent_board.agents a where a.id = v_agent;
|
|
821
|
+
end if;
|
|
822
|
+
|
|
823
|
+
begin
|
|
824
|
+
update agent_board.delegate_instances di
|
|
825
|
+
set agent_id = v_agent,
|
|
826
|
+
client_id = v_client_id,
|
|
827
|
+
grant_id = v_grant_id,
|
|
828
|
+
generation = v_generation,
|
|
829
|
+
slot_resource = coalesce(di.slot_resource, v_slot),
|
|
830
|
+
client_change_generation = v_change_gen,
|
|
831
|
+
updated_at = now()
|
|
832
|
+
where di.instance_id = v_instance_id;
|
|
833
|
+
exception when unique_violation then
|
|
834
|
+
-- D8: a slot belongs to one instance. Raising aborts the whole call, so nothing in this
|
|
835
|
+
-- transaction is kept.
|
|
836
|
+
raise exception 'slot_in_use' using errcode = '42501';
|
|
837
|
+
end;
|
|
838
|
+
|
|
839
|
+
-- A higher generation supersedes every lower one, in this transaction.
|
|
840
|
+
if not v_first and v_generation > v_row.generation then
|
|
841
|
+
update agent_board.agent_credentials ac
|
|
842
|
+
set revoked_at = now(),
|
|
843
|
+
updated_at = now(),
|
|
844
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
845
|
+
|| jsonb_build_object('revocationReason', 'generation_superseded', 'supersededByGeneration', v_generation)
|
|
846
|
+
where ac.agent_id = v_agent
|
|
847
|
+
and ac.revoked_at is null
|
|
848
|
+
and coalesce(ac.generation, 0) < v_generation;
|
|
849
|
+
end if;
|
|
850
|
+
|
|
851
|
+
-- The board credential row. No refresh token exists for it: the hash is random and never
|
|
852
|
+
-- disclosed, so the refresh path can never match it. expires_at <= now() + 900 s, and the
|
|
853
|
+
-- Edge Function mints exp = min(iat + 900, expires_at).
|
|
854
|
+
v_expires := now() + interval '900 seconds';
|
|
855
|
+
insert into agent_board.agent_credentials(
|
|
856
|
+
agent_id, refresh_token_hash, hash_version, credential_label, expires_at, metadata, grant_id, generation)
|
|
857
|
+
values (
|
|
858
|
+
v_agent,
|
|
859
|
+
extensions.digest(extensions.gen_random_bytes(32), 'sha256'),
|
|
860
|
+
1,
|
|
861
|
+
'hosted-delegate',
|
|
862
|
+
v_expires,
|
|
863
|
+
jsonb_build_object(
|
|
864
|
+
'source', 'exchange_delegate_credential',
|
|
865
|
+
'instance_id', v_instance_id,
|
|
866
|
+
'client_id', v_client_id,
|
|
867
|
+
'client_change', coalesce(v_change, 'null'::jsonb)),
|
|
868
|
+
v_grant_id,
|
|
869
|
+
v_generation)
|
|
870
|
+
returning id into v_credential;
|
|
871
|
+
|
|
872
|
+
return query select v_agent, v_auth, v_credential, v_expires;
|
|
873
|
+
end;
|
|
874
|
+
$fn$;
|
|
875
|
+
|
|
876
|
+
revoke all on function agent_board.exchange_delegate_credential(jsonb) from public, anon, authenticated;
|
|
877
|
+
grant execute on function agent_board.exchange_delegate_credential(jsonb) to service_role;
|
|
878
|
+
|
|
879
|
+
comment on function agent_board.exchange_delegate_credential(jsonb) is
|
|
880
|
+
'HOSTED (IF-0-HOSTED-7): turns a verified Portal delegate access token into a board credential row, in one transaction: under the owner lock and the owner''s auth.users row (FOR KEY SHARE), refuse a durably revoked or non-active owner (owner_inactive); insert-then-lock the instance; refuse a tombstone, another owner, an inactive agent, a 21st live instance (D13), a resource other than the recorded one (D8), a lower generation, an equal generation with another grant, and a client change without the owner''s confirmation; create the agent and auth user on first use; revoke lower generations; issue a credential with expires_at <= now()+900s. Returns (agent_id, auth_user_id, credential_id, expires_at). service_role only; called by the delegate-exchange Edge Function after it verified the token and rate-limited (D9).';
|
|
881
|
+
|
|
882
|
+
-- =============================================================================
|
|
883
|
+
-- 8. tombstone_delegate_instance (IF-0-HOSTED-8, D10)
|
|
884
|
+
-- =============================================================================
|
|
885
|
+
create or replace function agent_board.tombstone_delegate_instance(
|
|
886
|
+
p_instance_id text, p_issuer text, p_owner_sub text, p_reason text)
|
|
887
|
+
returns table(instance_id text, tombstoned_at timestamptz, first boolean)
|
|
888
|
+
language plpgsql
|
|
889
|
+
security definer
|
|
890
|
+
set search_path = ''
|
|
891
|
+
as $fn$
|
|
892
|
+
#variable_conflict use_column
|
|
893
|
+
declare
|
|
894
|
+
v_owner uuid;
|
|
895
|
+
v_owner_slug text;
|
|
896
|
+
v_inserted boolean;
|
|
897
|
+
v_row agent_board.delegate_instances%rowtype;
|
|
898
|
+
v_board uuid;
|
|
899
|
+
begin
|
|
900
|
+
if not agent_board._is_service_role() then
|
|
901
|
+
raise exception 'tombstone_delegate_instance is service_role-only' using errcode = '42501';
|
|
902
|
+
end if;
|
|
903
|
+
if p_instance_id is null or p_instance_id !~ '^[A-Za-z0-9_-]{16,128}$'
|
|
904
|
+
or coalesce(p_issuer, '') = '' or coalesce(p_owner_sub, '') = ''
|
|
905
|
+
or p_reason is null
|
|
906
|
+
or p_reason not in ('owner_revoked', 'owner_retired_after_upgrade', 'owner_account_closed', 'portal_admin') then
|
|
907
|
+
raise exception 'revocation_malformed' using errcode = '22023';
|
|
908
|
+
end if;
|
|
909
|
+
|
|
910
|
+
-- The owner lock, then the owner, exactly as the exchange takes and derives them (same key,
|
|
911
|
+
-- same position: before the instance row), so a revocation and an exchange of a new owner's
|
|
912
|
+
-- instance never provision two auth users for one owner.
|
|
913
|
+
v_owner_slug := agent_board._federated_identity_slug(p_issuer, p_owner_sub);
|
|
914
|
+
perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended('agent_board.delegate_owner:' || v_owner_slug, 0));
|
|
915
|
+
v_owner := agent_board._ensure_agent_auth_user(v_owner_slug);
|
|
916
|
+
|
|
917
|
+
-- A revocation BEFORE the first exchange creates the tombstoned row, so the first exchange
|
|
918
|
+
-- is refused. Insert-then-lock, as the exchange does, so a concurrent exchange serialises.
|
|
919
|
+
insert into agent_board.delegate_instances as di(instance_id, owner_user_id, tombstoned_at, tombstone_reason)
|
|
920
|
+
values (p_instance_id, v_owner, now(), p_reason)
|
|
921
|
+
on conflict (instance_id) do nothing
|
|
922
|
+
returning true into v_inserted;
|
|
923
|
+
|
|
924
|
+
if v_inserted then
|
|
925
|
+
return query select p_instance_id, now(), true;
|
|
926
|
+
return;
|
|
927
|
+
end if;
|
|
928
|
+
|
|
929
|
+
select * into v_row
|
|
930
|
+
from agent_board.delegate_instances di
|
|
931
|
+
where di.instance_id = p_instance_id
|
|
932
|
+
for update;
|
|
933
|
+
|
|
934
|
+
if v_row.owner_user_id is distinct from v_owner then
|
|
935
|
+
raise exception 'owner_mismatch' using errcode = '42501';
|
|
936
|
+
end if;
|
|
937
|
+
|
|
938
|
+
-- Idempotent: the key is the instance, and a tombstone is permanent.
|
|
939
|
+
if v_row.tombstoned_at is not null then
|
|
940
|
+
return query select v_row.instance_id, v_row.tombstoned_at, false;
|
|
941
|
+
return;
|
|
942
|
+
end if;
|
|
943
|
+
|
|
944
|
+
update agent_board.delegate_instances di
|
|
945
|
+
set tombstoned_at = now(),
|
|
946
|
+
tombstone_reason = p_reason,
|
|
947
|
+
updated_at = now()
|
|
948
|
+
where di.instance_id = p_instance_id;
|
|
949
|
+
|
|
950
|
+
if v_row.agent_id is not null then
|
|
951
|
+
update agent_board.agent_credentials ac
|
|
952
|
+
set revoked_at = coalesce(ac.revoked_at, now()),
|
|
953
|
+
updated_at = now(),
|
|
954
|
+
metadata = coalesce(ac.metadata, '{}'::jsonb)
|
|
955
|
+
|| jsonb_build_object('revocationReason', 'instance_tombstoned', 'tombstoneReason', p_reason)
|
|
956
|
+
where ac.agent_id = v_row.agent_id
|
|
957
|
+
and ac.revoked_at is null;
|
|
958
|
+
|
|
959
|
+
-- Every active membership ends through update_board_membership(..., 'left') under
|
|
960
|
+
-- service_role, so each ends with an append-only operator_removal event.
|
|
961
|
+
for v_board in
|
|
962
|
+
select bm.board_id
|
|
963
|
+
from agent_board.board_memberships bm
|
|
964
|
+
where bm.agent_id = v_row.agent_id
|
|
965
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
966
|
+
order by bm.board_id
|
|
967
|
+
loop
|
|
968
|
+
perform agent_board.update_board_membership(v_board, v_row.agent_id, 'left');
|
|
969
|
+
end loop;
|
|
970
|
+
|
|
971
|
+
update agent_board.agents a
|
|
972
|
+
set status = 'revoked', updated_at = now()
|
|
973
|
+
where a.id = v_row.agent_id
|
|
974
|
+
and a.status <> 'revoked';
|
|
975
|
+
end if;
|
|
976
|
+
|
|
977
|
+
return query select p_instance_id, now(), true;
|
|
978
|
+
end;
|
|
979
|
+
$fn$;
|
|
980
|
+
|
|
981
|
+
revoke all on function agent_board.tombstone_delegate_instance(text, text, text, text) from public, anon, authenticated;
|
|
982
|
+
grant execute on function agent_board.tombstone_delegate_instance(text, text, text, text) to service_role;
|
|
983
|
+
|
|
984
|
+
comment on function agent_board.tombstone_delegate_instance(text, text, text, text) is
|
|
985
|
+
'HOSTED (IF-0-HOSTED-8, D10): tombstones a hosted delegate instance, idempotently and permanently. Before the first exchange it creates the tombstoned row; otherwise, in one transaction, it revokes every credential, ends every active membership through update_board_membership(..., ''left'') (operator_removal events) and moves the agent to revoked. A revocation naming an existing instance of another owner raises owner_mismatch. Returns (instance_id, tombstoned_at, first). service_role only; called by the delegate-revoke Edge Function with the verified revocation JWT''s iss, owner_sub and reason.';
|
|
986
|
+
|
|
987
|
+
-- =============================================================================
|
|
988
|
+
-- 9. add_delegate_to_board (IF-0-HOSTED-12, A5; D2, D15)
|
|
989
|
+
-- =============================================================================
|
|
990
|
+
create or replace function agent_board.add_delegate_to_board(p_agent_id uuid, p_board_id uuid)
|
|
991
|
+
returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, role text)
|
|
992
|
+
language plpgsql
|
|
993
|
+
security definer
|
|
994
|
+
set search_path = ''
|
|
995
|
+
as $fn$
|
|
996
|
+
#variable_conflict use_column
|
|
997
|
+
declare
|
|
998
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
999
|
+
v_target agent_board.agents%rowtype;
|
|
1000
|
+
v_instance agent_board.delegate_instances%rowtype;
|
|
1001
|
+
v_m agent_board.board_memberships%rowtype;
|
|
1002
|
+
begin
|
|
1003
|
+
-- OWNER ONLY, from the session. A caller-supplied owner is never read.
|
|
1004
|
+
if v_actor is null then
|
|
1005
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
1006
|
+
end if;
|
|
1007
|
+
|
|
1008
|
+
select * into v_target from agent_board.agents a where a.id = p_agent_id;
|
|
1009
|
+
if not found or not v_target.is_delegate then
|
|
1010
|
+
raise exception 'add_delegate_to_board adds only a hosted delegate' using errcode = '42501';
|
|
1011
|
+
end if;
|
|
1012
|
+
|
|
1013
|
+
-- LOCK ORDER, each step before the next: the board advisory lock (this function row-locks both
|
|
1014
|
+
-- agents and board_memberships, so it is serialised against onboarding as grant_board_role is,
|
|
1015
|
+
-- agent_board_signer_lifecycle section 9, and like grant_board_role it takes the lock before
|
|
1016
|
+
-- any row lock); the owner's agents row (the fleet-ledger gate, FOR SHARE); the delegate's
|
|
1017
|
+
-- instance row (FOR SHARE, so a concurrent tombstone, which holds it FOR UPDATE, either commits
|
|
1018
|
+
-- first or waits for this placement); the owner's membership; the delegate's, in the sink.
|
|
1019
|
+
-- tombstone_delegate_instance takes its own owner-keyed advisory lock (a different key), then
|
|
1020
|
+
-- the instance row, and never the board advisory lock or the owner's agents row, so the
|
|
1021
|
+
-- instance row is the only lock the two share and no cycle closes between them.
|
|
1022
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
1023
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'add_delegate_to_board');
|
|
1024
|
+
|
|
1025
|
+
select * into v_instance
|
|
1026
|
+
from agent_board.delegate_instances di
|
|
1027
|
+
where di.agent_id = p_agent_id
|
|
1028
|
+
for share;
|
|
1029
|
+
if not found or v_instance.tombstoned_at is not null or v_target.status <> 'active' then
|
|
1030
|
+
raise exception 'the hosted delegate instance is not live' using errcode = '42501';
|
|
1031
|
+
end if;
|
|
1032
|
+
if v_instance.owner_user_id is distinct from (select a.auth_user_id from agent_board.agents a where a.id = v_actor) then
|
|
1033
|
+
raise exception 'only the delegate''s owner may add it to a board' using errcode = '42501';
|
|
1034
|
+
end if;
|
|
1035
|
+
|
|
1036
|
+
if not exists (select 1 from agent_board.boards b where b.id = p_board_id and b.status = 'active') then
|
|
1037
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
1038
|
+
end if;
|
|
1039
|
+
|
|
1040
|
+
-- D12 ordering: hold the owner's active membership row (shared) so a concurrent owner
|
|
1041
|
+
-- departure either commits first (and the authority check below fails) or waits for this
|
|
1042
|
+
-- placement and then cascades it.
|
|
1043
|
+
perform 1
|
|
1044
|
+
from agent_board.board_memberships bm
|
|
1045
|
+
where bm.board_id = p_board_id
|
|
1046
|
+
and bm.agent_id = v_actor
|
|
1047
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1048
|
+
for share;
|
|
1049
|
+
|
|
1050
|
+
-- UNDER THE OWNER'S OWN INVITE AUTHORITY: the gate create_invite_link applies before minting
|
|
1051
|
+
-- a member link, which places its redeemer straight into `joined`, as this does.
|
|
1052
|
+
if not agent_board._grant_within_ceiling(v_actor, p_board_id, 'member') then
|
|
1053
|
+
raise exception 'adding a delegate requires the owner''s own member-invite authority on this board'
|
|
1054
|
+
using errcode = '42501';
|
|
1055
|
+
end if;
|
|
1056
|
+
|
|
1057
|
+
-- R-LINK-7: a removed delegate stays removed (the set predicate every way back applies).
|
|
1058
|
+
if exists (
|
|
1059
|
+
select 1 from agent_board.board_memberships bm
|
|
1060
|
+
where bm.board_id = p_board_id and bm.agent_id = p_agent_id and bm.membership_state = 'removed'
|
|
1061
|
+
)
|
|
1062
|
+
and not exists (
|
|
1063
|
+
select 1 from agent_board.board_memberships bm
|
|
1064
|
+
where bm.board_id = p_board_id and bm.agent_id = p_agent_id and bm.membership_state in ('joined', 'subscribed')
|
|
1065
|
+
) then
|
|
1066
|
+
raise exception 'a removed delegate stays removed; a board admin must lift the removal' using errcode = '42501';
|
|
1067
|
+
end if;
|
|
1068
|
+
|
|
1069
|
+
-- D15: the ONE setter of the admission flag. The sink reads it, with the owner's session,
|
|
1070
|
+
-- and refuses a delegate placement without both. Cleared before returning.
|
|
1071
|
+
perform pg_catalog.set_config('agent_board.delegate_admission', p_agent_id::text, true);
|
|
1072
|
+
select * into v_m
|
|
1073
|
+
from agent_board._upsert_board_membership_state(p_board_id, p_agent_id, 'joined', v_actor);
|
|
1074
|
+
perform pg_catalog.set_config('agent_board.delegate_admission', '', true);
|
|
1075
|
+
|
|
1076
|
+
-- CONSENT (D2): the emitter recorded the placement as principal-authored, with the owner as
|
|
1077
|
+
-- actor. The consent predicate must agree, or nothing is kept.
|
|
1078
|
+
if not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
1079
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
1080
|
+
using errcode = '42501';
|
|
1081
|
+
end if;
|
|
1082
|
+
|
|
1083
|
+
return query select v_m.id, v_m.board_id, v_m.agent_id, v_m.membership_state, v_m.role;
|
|
1084
|
+
end;
|
|
1085
|
+
$fn$;
|
|
1086
|
+
|
|
1087
|
+
revoke all on function agent_board.add_delegate_to_board(uuid, uuid) from public, anon, service_role;
|
|
1088
|
+
grant execute on function agent_board.add_delegate_to_board(uuid, uuid) to authenticated;
|
|
1089
|
+
|
|
1090
|
+
comment on function agent_board.add_delegate_to_board(uuid, uuid) is
|
|
1091
|
+
'HOSTED (IF-0-HOSTED-12, A5): the ONLY way a hosted delegate joins a board. The caller (from the session) must be the instance''s owner, hold member-invite authority on the board (_grant_within_ceiling(caller, board, ''member'')) and not be fleet-revoked; the instance must be live; a removed delegate stays removed (R-LINK-7). Writes `joined` as member through the sink under the D15 admission flag, recorded principal-authored / principal_placement with the owner as actor, and requires _membership_consent_present. EXECUTE for authenticated.';
|
|
1092
|
+
|
|
1093
|
+
-- =============================================================================
|
|
1094
|
+
-- 10. principal-authored in the ledger (D2, §Joining a board and consent)
|
|
1095
|
+
-- =============================================================================
|
|
1096
|
+
-- The enum CHECK gains the value. The provenance/actor CHECK gains one arm, APPENDED after the
|
|
1097
|
+
-- existing three so their order is unchanged (agent_board_consent_substrate regex-pins
|
|
1098
|
+
-- target-authored.*operator-designated.*board-authored). Re-adding a CHECK validates every
|
|
1099
|
+
-- existing row, and none can be principal-authored yet.
|
|
1100
|
+
alter table agent_board.board_membership_events
|
|
1101
|
+
drop constraint if exists board_membership_events_provenance_check;
|
|
1102
|
+
alter table agent_board.board_membership_events
|
|
1103
|
+
add constraint board_membership_events_provenance_check
|
|
1104
|
+
check (provenance in ('board-authored', 'operator-designated', 'target-authored', 'principal-authored'));
|
|
1105
|
+
|
|
1106
|
+
alter table agent_board.board_membership_events
|
|
1107
|
+
drop constraint if exists board_membership_events_provenance_actor;
|
|
1108
|
+
alter table agent_board.board_membership_events
|
|
1109
|
+
add constraint board_membership_events_provenance_actor check (
|
|
1110
|
+
(provenance = 'target-authored' and actor_agent_id is not null and actor_agent_id = agent_id)
|
|
1111
|
+
or (provenance = 'operator-designated' and actor_agent_id is null)
|
|
1112
|
+
or (provenance = 'board-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
|
|
1113
|
+
or (provenance = 'principal-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
|
|
1114
|
+
);
|
|
1115
|
+
|
|
1116
|
+
-- =============================================================================
|
|
1117
|
+
-- 11. Redefinitions (each verbatim from the catalog plus its marked HOSTED lines)
|
|
1118
|
+
-- =============================================================================
|
|
1119
|
+
-- Order: the identity rule, the ledger (guard, emitter, readers), the sink and its callers,
|
|
1120
|
+
-- the direct writers, the addressing writers, attribution, the compatibility report.
|
|
1121
|
+
|
|
1122
|
+
CREATE OR REPLACE FUNCTION agent_board.current_agent_id()
|
|
1123
|
+
RETURNS uuid
|
|
1124
|
+
LANGUAGE sql
|
|
1125
|
+
STABLE SECURITY DEFINER
|
|
1126
|
+
SET search_path TO ''
|
|
1127
|
+
AS $function$
|
|
1128
|
+
with credential_claim as (
|
|
1129
|
+
-- #341: read BOTH sources. PostgREST sets the per-claim GUC; Realtime sets only the
|
|
1130
|
+
-- whole-JSON one. Reading a single source made the credential branch dead code for every
|
|
1131
|
+
-- Realtime subscriber. Mirrors auth.uid(), which has always coalesced for the same reason.
|
|
1132
|
+
select coalesce(
|
|
1133
|
+
nullif(current_setting('request.jwt.claim.agent_credential_id', true), ''),
|
|
1134
|
+
-- The OUTER nullif is not redundant, and its absence was a transport inconsistency
|
|
1135
|
+
-- caught by a test: the per-claim arm maps '' to absent, so without this the same
|
|
1136
|
+
-- logical claim ("" ) would be ABSENT on one transport and a malformed credential
|
|
1137
|
+
-- that DENIES on the other. Same input, different answer by transport, which is the
|
|
1138
|
+
-- exact bug class #341 is about. Empty means absent on both, matching what omission
|
|
1139
|
+
-- already permits.
|
|
1140
|
+
nullif(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'agent_credential_id', '')
|
|
1141
|
+
) as credential_id_text
|
|
1142
|
+
)
|
|
1143
|
+
select a.id
|
|
1144
|
+
from agent_board.agents a
|
|
1145
|
+
cross join credential_claim cc
|
|
1146
|
+
where a.auth_user_id = auth.uid()
|
|
1147
|
+
and a.status = 'active'
|
|
1148
|
+
and (
|
|
1149
|
+
-- HOSTED (IF-0-HOSTED-6): a hosted delegate NEVER takes the null-claim branch. Its
|
|
1150
|
+
-- credential claim is mandatory, so a delegate board token with the claim stripped
|
|
1151
|
+
-- resolves to NULL. Every other row behaves exactly as before.
|
|
1152
|
+
(cc.credential_id_text is null and not a.is_delegate)
|
|
1153
|
+
or (
|
|
1154
|
+
cc.credential_id_text ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
1155
|
+
and exists (
|
|
1156
|
+
select 1
|
|
1157
|
+
from agent_board.agent_credentials ac
|
|
1158
|
+
-- TEXT comparison, never a cast of the untrusted claim: a malformed value must DENY,
|
|
1159
|
+
-- not raise. `ac.id::text` is canonical lowercase, hence lower() on the claim.
|
|
1160
|
+
where ac.id::text = lower(cc.credential_id_text)
|
|
1161
|
+
and ac.agent_id = a.id
|
|
1162
|
+
and ac.revoked_at is null
|
|
1163
|
+
and (ac.expires_at is null or ac.expires_at > now())
|
|
1164
|
+
)
|
|
1165
|
+
)
|
|
1166
|
+
)
|
|
1167
|
+
limit 1;
|
|
1168
|
+
$function$;
|
|
1169
|
+
|
|
1170
|
+
CREATE OR REPLACE FUNCTION agent_board._board_membership_events_provenance_guard()
|
|
1171
|
+
RETURNS trigger
|
|
1172
|
+
LANGUAGE plpgsql
|
|
1173
|
+
AS $function$
|
|
1174
|
+
begin
|
|
1175
|
+
if NEW.provenance = 'target-authored'
|
|
1176
|
+
and NEW.actor_agent_id is distinct from NEW.agent_id then
|
|
1177
|
+
raise exception 'substrate CHECK refuses target-authored actor <> subject'
|
|
1178
|
+
using errcode = '23514';
|
|
1179
|
+
end if;
|
|
1180
|
+
if NEW.provenance = 'operator-designated'
|
|
1181
|
+
and NEW.actor_agent_id is not null then
|
|
1182
|
+
raise exception 'substrate CHECK refuses operator-designated non-null actor'
|
|
1183
|
+
using errcode = '23514';
|
|
1184
|
+
end if;
|
|
1185
|
+
if NEW.provenance = 'board-authored'
|
|
1186
|
+
and (NEW.actor_agent_id is null or NEW.actor_agent_id = NEW.agent_id) then
|
|
1187
|
+
raise exception 'substrate CHECK refuses board-authored actor = subject'
|
|
1188
|
+
using errcode = '23514';
|
|
1189
|
+
end if;
|
|
1190
|
+
-- HOSTED (D2): what the CHECK arm cannot say. The subject must have the delegate fact and
|
|
1191
|
+
-- the actor must be its principal. _delegate_principal_agent_id returns NULL for a
|
|
1192
|
+
-- non-delegate, so `is distinct from` refuses both a wrong actor and a non-delegate subject.
|
|
1193
|
+
if NEW.provenance = 'principal-authored'
|
|
1194
|
+
and (NEW.actor_agent_id is null
|
|
1195
|
+
or NEW.actor_agent_id is distinct from agent_board._delegate_principal_agent_id(NEW.agent_id)) then
|
|
1196
|
+
raise exception 'substrate CHECK refuses principal-authored actor that is not the subject''s principal'
|
|
1197
|
+
using errcode = '23514';
|
|
1198
|
+
end if;
|
|
1199
|
+
return NEW;
|
|
1200
|
+
end;
|
|
1201
|
+
$function$;
|
|
1202
|
+
|
|
1203
|
+
CREATE OR REPLACE FUNCTION agent_board._emit_membership_event(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_event_kind text DEFAULT NULL::text)
|
|
1204
|
+
RETURNS void
|
|
1205
|
+
LANGUAGE plpgsql
|
|
1206
|
+
SECURITY DEFINER
|
|
1207
|
+
SET search_path TO 'agent_board', 'public'
|
|
1208
|
+
AS $function$
|
|
1209
|
+
declare
|
|
1210
|
+
v_actor_id uuid := agent_board.current_agent_id();
|
|
1211
|
+
v_prov text;
|
|
1212
|
+
v_event_actor uuid;
|
|
1213
|
+
v_event_kind text;
|
|
1214
|
+
begin
|
|
1215
|
+
-- Provenance is DERIVED HERE from the session and NEVER from a caller-supplied argument.
|
|
1216
|
+
-- A caller-supplied id is an ASSERTION about who acted; the session is EVIDENCE. Keying off
|
|
1217
|
+
-- the argument would let any caller mint a `target-authored` event -- the one provenance
|
|
1218
|
+
-- that means "the subject consented".
|
|
1219
|
+
--
|
|
1220
|
+
-- service_role is ALWAYS operator-designated, even when a subject resolves from its token.
|
|
1221
|
+
-- An operator acting while some agent id happens to be resolvable is still an operator
|
|
1222
|
+
-- placement, and recording it as the subject's own act launders exactly what this ledger
|
|
1223
|
+
-- exists to distinguish.
|
|
1224
|
+
if agent_board._is_service_role() then
|
|
1225
|
+
v_prov := 'operator-designated';
|
|
1226
|
+
v_event_actor := null;
|
|
1227
|
+
elsif v_actor_id is not null and v_actor_id = p_agent_id then
|
|
1228
|
+
v_prov := 'target-authored';
|
|
1229
|
+
v_event_actor := p_agent_id;
|
|
1230
|
+
-- HOSTED (D2): the owner acting on ITS OWN hosted delegate is the delegate's principal, and
|
|
1231
|
+
-- its act is the delegate's consent. Derived here, never supplied: the subject must have the
|
|
1232
|
+
-- delegate fact AND the session must be the agent whose auth user owns the subject's
|
|
1233
|
+
-- instance. An owner acting on a non-delegate stays board-authored below.
|
|
1234
|
+
elsif v_actor_id is not null
|
|
1235
|
+
and v_actor_id = agent_board._delegate_principal_agent_id(p_agent_id) then
|
|
1236
|
+
v_prov := 'principal-authored';
|
|
1237
|
+
v_event_actor := v_actor_id;
|
|
1238
|
+
elsif v_actor_id is not null then
|
|
1239
|
+
v_prov := 'board-authored';
|
|
1240
|
+
v_event_actor := v_actor_id;
|
|
1241
|
+
else
|
|
1242
|
+
-- Unresolved, non-service actor. GATENULL should have refused upstream; if it did not,
|
|
1243
|
+
-- fail CLOSED rather than invent a provenance for an unattributable write.
|
|
1244
|
+
raise exception 'membership change has no attributable actor'
|
|
1245
|
+
using errcode = '42501';
|
|
1246
|
+
end if;
|
|
1247
|
+
|
|
1248
|
+
-- The KIND may be supplied by the caller; the PROVENANCE never can. The frozen mapping
|
|
1249
|
+
-- names specific kinds for the raw writers -- owner_seed, consent_link_redeem,
|
|
1250
|
+
-- operator_repair -- which a generic (provenance, state) derivation cannot produce. Letting
|
|
1251
|
+
-- the caller name the kind while the security-critical field stays derived keeps the
|
|
1252
|
+
-- vocabulary honest without reopening the forge: a wrong kind mislabels an event, a wrong
|
|
1253
|
+
-- provenance would fabricate consent.
|
|
1254
|
+
v_event_kind := coalesce(p_event_kind, case
|
|
1255
|
+
when v_prov = 'target-authored' and p_membership_state in ('joined', 'subscribed') then 'consent_self_join'
|
|
1256
|
+
when v_prov = 'target-authored' and p_membership_state = 'requested' then 'consent_join_request'
|
|
1257
|
+
when v_prov = 'target-authored' and p_membership_state = 'invited' then 'consent_self_invite'
|
|
1258
|
+
when v_prov = 'target-authored' and p_membership_state in ('left', 'removed') then 'self_withdraw'
|
|
1259
|
+
when v_prov = 'board-authored' and p_membership_state = 'invited' then 'invite_offer'
|
|
1260
|
+
when v_prov = 'board-authored' and p_membership_state = 'requested' then 'request_manufactured'
|
|
1261
|
+
when v_prov = 'board-authored' and p_membership_state in ('joined', 'subscribed') then 'resolution'
|
|
1262
|
+
when v_prov = 'board-authored' and p_membership_state in ('left', 'removed') then 'eviction'
|
|
1263
|
+
when v_prov = 'operator-designated' and p_membership_state in ('joined', 'subscribed') then 'operator_designation'
|
|
1264
|
+
when v_prov = 'operator-designated' and p_membership_state in ('left', 'removed') then 'operator_removal'
|
|
1265
|
+
-- Operator writes to a PENDING state are NEUTRAL, and must not borrow a grant kind.
|
|
1266
|
+
-- `operator_repair` denotes bootstrap's grant; letting it be the catch-all made an ordinary
|
|
1267
|
+
-- service-role write to `invited`/`requested` read as a repair-grant. Bootstrap still gets
|
|
1268
|
+
-- operator_repair -- it passes the kind explicitly.
|
|
1269
|
+
when v_prov = 'operator-designated' and p_membership_state = 'invited' then 'invite_offer'
|
|
1270
|
+
when v_prov = 'operator-designated' and p_membership_state = 'requested' then 'request_manufactured'
|
|
1271
|
+
-- HOSTED (D2): a principal places or withdraws; it never offers or requests on the
|
|
1272
|
+
-- delegate's behalf, so a pending state has no principal kind and raises below.
|
|
1273
|
+
when v_prov = 'principal-authored' and p_membership_state in ('joined', 'subscribed') then 'principal_placement'
|
|
1274
|
+
when v_prov = 'principal-authored' and p_membership_state in ('left', 'removed') then 'principal_withdraw'
|
|
1275
|
+
-- No catch-all. The (provenance, state) space is fully enumerated above, so reaching here
|
|
1276
|
+
-- means a state was added without deciding what it MEANS. Fail loudly rather than silently
|
|
1277
|
+
-- labelling it -- a wrong kind in an append-only ledger cannot be corrected later.
|
|
1278
|
+
else null
|
|
1279
|
+
end);
|
|
1280
|
+
|
|
1281
|
+
if v_event_kind is null then
|
|
1282
|
+
raise exception 'no event kind for provenance % with resulting state %', v_prov, p_membership_state
|
|
1283
|
+
using errcode = '22023';
|
|
1284
|
+
end if;
|
|
1285
|
+
|
|
1286
|
+
insert into agent_board.board_membership_events(
|
|
1287
|
+
board_id, agent_id, event_kind, result_state, provenance, actor_agent_id)
|
|
1288
|
+
values (p_board_id, p_agent_id, v_event_kind, p_membership_state, v_prov, v_event_actor);
|
|
1289
|
+
end;
|
|
1290
|
+
$function$;
|
|
1291
|
+
|
|
1292
|
+
CREATE OR REPLACE FUNCTION agent_board._membership_consent_present(p_board_id uuid, p_agent_id uuid)
|
|
1293
|
+
RETURNS boolean
|
|
1294
|
+
LANGUAGE sql
|
|
1295
|
+
STABLE SECURITY DEFINER
|
|
1296
|
+
SET search_path TO 'agent_board', 'public'
|
|
1297
|
+
AS $function$
|
|
1298
|
+
select coalesce(
|
|
1299
|
+
(
|
|
1300
|
+
-- An operator-designated event counts as consent ONLY when it is an actual
|
|
1301
|
+
-- PLACEMENT (joined|subscribed). It previously counted for ANY non-removal state,
|
|
1302
|
+
-- so a bare operator `invited` OFFER -- an invitation the subject had never seen,
|
|
1303
|
+
-- let alone answered -- made this predicate return true. This function's whole
|
|
1304
|
+
-- contract is "did this subject consent", and it is the frozen interface
|
|
1305
|
+
-- (IF-0-MBCONSENT-1) every future caller reads, so a pending offer must be NEUTRAL
|
|
1306
|
+
-- here exactly as a board-authored offer already is.
|
|
1307
|
+
select case
|
|
1308
|
+
when e.result_state in ('left', 'removed') then false
|
|
1309
|
+
when e.provenance = 'target-authored' then true
|
|
1310
|
+
when e.provenance = 'operator-designated'
|
|
1311
|
+
and e.result_state in ('joined', 'subscribed') then true
|
|
1312
|
+
-- HOSTED (D2): a principal's PLACEMENT is the delegate's consent, the same rule
|
|
1313
|
+
-- as an operator's. A principal withdrawal is a removal (first arm).
|
|
1314
|
+
when e.provenance = 'principal-authored'
|
|
1315
|
+
and e.result_state in ('joined', 'subscribed') then true
|
|
1316
|
+
end
|
|
1317
|
+
from agent_board.board_membership_events e
|
|
1318
|
+
join agent_board.boards b on b.id = e.board_id
|
|
1319
|
+
where e.board_id = p_board_id
|
|
1320
|
+
and e.agent_id = p_agent_id
|
|
1321
|
+
-- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
|
|
1322
|
+
-- earlier genuine grant beneath it still stands. Treating it as a removal would silently
|
|
1323
|
+
-- revoke the membership of someone who joined and LATER also filed a request.
|
|
1324
|
+
-- This is the ONE place created_at is correct: ordering uses seq because created_at is
|
|
1325
|
+
-- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
|
|
1326
|
+
and not (
|
|
1327
|
+
e.provenance = 'target-authored'
|
|
1328
|
+
and e.result_state = 'requested'
|
|
1329
|
+
and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
|
|
1330
|
+
)
|
|
1331
|
+
and (
|
|
1332
|
+
e.result_state in ('left', 'removed')
|
|
1333
|
+
or e.provenance = 'target-authored'
|
|
1334
|
+
or (e.provenance = 'operator-designated'
|
|
1335
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
1336
|
+
or (e.provenance = 'principal-authored'
|
|
1337
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
1338
|
+
)
|
|
1339
|
+
order by e.seq desc
|
|
1340
|
+
limit 1
|
|
1341
|
+
),
|
|
1342
|
+
false
|
|
1343
|
+
);
|
|
1344
|
+
$function$;
|
|
1345
|
+
|
|
1346
|
+
CREATE OR REPLACE FUNCTION agent_board._membership_consent_kind(p_board_id uuid, p_agent_id uuid)
|
|
1347
|
+
RETURNS text
|
|
1348
|
+
LANGUAGE sql
|
|
1349
|
+
STABLE SECURITY DEFINER
|
|
1350
|
+
SET search_path TO 'agent_board', 'public'
|
|
1351
|
+
AS $function$
|
|
1352
|
+
select (
|
|
1353
|
+
-- MUST mirror _membership_consent_present exactly: an operator-designated event counts
|
|
1354
|
+
-- only when it is an actual PLACEMENT (joined|subscribed), never a pending offer. These
|
|
1355
|
+
-- two functions are read as a pair -- `_present` for the boolean, `_kind` for the reason --
|
|
1356
|
+
-- so a narrowing applied to only one makes them disagree about the SAME event. That is
|
|
1357
|
+
-- precisely what happened: `_present` was narrowed and this twin was not, so a pending
|
|
1358
|
+
-- operator invite read present=false but kind='operator'.
|
|
1359
|
+
select case
|
|
1360
|
+
when e.result_state in ('left', 'removed') then null
|
|
1361
|
+
when e.provenance = 'target-authored' then 'target'
|
|
1362
|
+
when e.provenance = 'operator-designated'
|
|
1363
|
+
and e.result_state in ('joined', 'subscribed') then 'operator'
|
|
1364
|
+
-- HOSTED (D2): mirrors _membership_consent_present exactly.
|
|
1365
|
+
when e.provenance = 'principal-authored'
|
|
1366
|
+
and e.result_state in ('joined', 'subscribed') then 'principal'
|
|
1367
|
+
end
|
|
1368
|
+
from agent_board.board_membership_events e
|
|
1369
|
+
join agent_board.boards b on b.id = e.board_id
|
|
1370
|
+
where e.board_id = p_board_id
|
|
1371
|
+
and e.agent_id = p_agent_id
|
|
1372
|
+
-- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
|
|
1373
|
+
-- earlier genuine grant beneath it still stands. Treating it as a removal would silently
|
|
1374
|
+
-- revoke the membership of someone who joined and LATER also filed a request.
|
|
1375
|
+
-- This is the ONE place created_at is correct: ordering uses seq because created_at is
|
|
1376
|
+
-- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
|
|
1377
|
+
and not (
|
|
1378
|
+
e.provenance = 'target-authored'
|
|
1379
|
+
and e.result_state = 'requested'
|
|
1380
|
+
and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
|
|
1381
|
+
)
|
|
1382
|
+
and (
|
|
1383
|
+
e.result_state in ('left', 'removed')
|
|
1384
|
+
or e.provenance = 'target-authored'
|
|
1385
|
+
or (e.provenance = 'operator-designated'
|
|
1386
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
1387
|
+
or (e.provenance = 'principal-authored'
|
|
1388
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
1389
|
+
)
|
|
1390
|
+
order by e.seq desc
|
|
1391
|
+
limit 1
|
|
1392
|
+
);
|
|
1393
|
+
$function$;
|
|
1394
|
+
|
|
1395
|
+
CREATE OR REPLACE FUNCTION agent_board._upsert_board_membership_state(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb, p_membership_id uuid DEFAULT NULL::uuid, p_event_kind text DEFAULT NULL::text)
|
|
1396
|
+
RETURNS agent_board.board_memberships
|
|
1397
|
+
LANGUAGE plpgsql
|
|
1398
|
+
SECURITY DEFINER
|
|
1399
|
+
SET search_path TO 'agent_board', 'public'
|
|
1400
|
+
AS $function$
|
|
1401
|
+
declare
|
|
1402
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
1403
|
+
v_metadata jsonb;
|
|
1404
|
+
-- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
|
|
1405
|
+
v_currently_removed boolean;
|
|
1406
|
+
v_prior_state text;
|
|
1407
|
+
-- #317: the resolved next membership_state, computed ONCE and then both written and compared.
|
|
1408
|
+
-- An earlier draft wrote the guard as a CASE in the SET clause and RESTATED the same predicate
|
|
1409
|
+
-- to decide whether to emit. The two agreed, and nothing asserted they must: dropping
|
|
1410
|
+
-- `subscribed` from the emit copy alone re-created the false-ledger defect verbatim, and adding
|
|
1411
|
+
-- `removed` to it alone silently dropped a real state change from an append-only ledger. Both
|
|
1412
|
+
-- left the entire suite green. Deriving once removes the possibility rather than testing for it.
|
|
1413
|
+
v_next_state text;
|
|
1414
|
+
v_offer_noop boolean := false;
|
|
1415
|
+
-- HOSTED (D15, D12)
|
|
1416
|
+
v_is_delegate boolean;
|
|
1417
|
+
v_delegate_id uuid;
|
|
1418
|
+
begin
|
|
1419
|
+
if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
|
|
1420
|
+
raise exception 'invalid board membership state' using errcode = '22023';
|
|
1421
|
+
end if;
|
|
1422
|
+
|
|
1423
|
+
-- HOSTED (D15): THE SINK RULE. A hosted delegate is placed into an active or pending state
|
|
1424
|
+
-- ONLY by add_delegate_to_board, and the sink proves it with two facts, both required:
|
|
1425
|
+
-- 1. the transaction-local setting agent_board.delegate_admission names this target. Only
|
|
1426
|
+
-- add_delegate_to_board sets it (set_config(..., true)), after its owner, R-LINK-7 and
|
|
1427
|
+
-- authority checks, and clears it before returning. The sink only READS it;
|
|
1428
|
+
-- 2. the session is the target's principal. service_role has no resolved actor, so a
|
|
1429
|
+
-- service_role caller fails this even if it could set the flag.
|
|
1430
|
+
-- A move to `left` or `removed` needs neither: removals, the D12 cascade and the tombstone
|
|
1431
|
+
-- run without it. The entrypoints that bypass this sink carry the class refusal instead
|
|
1432
|
+
-- (agent_board._refuse_delegate_membership_target).
|
|
1433
|
+
v_is_delegate := coalesce((select a.is_delegate from agent_board.agents a where a.id = p_agent_id), false);
|
|
1434
|
+
if v_is_delegate
|
|
1435
|
+
and p_membership_state in ('joined', 'subscribed', 'invited', 'requested')
|
|
1436
|
+
and (
|
|
1437
|
+
coalesce(current_setting('agent_board.delegate_admission', true), '') <> p_agent_id::text
|
|
1438
|
+
or agent_board.current_agent_id() is null
|
|
1439
|
+
or agent_board.current_agent_id() is distinct from agent_board._delegate_principal_agent_id(p_agent_id)
|
|
1440
|
+
) then
|
|
1441
|
+
raise exception 'a hosted delegate joins a board only through add_delegate_to_board'
|
|
1442
|
+
using errcode = '42501';
|
|
1443
|
+
end if;
|
|
1444
|
+
|
|
1445
|
+
-- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
|
|
1446
|
+
-- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
|
|
1447
|
+
-- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
|
|
1448
|
+
-- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
|
|
1449
|
+
if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
|
|
1450
|
+
raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
|
|
1451
|
+
end if;
|
|
1452
|
+
|
|
1453
|
+
-- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
|
|
1454
|
+
-- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
|
|
1455
|
+
-- by a caller whose own row they govern. Strip them from caller input; operational writers set
|
|
1456
|
+
-- the column directly.
|
|
1457
|
+
select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
|
|
1458
|
+
into v_metadata
|
|
1459
|
+
from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
|
|
1460
|
+
where e.key not like 'ops\_%';
|
|
1461
|
+
|
|
1462
|
+
-- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
|
|
1463
|
+
-- (exists removed AND no active), not a single total-ordered row. A higher-id
|
|
1464
|
+
-- removed row can coexist with an active row; inferring "removed" from the
|
|
1465
|
+
-- total-order winner alone would then try to activate the removed row and
|
|
1466
|
+
-- collide with idx_agent_board_board_memberships_active_unique (23505).
|
|
1467
|
+
-- Capture standing before the row lock so the predicate is independent of
|
|
1468
|
+
-- which physical row the active-preferring SELECT below returns.
|
|
1469
|
+
v_currently_removed :=
|
|
1470
|
+
exists (
|
|
1471
|
+
select 1 from agent_board.board_memberships bm
|
|
1472
|
+
where bm.board_id = p_board_id
|
|
1473
|
+
and bm.agent_id = p_agent_id
|
|
1474
|
+
and bm.membership_state = 'removed'
|
|
1475
|
+
) and not exists (
|
|
1476
|
+
select 1 from agent_board.board_memberships bm
|
|
1477
|
+
where bm.board_id = p_board_id
|
|
1478
|
+
and bm.agent_id = p_agent_id
|
|
1479
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
-- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
|
|
1483
|
+
-- exists, then total-order by created_at desc, id desc. A pure total-order
|
|
1484
|
+
-- can select a higher-id REMOVED row while an ACTIVE row still exists.
|
|
1485
|
+
-- When v_currently_removed, no active row exists and the total-order pick is
|
|
1486
|
+
-- non-active (re-entry clamp applies below).
|
|
1487
|
+
-- When the caller names a row it has already validated AND LOCKED, act on THAT row. The
|
|
1488
|
+
-- active-preferring pick below is right when the sink chooses for itself, but wrong when a
|
|
1489
|
+
-- caller validated a specific one: rejecting a pending request would lock and mutate the
|
|
1490
|
+
-- subject's ACTIVE membership instead, downgrading a real member (#109).
|
|
1491
|
+
if p_membership_id is not null then
|
|
1492
|
+
select *
|
|
1493
|
+
into v_membership
|
|
1494
|
+
from agent_board.board_memberships bm
|
|
1495
|
+
where bm.id = p_membership_id
|
|
1496
|
+
and bm.board_id = p_board_id
|
|
1497
|
+
and bm.agent_id = p_agent_id
|
|
1498
|
+
for update;
|
|
1499
|
+
|
|
1500
|
+
if v_membership.id is null then
|
|
1501
|
+
raise exception 'membership row % not found for this subject', p_membership_id
|
|
1502
|
+
using errcode = 'P0002';
|
|
1503
|
+
end if;
|
|
1504
|
+
else
|
|
1505
|
+
select *
|
|
1506
|
+
into v_membership
|
|
1507
|
+
from agent_board.board_memberships bm
|
|
1508
|
+
where bm.board_id = p_board_id
|
|
1509
|
+
and bm.agent_id = p_agent_id
|
|
1510
|
+
order by
|
|
1511
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
1512
|
+
bm.created_at desc,
|
|
1513
|
+
bm.id desc
|
|
1514
|
+
limit 1
|
|
1515
|
+
for update;
|
|
1516
|
+
end if;
|
|
1517
|
+
|
|
1518
|
+
-- The PRIOR state, captured before the update below overwrites v_membership. The emitter
|
|
1519
|
+
-- cannot see it, and without it a joined<->subscribed self-toggle is indistinguishable from
|
|
1520
|
+
-- a fresh join.
|
|
1521
|
+
v_prior_state := v_membership.membership_state;
|
|
1522
|
+
|
|
1523
|
+
if v_membership.id is null then
|
|
1524
|
+
-- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
|
|
1525
|
+
-- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
|
|
1526
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
|
|
1527
|
+
values (
|
|
1528
|
+
p_board_id,
|
|
1529
|
+
p_agent_id,
|
|
1530
|
+
p_membership_state,
|
|
1531
|
+
p_created_by_agent_id,
|
|
1532
|
+
case when p_membership_state in ('left', 'removed') then now() else null end,
|
|
1533
|
+
v_metadata
|
|
1534
|
+
)
|
|
1535
|
+
returning * into v_membership;
|
|
1536
|
+
else
|
|
1537
|
+
-- THE #317 RULE, in one place. `invited` and `requested` are OFFERS: they describe a
|
|
1538
|
+
-- membership that does not yet exist. Applying one to a membership that DOES exist
|
|
1539
|
+
-- (`joined`/`subscribed`) leaves the state alone -- the offer is already satisfied and the
|
|
1540
|
+
-- stronger standing wins. Every other transition is untouched, including joined <-> subscribed,
|
|
1541
|
+
-- active -> left/removed, and re-entry from any non-active state.
|
|
1542
|
+
v_next_state := case
|
|
1543
|
+
when p_membership_state in ('invited', 'requested')
|
|
1544
|
+
and v_membership.membership_state in ('joined', 'subscribed')
|
|
1545
|
+
then v_membership.membership_state
|
|
1546
|
+
else p_membership_state
|
|
1547
|
+
end;
|
|
1548
|
+
|
|
1549
|
+
-- Suppressed iff the state did not move AND the caller asked for something else. The second
|
|
1550
|
+
-- conjunct matters: join_board and update_board_membership both permit joined -> joined, which
|
|
1551
|
+
-- also does not move the state but IS the caller getting what they asked for, and still emits.
|
|
1552
|
+
v_offer_noop := v_next_state is not distinct from v_membership.membership_state
|
|
1553
|
+
and p_membership_state is distinct from v_next_state;
|
|
1554
|
+
|
|
1555
|
+
update agent_board.board_memberships bm
|
|
1556
|
+
-- #317: AN INVITE MUST NOT WEAKEN AN ACTIVE MEMBERSHIP.
|
|
1557
|
+
--
|
|
1558
|
+
-- Reproduced on production and again in a scratch database: an agent JOINED since
|
|
1559
|
+
-- 2026-08-05 was moved back to `invited` by a single invite_offer, because this UPDATE
|
|
1560
|
+
-- set the requested state unconditionally. The row was never deleted -- it was
|
|
1561
|
+
-- DOWNGRADED, and the agent silently lost board delivery while both sides saw a board
|
|
1562
|
+
-- that merely looked quiet.
|
|
1563
|
+
--
|
|
1564
|
+
-- This is NOT #109 and the #109 guard does not cover it. #109 was the sink acting on the
|
|
1565
|
+
-- WRONG ROW (an active row when a pending one had been validated), fixed by the
|
|
1566
|
+
-- p_membership_id targeting above. This is the RIGHT row moved in the WRONG DIRECTION,
|
|
1567
|
+
-- so it needs a direction rule, not a selection rule.
|
|
1568
|
+
--
|
|
1569
|
+
-- The rule: `invited` and `requested` are OFFERS -- they describe a membership that does
|
|
1570
|
+
-- not yet exist. Applying an offer to one that DOES exist (`joined`/`subscribed`) leaves
|
|
1571
|
+
-- the state alone: the offer is already satisfied and the stronger standing wins. Every
|
|
1572
|
+
-- other transition is untouched, including joined <-> subscribed, active -> left/removed,
|
|
1573
|
+
-- and re-entry from any non-active state.
|
|
1574
|
+
--
|
|
1575
|
+
-- A no-op rather than an exception, deliberately: invite is idempotent by design (the
|
|
1576
|
+
-- MBCONSENT invite flow re-offers freely) and raising here would break callers that
|
|
1577
|
+
-- succeed today.
|
|
1578
|
+
--
|
|
1579
|
+
-- "No-op" IS COLUMN-SCOPED, and saying so is the difference between a true claim and a
|
|
1580
|
+
-- comfortable one. What a suppressed offer preserves is the AUTHORITY-BEARING state:
|
|
1581
|
+
-- membership_state, left_at, and role. The row is still WRITTEN -- created_by_agent_id,
|
|
1582
|
+
-- metadata and updated_at all follow the ordinary path below. That is pre-existing
|
|
1583
|
+
-- behaviour this change does not alter: created_by_agent_id is audit provenance surfaced
|
|
1584
|
+
-- as created_by_handle (20260507000000:85-87), not authority, and the metadata merge is
|
|
1585
|
+
-- what every other caller gets. Making a suppressed offer skip the UPDATE entirely would
|
|
1586
|
+
-- be defensible and is deliberately NOT done here -- it would change metadata semantics
|
|
1587
|
+
-- for a path unrelated to the defect, in a fix meant to be narrow. Both the preserved
|
|
1588
|
+
-- columns and the written ones are asserted, so the scope is a fact rather than a promise.
|
|
1589
|
+
-- v_next_state, derived above. NOTE the deliberate asymmetry with the two CASEs below:
|
|
1590
|
+
-- `role` and `left_at` still key off p_membership_state, the caller's REQUEST, and that is
|
|
1591
|
+
-- correct rather than an oversight -- the #79 role clamp fires on ADMISSION to an active
|
|
1592
|
+
-- state, and a suppressed offer admits nobody.
|
|
1593
|
+
--
|
|
1594
|
+
-- Stated precisely, because a first draft of this comment overclaimed and a mutation
|
|
1595
|
+
-- refuted it: keying the clamp off v_next_state instead does NOT reset an ordinary owner,
|
|
1596
|
+
-- because the clamp's second conjunct already requires the PRIOR state to be non-active
|
|
1597
|
+
-- (or currently-removed standing to hold), and on a suppressed offer the prior state is
|
|
1598
|
+
-- active by construction. The two spellings differ only in the `v_currently_removed`
|
|
1599
|
+
-- corner, where the selected row is active while a removed duplicate exists. So the
|
|
1600
|
+
-- asymmetry is worth keeping and worth explaining, but it is not the gaping hole the
|
|
1601
|
+
-- draft claimed. The assertions below pin the ordinary case, which is what they cover.
|
|
1602
|
+
set membership_state = v_next_state,
|
|
1603
|
+
created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
|
|
1604
|
+
-- PRESERVE the first departure time. Restamping it would let the departing agent (or a
|
|
1605
|
+
-- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
|
|
1606
|
+
left_at = case
|
|
1607
|
+
when p_membership_state in ('left', 'removed') then
|
|
1608
|
+
case
|
|
1609
|
+
-- SAME terminal state re-asserted: preserve the FIRST departure time so a
|
|
1610
|
+
-- repeat call cannot push the retention cutoff forward.
|
|
1611
|
+
when v_membership.membership_state = p_membership_state
|
|
1612
|
+
then coalesce(v_membership.left_at, now())
|
|
1613
|
+
-- GENUINE transition (notably left -> removed, an owner barring
|
|
1614
|
+
-- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
|
|
1615
|
+
-- deliberately does not block voluntary 'left' (20260724000100:321-322),
|
|
1616
|
+
-- so this is the real kick moment; inheriting the earlier departure would
|
|
1617
|
+
-- both lose the audit answer and make the marker row retention-eligible
|
|
1618
|
+
-- prematurely.
|
|
1619
|
+
else now()
|
|
1620
|
+
end
|
|
1621
|
+
else null
|
|
1622
|
+
end,
|
|
1623
|
+
-- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
|
|
1624
|
+
-- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
|
|
1625
|
+
-- information_schema at runtime. Active -> active preserves bm.role (including owner).
|
|
1626
|
+
-- Unconditional — no service_role exemption.
|
|
1627
|
+
-- MBROWID: origin is non-active when the selected row is non-active OR when
|
|
1628
|
+
-- currently-removed standing holds (R-LINK-7 set form). With active-preferring
|
|
1629
|
+
-- selection those agree; the set form is load-bearing against equal-created_at
|
|
1630
|
+
-- removed+left duplicates where a single-row read would flip with id order.
|
|
1631
|
+
role = case
|
|
1632
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
1633
|
+
and (
|
|
1634
|
+
v_membership.membership_state not in ('joined', 'subscribed')
|
|
1635
|
+
or v_currently_removed
|
|
1636
|
+
)
|
|
1637
|
+
then 'member'
|
|
1638
|
+
else bm.role
|
|
1639
|
+
end,
|
|
1640
|
+
metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
|
|
1641
|
+
updated_at = now()
|
|
1642
|
+
where bm.id = v_membership.id
|
|
1643
|
+
returning * into v_membership;
|
|
1644
|
+
end if;
|
|
1645
|
+
|
|
1646
|
+
-- Emission is delegated so the sink and the four raw writers cannot drift apart.
|
|
1647
|
+
-- The frozen mapping distinguishes a self-TOGGLE between two active states from a fresh
|
|
1648
|
+
-- self-JOIN. Only the sink knows the prior state, so it names the kind; the emitter derives
|
|
1649
|
+
-- everything security-critical regardless.
|
|
1650
|
+
-- A caller-supplied kind wins; otherwise the sink's own derivation stands. Only the
|
|
1651
|
+
-- DESCRIPTIVE field is delegated -- provenance is always derived from the session.
|
|
1652
|
+
--
|
|
1653
|
+
-- #317 -- NOTHING IS EMITTED WHEN NOTHING HAPPENED, and both of the obvious alternatives are
|
|
1654
|
+
-- WORSE. Before the guard above, the argument and the resulting row could not differ, so the
|
|
1655
|
+
-- question did not arise. The guard makes them differ on exactly one path, and that path then
|
|
1656
|
+
-- has three options, two of which are defects:
|
|
1657
|
+
--
|
|
1658
|
+
-- * emit `p_membership_state` (what the first draft of this migration did): appends
|
|
1659
|
+
-- `result_state = 'invited'` beside a row that says `joined`. `board_membership_events` is
|
|
1660
|
+
-- append-only, so the false row can never be corrected -- and it is the table the #317
|
|
1661
|
+
-- investigation itself read to establish what happened. The fix would have falsified its
|
|
1662
|
+
-- own diagnostic. Caught by three review seats.
|
|
1663
|
+
--
|
|
1664
|
+
-- * emit `v_membership.membership_state` (what the SECOND draft did, "fixing" the above):
|
|
1665
|
+
-- MINTS CONSENT OUT OF AN OFFER, which is strictly worse than a cosmetically wrong row.
|
|
1666
|
+
-- `_membership_consent_present` -- the FROZEN MBCONSENT interface (IF-0-MBCONSENT-1) that
|
|
1667
|
+
-- `update_board_membership` consults before admitting anyone -- treats
|
|
1668
|
+
-- `operator-designated` + `result_state in ('joined','subscribed')` as an actual PLACEMENT,
|
|
1669
|
+
-- i.e. as consent. An operator re-inviting a member whose ledger is silent would therefore
|
|
1670
|
+
-- turn a mere OFFER into an admission grant. Measured, not reasoned:
|
|
1671
|
+
--
|
|
1672
|
+
-- consent BEFORE operator re-invite = false
|
|
1673
|
+
-- consent AFTER operator re-invite = true
|
|
1674
|
+
--
|
|
1675
|
+
-- That is the precise narrowing MBCONSENT shipped 20260806000100 to make -- "a bare operator
|
|
1676
|
+
-- `invited` OFFER must be NEUTRAL" -- reopened from the other side. Delegating a kind cannot
|
|
1677
|
+
-- avoid it either: the predicate keys on (provenance, result_state) ONLY, and provenance is
|
|
1678
|
+
-- derived from the session and is not delegatable.
|
|
1679
|
+
--
|
|
1680
|
+
-- * emit nothing, which is what this does. `board_membership_events` records membership-state
|
|
1681
|
+
-- events; a suppressed offer changed no state, so there is no event. A silent ledger reads
|
|
1682
|
+
-- as `false` from the consent predicate, so this cannot mint anything, and it writes no row
|
|
1683
|
+
-- that a later reader must distrust.
|
|
1684
|
+
--
|
|
1685
|
+
-- WHAT THIS GIVES UP, stated rather than left to be discovered: an invite that lands on an
|
|
1686
|
+
-- already-active member is no longer visible in this ledger at all. That is a real loss of
|
|
1687
|
+
-- audit for a real action. It is accepted because the only two ways to record it here are a
|
|
1688
|
+
-- false statement about the row or a forged consent grant, and because the CALLER still sees
|
|
1689
|
+
-- the outcome -- invite_board_member and update_board_membership both return the row, which
|
|
1690
|
+
-- now reads `joined` rather than `invited`. If that action needs auditing, it needs a surface
|
|
1691
|
+
-- that is not the consent ledger.
|
|
1692
|
+
--
|
|
1693
|
+
-- Emission is otherwise unchanged and still delegated, so the sink and the raw writers cannot
|
|
1694
|
+
-- drift apart.
|
|
1695
|
+
-- HOSTED (D15): a hosted delegate is a member, never higher, whatever route wrote the row.
|
|
1696
|
+
if v_is_delegate and v_membership.role is distinct from 'member' then
|
|
1697
|
+
raise exception 'a hosted delegate''s board role is member, never higher'
|
|
1698
|
+
using errcode = '42501';
|
|
1699
|
+
end if;
|
|
1700
|
+
|
|
1701
|
+
if not v_offer_noop then
|
|
1702
|
+
perform agent_board._emit_membership_event(
|
|
1703
|
+
p_board_id, p_agent_id, p_membership_state,
|
|
1704
|
+
coalesce(p_event_kind, case
|
|
1705
|
+
when v_prior_state in ('joined', 'subscribed')
|
|
1706
|
+
and p_membership_state in ('joined', 'subscribed')
|
|
1707
|
+
-- The states must actually DIFFER. join_board and update_board_membership both permit
|
|
1708
|
+
-- joined->joined and subscribed->subscribed, and a write that changes nothing is not a
|
|
1709
|
+
-- toggle of anything -- recording it as one permanently mislabels an append-only event.
|
|
1710
|
+
and v_prior_state <> p_membership_state
|
|
1711
|
+
and not agent_board._is_service_role()
|
|
1712
|
+
and agent_board.current_agent_id() = p_agent_id
|
|
1713
|
+
then 'consent_self_toggle'
|
|
1714
|
+
else null
|
|
1715
|
+
end));
|
|
1716
|
+
end if;
|
|
1717
|
+
|
|
1718
|
+
-- HOSTED (D12): A DELEGATE LEAVES A BOARD WHEN ITS OWNER DOES. When this write leaves the
|
|
1719
|
+
-- target without an active membership (at most one active row exists per board and agent,
|
|
1720
|
+
-- idx_agent_board_board_memberships_active_unique), every hosted delegate of the target
|
|
1721
|
+
-- that is active on this board moves to `left` in the same transaction, through this sink,
|
|
1722
|
+
-- so each gets its own ledger event derived from the session (principal_withdraw when the
|
|
1723
|
+
-- owner leaves, eviction when an admin removes the owner, operator_removal under
|
|
1724
|
+
-- service_role). A delegate already removed is left as it is; the owner's return does not
|
|
1725
|
+
-- restore its delegates. Lock order: the owner's row (held above) before the delegates'.
|
|
1726
|
+
if not v_is_delegate and v_membership.membership_state not in ('joined', 'subscribed') then
|
|
1727
|
+
for v_delegate_id in
|
|
1728
|
+
select bm.agent_id
|
|
1729
|
+
from agent_board.board_memberships bm
|
|
1730
|
+
join agent_board.delegate_instances di on di.agent_id = bm.agent_id
|
|
1731
|
+
join agent_board.agents owner_agent on owner_agent.auth_user_id = di.owner_user_id
|
|
1732
|
+
where owner_agent.id = p_agent_id
|
|
1733
|
+
and bm.board_id = p_board_id
|
|
1734
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1735
|
+
order by bm.agent_id
|
|
1736
|
+
loop
|
|
1737
|
+
perform agent_board._upsert_board_membership_state(p_board_id, v_delegate_id, 'left');
|
|
1738
|
+
end loop;
|
|
1739
|
+
end if;
|
|
1740
|
+
|
|
1741
|
+
return v_membership;
|
|
1742
|
+
end;
|
|
1743
|
+
$function$;
|
|
1744
|
+
|
|
1745
|
+
CREATE OR REPLACE FUNCTION agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
1746
|
+
RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
|
|
1747
|
+
LANGUAGE plpgsql
|
|
1748
|
+
SECURITY DEFINER
|
|
1749
|
+
SET search_path TO 'agent_board', 'public'
|
|
1750
|
+
AS $function$
|
|
1751
|
+
declare
|
|
1752
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
1753
|
+
v_board agent_board.boards%rowtype;
|
|
1754
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
1755
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
1756
|
+
v_existing_state text;
|
|
1757
|
+
v_target_role text;
|
|
1758
|
+
v_actor_role text;
|
|
1759
|
+
-- HOSTED (D7): the caller is the target hosted delegate's principal asking for `left`.
|
|
1760
|
+
v_principal_withdraw boolean;
|
|
1761
|
+
begin
|
|
1762
|
+
if v_actor is null and not v_is_service then
|
|
1763
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
1764
|
+
end if;
|
|
1765
|
+
|
|
1766
|
+
v_principal_withdraw := not v_is_service
|
|
1767
|
+
and v_actor <> p_agent_id
|
|
1768
|
+
and p_membership_state = 'left'
|
|
1769
|
+
and v_actor = agent_board._delegate_principal_agent_id(p_agent_id);
|
|
1770
|
+
|
|
1771
|
+
if not v_is_service and v_actor <> p_agent_id and not v_principal_withdraw then
|
|
1772
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
1773
|
+
if v_board.id is null then
|
|
1774
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
1775
|
+
end if;
|
|
1776
|
+
select bm.role
|
|
1777
|
+
into v_actor_role
|
|
1778
|
+
from agent_board.board_memberships bm
|
|
1779
|
+
where bm.board_id = p_board_id
|
|
1780
|
+
and bm.agent_id = v_actor
|
|
1781
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1782
|
+
limit 1;
|
|
1783
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
1784
|
+
raise exception 'board membership change requires owner or admin role'
|
|
1785
|
+
using errcode = '42501';
|
|
1786
|
+
end if;
|
|
1787
|
+
end if;
|
|
1788
|
+
|
|
1789
|
+
-- HOSTED: LOCKED, so every transition rule below is decided on the state the sink will
|
|
1790
|
+
-- overwrite. Unlocked, an admin's uncommitted `removed` committed after this read, and the D7
|
|
1791
|
+
-- withdrawal (and an ordinary self-leave, the same branch shape) then wrote `left` over it in
|
|
1792
|
+
-- the sink, erasing the eviction R-LINK-7 reads (hosted_delegate_withdraw_evict_race). The
|
|
1793
|
+
-- sink's own first lock is this same row in the same order, so the lock set is unchanged;
|
|
1794
|
+
-- it is only taken earlier, before reads that lock nothing.
|
|
1795
|
+
select bm.membership_state, bm.role
|
|
1796
|
+
into v_existing_state, v_target_role
|
|
1797
|
+
from agent_board.board_memberships bm
|
|
1798
|
+
where bm.board_id = p_board_id
|
|
1799
|
+
and bm.agent_id = p_agent_id
|
|
1800
|
+
order by
|
|
1801
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
1802
|
+
bm.created_at desc,
|
|
1803
|
+
bm.id desc
|
|
1804
|
+
limit 1
|
|
1805
|
+
for update;
|
|
1806
|
+
if v_existing_state is null then
|
|
1807
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
1808
|
+
end if;
|
|
1809
|
+
|
|
1810
|
+
if v_principal_withdraw then
|
|
1811
|
+
-- HOSTED (D7): the owner withdraws its own delegate whatever its board role, but ONLY from
|
|
1812
|
+
-- the states a self-leave may leave. Never from `removed`: turning an eviction into `left`
|
|
1813
|
+
-- would erase the standing R-LINK-7 reads and let the owner re-add the evicted delegate.
|
|
1814
|
+
if v_existing_state not in ('joined', 'subscribed', 'invited', 'requested') then
|
|
1815
|
+
raise exception 'a principal may withdraw its delegate only from an active or pending membership'
|
|
1816
|
+
using errcode = '42501';
|
|
1817
|
+
end if;
|
|
1818
|
+
elsif not v_is_service then
|
|
1819
|
+
if v_actor = p_agent_id then
|
|
1820
|
+
if not (
|
|
1821
|
+
(p_membership_state = 'left'
|
|
1822
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
1823
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
1824
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
1825
|
+
or (p_membership_state = v_existing_state
|
|
1826
|
+
and p_membership_state in ('left', 'removed'))
|
|
1827
|
+
) then
|
|
1828
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
1829
|
+
using errcode = '42501';
|
|
1830
|
+
end if;
|
|
1831
|
+
else
|
|
1832
|
+
if agent_board._role_rank(v_actor_role)
|
|
1833
|
+
<= agent_board._role_rank(
|
|
1834
|
+
case
|
|
1835
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
1836
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
1837
|
+
then 'member'
|
|
1838
|
+
else coalesce(v_target_role, '')
|
|
1839
|
+
end) then
|
|
1840
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
1841
|
+
using errcode = '42501';
|
|
1842
|
+
end if;
|
|
1843
|
+
|
|
1844
|
+
if p_membership_state in ('joined', 'subscribed')
|
|
1845
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
1846
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
1847
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
1848
|
+
using errcode = '42501';
|
|
1849
|
+
end if;
|
|
1850
|
+
|
|
1851
|
+
-- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
|
|
1852
|
+
-- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
|
|
1853
|
+
-- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
|
|
1854
|
+
-- states are already consent-gated above; service_role/self never reach this branch.
|
|
1855
|
+
if p_membership_state in ('invited', 'requested')
|
|
1856
|
+
and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
|
|
1857
|
+
raise exception 'target has blocked invitations from this inviter on this board'
|
|
1858
|
+
using errcode = '42501';
|
|
1859
|
+
end if;
|
|
1860
|
+
end if;
|
|
1861
|
+
end if;
|
|
1862
|
+
|
|
1863
|
+
if not v_is_service
|
|
1864
|
+
and p_membership_state = v_existing_state
|
|
1865
|
+
and p_membership_state in ('left', 'removed') then
|
|
1866
|
+
return query
|
|
1867
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
1868
|
+
from agent_board.board_memberships bm
|
|
1869
|
+
where bm.board_id = p_board_id
|
|
1870
|
+
and bm.agent_id = p_agent_id
|
|
1871
|
+
order by
|
|
1872
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
1873
|
+
bm.created_at desc,
|
|
1874
|
+
bm.id desc
|
|
1875
|
+
limit 1;
|
|
1876
|
+
return;
|
|
1877
|
+
end if;
|
|
1878
|
+
|
|
1879
|
+
select * into v_membership
|
|
1880
|
+
from agent_board._upsert_board_membership_state(
|
|
1881
|
+
p_board_id,
|
|
1882
|
+
p_agent_id,
|
|
1883
|
+
p_membership_state,
|
|
1884
|
+
null,
|
|
1885
|
+
p_metadata
|
|
1886
|
+
);
|
|
1887
|
+
|
|
1888
|
+
return query
|
|
1889
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
1890
|
+
end;
|
|
1891
|
+
$function$;
|
|
1892
|
+
|
|
1893
|
+
CREATE OR REPLACE FUNCTION agent_board.grant_board_role(p_board_id uuid, p_agent_id uuid, p_grantee_role text, p_grantor_agent_id uuid DEFAULT NULL::uuid)
|
|
1894
|
+
RETURNS TABLE(board_id uuid, agent_id uuid, role text, updated_at timestamp with time zone)
|
|
1895
|
+
LANGUAGE plpgsql
|
|
1896
|
+
SECURITY DEFINER
|
|
1897
|
+
SET search_path TO 'agent_board', 'public'
|
|
1898
|
+
AS $function$
|
|
1899
|
+
declare
|
|
1900
|
+
v_grantor uuid;
|
|
1901
|
+
v_updated agent_board.board_memberships%rowtype;
|
|
1902
|
+
begin
|
|
1903
|
+
if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
|
|
1904
|
+
raise exception 'invalid grantee role' using errcode = '22023';
|
|
1905
|
+
end if;
|
|
1906
|
+
|
|
1907
|
+
-- #276, FORCED BY AN EXISTING CLASS ASSERTION, not chosen. Adding the ledger gate makes this
|
|
1908
|
+
-- function row-lock `agents` (transitively, through _assert_actor_not_fleet_revoked) as well
|
|
1909
|
+
-- as board_memberships, which puts it in the class agent_board_signer_lifecycle.test.sql
|
|
1910
|
+
-- section 9 pins from the call-graph closure: every function locking BOTH relations must take
|
|
1911
|
+
-- the board advisory lock that serialises them against onboard_federated_fleet_agent. That
|
|
1912
|
+
-- assertion FAILED and named this function when the gate was added without this line.
|
|
1913
|
+
--
|
|
1914
|
+
-- The tempting reply is that this function's order is agents -> board_memberships, the SAME
|
|
1915
|
+
-- direction as onboarding, so it cannot close the F4 cycle. That is true today and is exactly
|
|
1916
|
+
-- the ordering argument #269 refused: mutual exclusion survives a lock being added downstream,
|
|
1917
|
+
-- a re-derived ordering does not. So the lock is taken rather than the assertion relaxed.
|
|
1918
|
+
--
|
|
1919
|
+
-- It also closes a gap 20260901000110 left open and explicitly declined to determine: this
|
|
1920
|
+
-- function reads the ceiling for authorization (via _grant_within_ceiling below) and was one
|
|
1921
|
+
-- of the three readers that did NOT take the lock serialising the ceiling row's ABSENCE, so a
|
|
1922
|
+
-- concurrent first-ceiling insert could be missed and the read pass as default-owner. It is
|
|
1923
|
+
-- now serialised, and agent_board_atomic_fleet_onboarding.test.sql's ceiling-reader registry
|
|
1924
|
+
-- records it as such.
|
|
1925
|
+
--
|
|
1926
|
+
-- FIRST, before any row lock, so both routes are advisory -> agents -> board_memberships.
|
|
1927
|
+
-- Re-taking it inside signed_grant_board_role (which already holds it) is a no-op: advisory
|
|
1928
|
+
-- transaction locks are re-entrant within a session.
|
|
1929
|
+
-- HOSTED (role-writer class): a hosted delegate is a member, never higher.
|
|
1930
|
+
perform agent_board._refuse_delegate_membership_target(p_agent_id, 'grant_board_role');
|
|
1931
|
+
|
|
1932
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
1933
|
+
|
|
1934
|
+
-- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
|
|
1935
|
+
-- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
|
|
1936
|
+
-- supplied arg <> current_agent_id(). This closes the single-principal escalation.
|
|
1937
|
+
v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
|
|
1938
|
+
|
|
1939
|
+
-- #276. Fleet revocation removes board authority, not only send. Sited HERE — inside the
|
|
1940
|
+
-- chokepoint, not in the signed wrapper — because signed_grant_board_role is the only other
|
|
1941
|
+
-- referrer of this function in the whole database, so the signed route inherits this check
|
|
1942
|
+
-- by construction and the unsigned `authenticated` route (the wider door) is closed by the
|
|
1943
|
+
-- same line. Before the membership pre-check so the lock order is agents -> board_memberships,
|
|
1944
|
+
-- matching onboard_federated_fleet_agent; below the write it would be the inversion #269
|
|
1945
|
+
-- reproduced as `deadlock detected`, and this route has no advisory lock to be rescued by.
|
|
1946
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_grantor, 'grant_board_role');
|
|
1947
|
+
|
|
1948
|
+
if not exists (
|
|
1949
|
+
select 1 from agent_board.board_memberships bm
|
|
1950
|
+
where bm.board_id = p_board_id and bm.agent_id = p_agent_id
|
|
1951
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1952
|
+
) then
|
|
1953
|
+
raise exception 'grantee has no membership on this board' using errcode = 'P0002';
|
|
1954
|
+
end if;
|
|
1955
|
+
|
|
1956
|
+
-- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
|
|
1957
|
+
if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
|
|
1958
|
+
raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
|
|
1959
|
+
end if;
|
|
1960
|
+
|
|
1961
|
+
update agent_board.board_memberships bm
|
|
1962
|
+
set role = p_grantee_role,
|
|
1963
|
+
updated_at = now()
|
|
1964
|
+
where bm.board_id = p_board_id
|
|
1965
|
+
and bm.agent_id = p_agent_id
|
|
1966
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1967
|
+
returning * into v_updated;
|
|
1968
|
+
|
|
1969
|
+
-- CR finding: the state predicate above closes the unauthorized WRITE, but on its own it
|
|
1970
|
+
-- CREATES a false-success path. If an eviction commits between the pre-check and this
|
|
1971
|
+
-- UPDATE (the TOCTOU window the mirrored predicate exists for), the UPDATE matches zero
|
|
1972
|
+
-- rows, v_updated stays NULL, and the RETURN QUERY below would emit ONE ALL-NULL ROW with
|
|
1973
|
+
-- no error -- a caller that checks "did I get a row back?" reads that as a completed grant.
|
|
1974
|
+
-- Fail closed with the same P0002 the pre-check raises, so the serial and racing cases are
|
|
1975
|
+
-- indistinguishable to the caller.
|
|
1976
|
+
if not found then
|
|
1977
|
+
raise exception 'grantee has no membership on this board' using errcode = 'P0002';
|
|
1978
|
+
end if;
|
|
1979
|
+
|
|
1980
|
+
return query
|
|
1981
|
+
select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
|
|
1982
|
+
end;
|
|
1983
|
+
$function$;
|
|
1984
|
+
|
|
1985
|
+
CREATE OR REPLACE FUNCTION agent_board.create_board(p_created_by_agent_id uuid, p_slug text, p_name text, p_description text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_visibility text DEFAULT 'private'::text)
|
|
1986
|
+
RETURNS TABLE(board_id uuid, slug text, name text, status text, visibility text, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
1987
|
+
LANGUAGE plpgsql
|
|
1988
|
+
SECURITY DEFINER
|
|
1989
|
+
SET search_path TO 'agent_board', 'public'
|
|
1990
|
+
AS $function$
|
|
1991
|
+
#variable_conflict use_column
|
|
1992
|
+
declare
|
|
1993
|
+
v_seed_state text;
|
|
1994
|
+
v_actor uuid;
|
|
1995
|
+
v_existing agent_board.boards%rowtype;
|
|
1996
|
+
v_board agent_board.boards%rowtype;
|
|
1997
|
+
begin
|
|
1998
|
+
if nullif(trim(p_slug), '') is null or nullif(trim(p_name), '') is null then
|
|
1999
|
+
raise exception 'board slug and name are required' using errcode = '22023';
|
|
2000
|
+
end if;
|
|
2001
|
+
if coalesce(p_visibility, 'private') not in ('private', 'invite_only', 'open', 'directory_hidden') then
|
|
2002
|
+
raise exception 'invalid board visibility' using errcode = '22023';
|
|
2003
|
+
end if;
|
|
2004
|
+
|
|
2005
|
+
-- Effective actor: service_role -> supplied arg (create-on-behalf-of);
|
|
2006
|
+
-- authenticated -> current_agent_id() and rejects arg <> current (42501).
|
|
2007
|
+
v_actor := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
2008
|
+
|
|
2009
|
+
-- HOSTED (placement class): create_board seeds its creator's own owner seat directly. A
|
|
2010
|
+
-- hosted delegate is never seated except by add_delegate_to_board.
|
|
2011
|
+
perform agent_board._refuse_delegate_membership_target(v_actor, 'create_board');
|
|
2012
|
+
|
|
2013
|
+
if not exists (select 1 from agent_board.agents where id = v_actor) then
|
|
2014
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
2015
|
+
end if;
|
|
2016
|
+
|
|
2017
|
+
select * into v_existing
|
|
2018
|
+
from agent_board.boards b
|
|
2019
|
+
where b.slug = trim(p_slug);
|
|
2020
|
+
|
|
2021
|
+
if found then
|
|
2022
|
+
-- Slug-collision path is OWNER-ONLY, keyed on CURRENT authority: the actor must
|
|
2023
|
+
-- hold an ACTIVE owner-role membership on the existing board — NOT merely be its
|
|
2024
|
+
-- original `created_by_agent_id`. A demoted or departed original creator no longer
|
|
2025
|
+
-- owns the board and must NOT be able to re-open/rename/flip-visibility it.
|
|
2026
|
+
if not exists (
|
|
2027
|
+
select 1 from agent_board.board_memberships m
|
|
2028
|
+
where m.board_id = v_existing.id
|
|
2029
|
+
and m.agent_id = v_actor
|
|
2030
|
+
and m.role = 'owner'
|
|
2031
|
+
and m.membership_state in ('joined', 'subscribed')
|
|
2032
|
+
) then
|
|
2033
|
+
raise exception 'board slug already in use' using errcode = '42501';
|
|
2034
|
+
end if;
|
|
2035
|
+
|
|
2036
|
+
update agent_board.boards as b
|
|
2037
|
+
set name = trim(p_name),
|
|
2038
|
+
description = nullif(trim(p_description), ''),
|
|
2039
|
+
status = 'active',
|
|
2040
|
+
visibility = coalesce(p_visibility, 'private'),
|
|
2041
|
+
metadata = coalesce(b.metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb),
|
|
2042
|
+
updated_at = now()
|
|
2043
|
+
where b.id = v_existing.id
|
|
2044
|
+
returning * into v_board;
|
|
2045
|
+
-- No owner re-seed: the owner was seeded at this board's original creation, and
|
|
2046
|
+
-- re-promoting on every create_board would resurrect a deliberately demoted owner.
|
|
2047
|
+
else
|
|
2048
|
+
insert into agent_board.boards as b(slug, name, description, created_by_agent_id, visibility, metadata)
|
|
2049
|
+
values (
|
|
2050
|
+
trim(p_slug),
|
|
2051
|
+
trim(p_name),
|
|
2052
|
+
nullif(trim(p_description), ''),
|
|
2053
|
+
v_actor,
|
|
2054
|
+
coalesce(p_visibility, 'private'),
|
|
2055
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
2056
|
+
)
|
|
2057
|
+
returning * into v_board;
|
|
2058
|
+
|
|
2059
|
+
-- Seed the creator's owner membership, bound to the board's ACTUAL
|
|
2060
|
+
-- created_by_agent_id (never the raw param). Idempotent against the active
|
|
2061
|
+
-- partial-unique index so at most one active owner row exists.
|
|
2062
|
+
insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
|
|
2063
|
+
values (v_board.id, v_board.created_by_agent_id, 'joined', 'owner', v_board.created_by_agent_id)
|
|
2064
|
+
on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
|
|
2065
|
+
do update set role = 'owner', updated_at = now()
|
|
2066
|
+
returning bm.membership_state into v_seed_state;
|
|
2067
|
+
-- The creator seeds their OWN owner seat, so the session is the subject and this emits
|
|
2068
|
+
-- target-authored. Under service_role it emits operator-designated instead -- correct
|
|
2069
|
+
-- either way, because the helper reads the session rather than being told.
|
|
2070
|
+
--
|
|
2071
|
+
-- v_seed_state, NOT a literal 'joined'. The ON CONFLICT above lifts only the ROLE, so a
|
|
2072
|
+
-- board whose creator already holds a `subscribed` row stays subscribed while a literal
|
|
2073
|
+
-- would record `joined` -- a ledger contradicting the table. Same defect the redemption
|
|
2074
|
+
-- path had; fixing one instance without sweeping the class is how it survived here.
|
|
2075
|
+
perform agent_board._emit_membership_event(v_board.id, v_board.created_by_agent_id, v_seed_state, 'owner_seed');
|
|
2076
|
+
end if;
|
|
2077
|
+
|
|
2078
|
+
-- #280. Revoked means revoked: a fleet-revoked agent creates nothing. #276 gated the three
|
|
2079
|
+
-- authority chokepoints and both invite halves and left this function open on a stated
|
|
2080
|
+
-- argument (a fresh board confers nothing on anyone else); the owner overturned that in favour
|
|
2081
|
+
-- of the simpler rule, and this line is the whole of the change.
|
|
2082
|
+
--
|
|
2083
|
+
-- SITED HERE, at the convergence of both branches and AFTER every write, for the lock order.
|
|
2084
|
+
-- Fresh branch: boards(insert) -> board_memberships(insert, FK KEY SHARE on agents) ->
|
|
2085
|
+
-- agents(SHARE). Collision branch: boards(FOR UPDATE) -> agents(SHARE). Both are
|
|
2086
|
+
-- boards-before-agents, the canonical order. HOISTED above the slug read it would become
|
|
2087
|
+
-- agents(SHARE) -> boards(FOR UPDATE) on the collision branch, which is #269 F4's exact ABBA
|
|
2088
|
+
-- against onboard_federated_fleet_agent's boards -> agents. One transaction, so this raise
|
|
2089
|
+
-- rolls back the board, the owner seat and the owner_seed event alike -- position costs
|
|
2090
|
+
-- nothing in correctness and everything in deadlock safety.
|
|
2091
|
+
--
|
|
2092
|
+
-- On the fresh branch the FK on the seat insert has ALREADY taken KEY SHARE on this row, so
|
|
2093
|
+
-- the FOR SHARE is a free upgrade and is NOT what serializes that path (see the header). On
|
|
2094
|
+
-- the collision branch it is the only agents lock there is, and it is the serialization point.
|
|
2095
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'create_board');
|
|
2096
|
+
|
|
2097
|
+
return query
|
|
2098
|
+
select v_board.id, v_board.slug, v_board.name, v_board.status, v_board.visibility, v_board.created_at, v_board.updated_at;
|
|
2099
|
+
end;
|
|
2100
|
+
$function$;
|
|
2101
|
+
|
|
2102
|
+
CREATE OR REPLACE FUNCTION agent_board.bootstrap_board_owner_memberships()
|
|
2103
|
+
RETURNS TABLE(board_id uuid, owner_agent_id uuid, membership_id uuid)
|
|
2104
|
+
LANGUAGE plpgsql
|
|
2105
|
+
SECURITY DEFINER
|
|
2106
|
+
SET search_path TO 'agent_board', 'public'
|
|
2107
|
+
AS $function$
|
|
2108
|
+
#variable_conflict use_column
|
|
2109
|
+
declare
|
|
2110
|
+
v_seed_state text;
|
|
2111
|
+
v_row record;
|
|
2112
|
+
v_membership_id uuid;
|
|
2113
|
+
begin
|
|
2114
|
+
-- service_role-only: current_agent_id() is null for service_role, non-null for any
|
|
2115
|
+
-- authenticated principal (guarded here in addition to the EXECUTE revoke below).
|
|
2116
|
+
if not agent_board._is_service_role() then
|
|
2117
|
+
raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
|
|
2118
|
+
end if;
|
|
2119
|
+
|
|
2120
|
+
-- R-LINK-7 guard shape (structural proof requires no-active AND no-removed
|
|
2121
|
+
-- contiguously): re-seed only when the creator has no active membership AND is
|
|
2122
|
+
-- not currently removed. NEVER suppress on 'left' alone — that is the
|
|
2123
|
+
-- sole-owner-lockout recovery trigger (agent_board_rolereset.test.sql assertion
|
|
2124
|
+
-- 23). requested/invited-only creators still re-seed; a removed+left pair is
|
|
2125
|
+
-- suppressed.
|
|
2126
|
+
for v_row in
|
|
2127
|
+
select b.id as board_id, b.created_by_agent_id as owner_agent_id
|
|
2128
|
+
from agent_board.boards b
|
|
2129
|
+
where not exists (
|
|
2130
|
+
select 1
|
|
2131
|
+
from agent_board.board_memberships bm
|
|
2132
|
+
where bm.board_id = b.id
|
|
2133
|
+
and bm.agent_id = b.created_by_agent_id
|
|
2134
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
2135
|
+
)
|
|
2136
|
+
and not exists (
|
|
2137
|
+
select 1
|
|
2138
|
+
from agent_board.board_memberships bm
|
|
2139
|
+
where bm.board_id = b.id
|
|
2140
|
+
and bm.agent_id = b.created_by_agent_id
|
|
2141
|
+
and bm.membership_state = 'removed'
|
|
2142
|
+
)
|
|
2143
|
+
loop
|
|
2144
|
+
-- HOSTED (placement class): bootstrap seats a board creator directly; never a delegate.
|
|
2145
|
+
perform agent_board._refuse_delegate_membership_target(v_row.owner_agent_id, 'bootstrap_board_owner_memberships');
|
|
2146
|
+
insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
|
|
2147
|
+
values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
|
|
2148
|
+
on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
|
|
2149
|
+
do update set role = 'owner', updated_at = now()
|
|
2150
|
+
returning bm.id, bm.membership_state into v_membership_id, v_seed_state;
|
|
2151
|
+
|
|
2152
|
+
-- Operator repair path: always operator-designated, never the owner's own act.
|
|
2153
|
+
-- v_seed_state, NOT a literal: the ON CONFLICT lifts only the ROLE, so a concurrently
|
|
2154
|
+
-- created `subscribed` membership stays subscribed while a literal would record `joined`.
|
|
2155
|
+
perform agent_board._emit_membership_event(v_row.board_id, v_row.owner_agent_id, v_seed_state, 'operator_repair');
|
|
2156
|
+
|
|
2157
|
+
board_id := v_row.board_id;
|
|
2158
|
+
owner_agent_id := v_row.owner_agent_id;
|
|
2159
|
+
membership_id := v_membership_id;
|
|
2160
|
+
return next;
|
|
2161
|
+
end loop;
|
|
2162
|
+
end;
|
|
2163
|
+
$function$;
|
|
2164
|
+
|
|
2165
|
+
CREATE OR REPLACE FUNCTION agent_board._fedsession_autojoin_default_board(p_provider agent_board.identity_providers, p_agent_id uuid)
|
|
2166
|
+
RETURNS void
|
|
2167
|
+
LANGUAGE plpgsql
|
|
2168
|
+
SECURITY DEFINER
|
|
2169
|
+
SET search_path TO 'agent_board', 'public'
|
|
2170
|
+
AS $function$
|
|
2171
|
+
begin
|
|
2172
|
+
if p_provider.default_board_id is null then
|
|
2173
|
+
return;
|
|
2174
|
+
end if;
|
|
2175
|
+
-- HOSTED (placement class): the auto-join seats a federated human directly; never a delegate.
|
|
2176
|
+
perform agent_board._refuse_delegate_membership_target(p_agent_id, 'fedsession auto-join');
|
|
2177
|
+
-- The NOT EXISTS below is FIRST-TIME-ONLY (any existing membership row, incl. 'left'/
|
|
2178
|
+
-- 'removed', suppresses the auto-join so a human who deliberately left is not re-joined).
|
|
2179
|
+
-- Under concurrency it is NOT sufficient on its own: two concurrent re-provisions of the
|
|
2180
|
+
-- same already-provisioned human can both pass NOT EXISTS, and the second insert then trips
|
|
2181
|
+
-- the partial unique index idx_agent_board_board_memberships_active_unique. Catch that
|
|
2182
|
+
-- unique_violation to a no-op (mirrors enroll_federated_human's step-8 agent-insert race
|
|
2183
|
+
-- handling) so auto-join is NEVER fatal to the session mint and never duplicates.
|
|
2184
|
+
begin
|
|
2185
|
+
insert into agent_board.board_memberships(
|
|
2186
|
+
board_id, agent_id, role, membership_state, created_by_agent_id, metadata)
|
|
2187
|
+
select p_provider.default_board_id, p_agent_id, 'member', 'joined', p_agent_id,
|
|
2188
|
+
jsonb_build_object('source', 'fedsession-autojoin', 'providerId', p_provider.id)
|
|
2189
|
+
where exists (
|
|
2190
|
+
select 1 from agent_board.boards b
|
|
2191
|
+
where b.id = p_provider.default_board_id
|
|
2192
|
+
and b.status = 'active')
|
|
2193
|
+
and not exists (
|
|
2194
|
+
select 1 from agent_board.board_memberships m
|
|
2195
|
+
where m.board_id = p_provider.default_board_id
|
|
2196
|
+
and m.agent_id = p_agent_id);
|
|
2197
|
+
|
|
2198
|
+
-- FOUND is true only if the conditional insert actually placed a row. The NOT EXISTS can
|
|
2199
|
+
-- suppress it entirely, and the race branch below is a no-op -- neither must emit an event
|
|
2200
|
+
-- for a placement that did not happen.
|
|
2201
|
+
if found then
|
|
2202
|
+
-- A LITERAL is correct here, and only here. The other writers use ON CONFLICT DO UPDATE,
|
|
2203
|
+
-- which can preserve a DIFFERENT existing state -- so a literal there records an outcome
|
|
2204
|
+
-- the row does not have. This insert has NO conflict-update path: it either inserts
|
|
2205
|
+
-- exactly this state or inserts nothing, and `found` gates the emit. Do not copy the
|
|
2206
|
+
-- literal to a writer that upserts.
|
|
2207
|
+
perform agent_board._emit_membership_event(p_provider.default_board_id, p_agent_id, 'joined', 'operator_designation');
|
|
2208
|
+
end if;
|
|
2209
|
+
exception when unique_violation then
|
|
2210
|
+
null; -- concurrent auto-join won the race; its membership stands. Non-fatal.
|
|
2211
|
+
end;
|
|
2212
|
+
end;
|
|
2213
|
+
$function$;
|
|
2214
|
+
|
|
2215
|
+
CREATE OR REPLACE FUNCTION agent_board._redeem_unbounded_invite_for_agent(p_code text, p_agent_id uuid)
|
|
2216
|
+
RETURNS TABLE(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamp with time zone)
|
|
2217
|
+
LANGUAGE plpgsql
|
|
2218
|
+
SECURITY DEFINER
|
|
2219
|
+
SET search_path TO 'agent_board', 'public'
|
|
2220
|
+
AS $function$
|
|
2221
|
+
-- OUT params board_id/agent_id/role/membership_state/updated_at share names with the
|
|
2222
|
+
-- membership columns referenced in the on-conflict target + update set below; prefer
|
|
2223
|
+
-- the column in any collision (mirrors ROOMOWN create_board).
|
|
2224
|
+
#variable_conflict use_column
|
|
2225
|
+
declare
|
|
2226
|
+
v_inv agent_board.board_invite_tokens%rowtype;
|
|
2227
|
+
v_led agent_board.board_invite_redemptions%rowtype;
|
|
2228
|
+
v_m agent_board.board_memberships%rowtype;
|
|
2229
|
+
begin
|
|
2230
|
+
-- HOSTED (placement class): a hosted delegate redeems no invite; this helper seats directly.
|
|
2231
|
+
perform agent_board._refuse_delegate_membership_target(p_agent_id, 'invite redemption');
|
|
2232
|
+
-- Lock the token row FIRST (concurrency invariant: the exhaustion check and the
|
|
2233
|
+
-- same-txn uses_count increment happen under this lock, and concurrent redeemers
|
|
2234
|
+
-- of the same token serialize here).
|
|
2235
|
+
select * into v_inv
|
|
2236
|
+
from agent_board.board_invite_tokens t
|
|
2237
|
+
where t.code = p_code
|
|
2238
|
+
for update;
|
|
2239
|
+
if not found then
|
|
2240
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2241
|
+
end if;
|
|
2242
|
+
|
|
2243
|
+
-- M1 idempotency ordering: an existing (invite, agent) ledger row means this is a
|
|
2244
|
+
-- replay -> return the existing membership and SKIP every exhaustion/ceiling check
|
|
2245
|
+
-- (a maxed link re-redeemed by the same agent is a no-op, NOT 42501).
|
|
2246
|
+
select * into v_led
|
|
2247
|
+
from agent_board.board_invite_redemptions r
|
|
2248
|
+
where r.invite_id = v_inv.id
|
|
2249
|
+
and r.agent_id = p_agent_id;
|
|
2250
|
+
if found then
|
|
2251
|
+
select * into v_m
|
|
2252
|
+
from agent_board.board_memberships bm
|
|
2253
|
+
where bm.id = v_led.membership_id;
|
|
2254
|
+
return query
|
|
2255
|
+
select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
|
|
2256
|
+
return;
|
|
2257
|
+
end if;
|
|
2258
|
+
|
|
2259
|
+
-- Genuinely new redemption: uniform 42501 for revoked / expired / exhausted
|
|
2260
|
+
-- (no P0002-vs-42501 existence oracle).
|
|
2261
|
+
if v_inv.revoked_at is not null then
|
|
2262
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2263
|
+
end if;
|
|
2264
|
+
if v_inv.expires_at <= now() then
|
|
2265
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2266
|
+
end if;
|
|
2267
|
+
if v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses then
|
|
2268
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2269
|
+
end if;
|
|
2270
|
+
|
|
2271
|
+
-- R-LINK-2 suspension containment: the creator must still be an ACTIVE agent
|
|
2272
|
+
-- (revoke_agent suspends but does not drop memberships, so an outstanding link
|
|
2273
|
+
-- from a compromised-then-suspended owner must stop minting).
|
|
2274
|
+
if not exists (
|
|
2275
|
+
select 1 from agent_board.agents a
|
|
2276
|
+
where a.id = v_inv.created_by_agent_id and a.status = 'active'
|
|
2277
|
+
) then
|
|
2278
|
+
-- Uniform message (see other 42501s) so a code-bearer cannot distinguish
|
|
2279
|
+
-- "creator suspended" from a dead/expired/exhausted link (no lifecycle oracle).
|
|
2280
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2281
|
+
end if;
|
|
2282
|
+
|
|
2283
|
+
-- #276 fleet containment, the sibling of R-LINK-2 one column over. Fleet revocation
|
|
2284
|
+
-- deliberately leaves agents.status = 'active', so the check above passes for a
|
|
2285
|
+
-- ledger-revoked creator and an outstanding admin link kept conferring admin -- the same
|
|
2286
|
+
-- principal the send path already refuses. Keyed on the CREATOR because the creator is who
|
|
2287
|
+
-- confers here; the redeemer is receiving, and its own standing is handled by
|
|
2288
|
+
-- current_agent_id() and by the R-LINK-7 clause below.
|
|
2289
|
+
--
|
|
2290
|
+
-- UNLOCKED (_actor_fleet_revoked, not the share-locking assert) and with the SAME uniform
|
|
2291
|
+
-- message as every other arm. Both choices are load-bearing, not stylistic: see the header.
|
|
2292
|
+
if agent_board._actor_fleet_revoked(v_inv.created_by_agent_id) then
|
|
2293
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2294
|
+
end if;
|
|
2295
|
+
|
|
2296
|
+
-- Re-bind: the creator's CURRENT authority must still cover the preset role
|
|
2297
|
+
-- (stale admin-link auto-degrades if the creator was demoted).
|
|
2298
|
+
if not agent_board._grant_within_ceiling(v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role) then
|
|
2299
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2300
|
+
end if;
|
|
2301
|
+
|
|
2302
|
+
-- R-LINK-7 kicked-agent re-entry: refuse when the agent's CURRENT standing on the
|
|
2303
|
+
-- board is 'removed' — i.e. a 'removed' row exists AND there is no active
|
|
2304
|
+
-- (joined/subscribed) membership. The active partial-unique index does NOT cover
|
|
2305
|
+
-- 'removed' rows, so a fresh 'joined' insert would otherwise bypass revocation +
|
|
2306
|
+
-- join-approval. Scoping to "removed and not currently active" avoids a false
|
|
2307
|
+
-- negative for an agent who was removed and then legitimately re-added (an active
|
|
2308
|
+
-- row present ⇒ the rank-max upsert handles them correctly). Voluntary 'left' is
|
|
2309
|
+
-- intentionally NOT blocked (self-departure).
|
|
2310
|
+
if exists (
|
|
2311
|
+
select 1 from agent_board.board_memberships bm
|
|
2312
|
+
where bm.board_id = v_inv.board_id
|
|
2313
|
+
and bm.agent_id = p_agent_id
|
|
2314
|
+
and bm.membership_state = 'removed'
|
|
2315
|
+
) and not exists (
|
|
2316
|
+
select 1 from agent_board.board_memberships bm
|
|
2317
|
+
where bm.board_id = v_inv.board_id
|
|
2318
|
+
and bm.agent_id = p_agent_id
|
|
2319
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
2320
|
+
) then
|
|
2321
|
+
raise exception 'invite is not usable' using errcode = '42501';
|
|
2322
|
+
end if;
|
|
2323
|
+
|
|
2324
|
+
-- Record the ledger row (idempotent under concurrent same-agent redemption).
|
|
2325
|
+
insert into agent_board.board_invite_redemptions as r(invite_id, agent_id)
|
|
2326
|
+
values (v_inv.id, p_agent_id)
|
|
2327
|
+
on conflict (invite_id, agent_id) do nothing;
|
|
2328
|
+
|
|
2329
|
+
-- B1 atomic rank-max membership upsert: never downgrade a higher existing role,
|
|
2330
|
+
-- race-safe across concurrent tokens. On conflict targets the active partial-unique
|
|
2331
|
+
-- index; the CASE lifts ONLY when the preset outranks the current role.
|
|
2332
|
+
insert into agent_board.board_memberships (
|
|
2333
|
+
board_id, agent_id, membership_state, role, created_by_agent_id
|
|
2334
|
+
)
|
|
2335
|
+
values (
|
|
2336
|
+
v_inv.board_id, p_agent_id, v_inv.preset_membership_state, v_inv.preset_role,
|
|
2337
|
+
v_inv.created_by_agent_id
|
|
2338
|
+
)
|
|
2339
|
+
on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
|
|
2340
|
+
do update set
|
|
2341
|
+
role = case
|
|
2342
|
+
when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)
|
|
2343
|
+
then excluded.role
|
|
2344
|
+
else agent_board.board_memberships.role
|
|
2345
|
+
end,
|
|
2346
|
+
updated_at = now()
|
|
2347
|
+
returning * into v_m;
|
|
2348
|
+
|
|
2349
|
+
-- The redeemer places THEMSELVES -- the public wrapper passes current_agent_id() and takes no
|
|
2350
|
+
-- target parameter -- so this emits target-authored: redeeming a link IS the consenting act.
|
|
2351
|
+
--
|
|
2352
|
+
-- v_m.membership_state, NOT v_inv.preset_membership_state. The ON CONFLICT above lifts only
|
|
2353
|
+
-- the ROLE and leaves an existing membership_state untouched, so an already-`subscribed`
|
|
2354
|
+
-- member redeeming a `joined` link stays subscribed. Emitting the token's preset would
|
|
2355
|
+
-- record a resulting state the row does not have -- a ledger that contradicts the table it
|
|
2356
|
+
-- describes. Recording the redemption as a fresh consent act is right; recording the wrong
|
|
2357
|
+
-- outcome is not.
|
|
2358
|
+
perform agent_board._emit_membership_event(
|
|
2359
|
+
v_inv.board_id, p_agent_id, v_m.membership_state, 'consent_link_redeem');
|
|
2360
|
+
|
|
2361
|
+
-- Consume one use and bind the ledger row to the resulting membership.
|
|
2362
|
+
update agent_board.board_invite_tokens t
|
|
2363
|
+
set uses_count = t.uses_count + 1
|
|
2364
|
+
where t.id = v_inv.id;
|
|
2365
|
+
|
|
2366
|
+
update agent_board.board_invite_redemptions r
|
|
2367
|
+
set membership_id = v_m.id
|
|
2368
|
+
where r.invite_id = v_inv.id
|
|
2369
|
+
and r.agent_id = p_agent_id;
|
|
2370
|
+
|
|
2371
|
+
return query
|
|
2372
|
+
select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
|
|
2373
|
+
end;
|
|
2374
|
+
$function$;
|
|
2375
|
+
|
|
2376
|
+
CREATE OR REPLACE FUNCTION agent_board.create_task(p_thread_id uuid, p_created_by_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_parent_task_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_orchestration_mode text DEFAULT 'distributed'::text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
2377
|
+
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)
|
|
2378
|
+
LANGUAGE plpgsql
|
|
2379
|
+
SECURITY DEFINER
|
|
2380
|
+
SET search_path TO 'agent_board', 'public'
|
|
2381
|
+
AS $function$
|
|
2382
|
+
declare
|
|
2383
|
+
v_task_id uuid;
|
|
2384
|
+
v_root_task_id uuid;
|
|
2385
|
+
v_event_id uuid;
|
|
2386
|
+
v_actor_agent_id uuid;
|
|
2387
|
+
begin
|
|
2388
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
2389
|
+
|
|
2390
|
+
if nullif(trim(p_title), '') is null then
|
|
2391
|
+
raise exception 'task title is required' using errcode = '22023';
|
|
2392
|
+
end if;
|
|
2393
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
2394
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
2395
|
+
end if;
|
|
2396
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
2397
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
2398
|
+
end if;
|
|
2399
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role to author.
|
|
2400
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
2401
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
2402
|
+
end if;
|
|
2403
|
+
if p_assigned_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_assigned_agent_id) then
|
|
2404
|
+
raise exception 'assigned agent not found' using errcode = 'P0002';
|
|
2405
|
+
end if;
|
|
2406
|
+
-- HOSTED (D16): a hosted delegate can act on no task, so it is never an assignee.
|
|
2407
|
+
perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'task assignment');
|
|
2408
|
+
|
|
2409
|
+
if p_parent_task_id is not null then
|
|
2410
|
+
select coalesce(tasks.root_task_id, tasks.id)
|
|
2411
|
+
into v_root_task_id
|
|
2412
|
+
from agent_board.tasks
|
|
2413
|
+
where id = p_parent_task_id and tasks.thread_id = p_thread_id;
|
|
2414
|
+
if v_root_task_id is null then
|
|
2415
|
+
raise exception 'parent task not found in thread' using errcode = '22023';
|
|
2416
|
+
end if;
|
|
2417
|
+
if not agent_board._current_agent_can_access_task(p_parent_task_id) then
|
|
2418
|
+
raise exception 'parent task not accessible to current agent' using errcode = '42501';
|
|
2419
|
+
end if;
|
|
2420
|
+
end if;
|
|
2421
|
+
|
|
2422
|
+
v_task_id := gen_random_uuid();
|
|
2423
|
+
if p_parent_task_id is null then
|
|
2424
|
+
v_root_task_id := v_task_id;
|
|
2425
|
+
end if;
|
|
2426
|
+
|
|
2427
|
+
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)
|
|
2428
|
+
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));
|
|
2429
|
+
|
|
2430
|
+
-- #288: create_task is the ONE writer that inserts into task_events directly rather than
|
|
2431
|
+
-- through the funnel, which is why a fix instrumenting only append_task_event's callers
|
|
2432
|
+
-- would miss it. Its own declared posture is signed-metadata (it declares p_metadata and the
|
|
2433
|
+
-- client signs it). No parameter is threaded here deliberately -- see the plan's section 5:
|
|
2434
|
+
-- where a signed wrapper is involved (signed_create_tasking_delivery keys its evidence to
|
|
2435
|
+
-- THIS event, two levels up), a real signature_evidence row exists and v_task_tree already
|
|
2436
|
+
-- surfaces it, which is the stronger signal. A threaded parameter would change no answer.
|
|
2437
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
2438
|
+
values (v_task_id, p_thread_id, v_actor_agent_id, 1, 'created', jsonb_build_object('title', trim(p_title)), 'unattested:signed-metadata-rpc')
|
|
2439
|
+
returning id into v_event_id;
|
|
2440
|
+
|
|
2441
|
+
return query
|
|
2442
|
+
select t.id, t.parent_task_id, t.root_task_id, v_event_id, 1::bigint, t.status, t.created_at, t.updated_at
|
|
2443
|
+
from agent_board.tasks t
|
|
2444
|
+
where t.id = v_task_id;
|
|
2445
|
+
end;
|
|
2446
|
+
$function$;
|
|
2447
|
+
|
|
2448
|
+
CREATE OR REPLACE FUNCTION agent_board._append_task_event(p_origin_posture text, p_task_id uuid, p_actor_agent_id uuid, p_event_type text, p_payload jsonb DEFAULT '{}'::jsonb, p_status text DEFAULT NULL::text, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_completed_at timestamp with time zone DEFAULT NULL::timestamp with time zone)
|
|
2449
|
+
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)
|
|
2450
|
+
LANGUAGE plpgsql
|
|
2451
|
+
SECURITY DEFINER
|
|
2452
|
+
SET search_path TO 'agent_board', 'public'
|
|
2453
|
+
AS $function$
|
|
2454
|
+
declare
|
|
2455
|
+
v_thread_id uuid;
|
|
2456
|
+
v_locked_thread_id uuid;
|
|
2457
|
+
v_sequence bigint;
|
|
2458
|
+
v_event_id uuid;
|
|
2459
|
+
v_actor_agent_id uuid;
|
|
2460
|
+
begin
|
|
2461
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
2462
|
+
|
|
2463
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
2464
|
+
if v_thread_id is null then
|
|
2465
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
2466
|
+
end if;
|
|
2467
|
+
|
|
2468
|
+
perform 1 from agent_board.threads th where th.id = v_thread_id for key share;
|
|
2469
|
+
|
|
2470
|
+
select tasks.thread_id into v_locked_thread_id from agent_board.tasks where id = p_task_id for update;
|
|
2471
|
+
if v_locked_thread_id is null then
|
|
2472
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
2473
|
+
end if;
|
|
2474
|
+
if v_locked_thread_id is distinct from v_thread_id then
|
|
2475
|
+
raise exception 'task thread changed while acquiring locks' using errcode = '40001';
|
|
2476
|
+
end if;
|
|
2477
|
+
|
|
2478
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
2479
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
2480
|
+
end if;
|
|
2481
|
+
if not agent_board._current_agent_can_mutate_task(p_task_id) then
|
|
2482
|
+
raise exception 'task mutation requires a non-observer board role, or task assignment/creation'
|
|
2483
|
+
using errcode = '42501';
|
|
2484
|
+
end if;
|
|
2485
|
+
|
|
2486
|
+
-- HOSTED (D16): a hosted delegate can act on no task, so it is never an assignee.
|
|
2487
|
+
perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'task assignment');
|
|
2488
|
+
|
|
2489
|
+
select coalesce(max(te.event_sequence), 0) + 1 into v_sequence from agent_board.task_events te where te.task_id = p_task_id;
|
|
2490
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
2491
|
+
values (p_task_id, v_thread_id, v_actor_agent_id, v_sequence, p_event_type, coalesce(p_payload, '{}'::jsonb), p_origin_posture)
|
|
2492
|
+
returning id into v_event_id;
|
|
2493
|
+
|
|
2494
|
+
update agent_board.tasks
|
|
2495
|
+
set status = coalesce(p_status, tasks.status),
|
|
2496
|
+
assigned_agent_id = coalesce(p_assigned_agent_id, tasks.assigned_agent_id),
|
|
2497
|
+
completed_at = coalesce(p_completed_at, tasks.completed_at),
|
|
2498
|
+
updated_at = now()
|
|
2499
|
+
where id = p_task_id;
|
|
2500
|
+
|
|
2501
|
+
return query
|
|
2502
|
+
select t.id, t.thread_id, v_event_id, v_sequence, t.status, t.assigned_agent_id, t.updated_at, t.completed_at
|
|
2503
|
+
from agent_board.tasks t
|
|
2504
|
+
where t.id = p_task_id;
|
|
2505
|
+
end;
|
|
2506
|
+
$function$;
|
|
2507
|
+
|
|
2508
|
+
CREATE OR REPLACE FUNCTION agent_board.delegate_task(p_parent_task_id uuid, p_actor_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_delegated_to_agent_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_note text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
2509
|
+
RETURNS TABLE(delegation_id uuid, child_task_id uuid, parent_task_id uuid, root_task_id uuid, delegation_status text, child_event_sequence bigint, parent_event_sequence bigint, created_at timestamp with time zone)
|
|
2510
|
+
LANGUAGE plpgsql
|
|
2511
|
+
SECURITY DEFINER
|
|
2512
|
+
SET search_path TO 'agent_board', 'public'
|
|
2513
|
+
AS $function$
|
|
2514
|
+
declare
|
|
2515
|
+
v_parent agent_board.tasks%rowtype;
|
|
2516
|
+
v_child record;
|
|
2517
|
+
v_delegation_id uuid;
|
|
2518
|
+
v_parent_event record;
|
|
2519
|
+
v_actor_agent_id uuid;
|
|
2520
|
+
begin
|
|
2521
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
2522
|
+
|
|
2523
|
+
select * into v_parent from agent_board.tasks where id = p_parent_task_id;
|
|
2524
|
+
if v_parent.id is null then
|
|
2525
|
+
raise exception 'parent task not found' using errcode = 'P0002';
|
|
2526
|
+
end if;
|
|
2527
|
+
if not agent_board._current_agent_can_access_task(p_parent_task_id) then
|
|
2528
|
+
raise exception 'parent task not accessible to current agent' using errcode = '42501';
|
|
2529
|
+
end if;
|
|
2530
|
+
|
|
2531
|
+
select * into v_child
|
|
2532
|
+
from agent_board.create_task(v_parent.thread_id, v_actor_agent_id, p_title, p_details, p_delegated_to_agent_id, p_parent_task_id, p_priority, 'hierarchical', p_metadata);
|
|
2533
|
+
|
|
2534
|
+
-- HOSTED (D16): a hosted delegate can act on no task, so nothing is delegated to it.
|
|
2535
|
+
perform agent_board._refuse_delegate_addressee(p_delegated_to_agent_id, null, 'task delegation');
|
|
2536
|
+
insert into agent_board.delegations(parent_task_id, child_task_id, delegated_by_agent_id, delegated_to_agent_id, note, metadata)
|
|
2537
|
+
values (p_parent_task_id, v_child.task_id, v_actor_agent_id, p_delegated_to_agent_id, p_note, coalesce(p_metadata, '{}'::jsonb))
|
|
2538
|
+
returning id into v_delegation_id;
|
|
2539
|
+
|
|
2540
|
+
select * into v_parent_event
|
|
2541
|
+
from agent_board._append_task_event('unattested:signed-metadata-rpc', p_parent_task_id, v_actor_agent_id, 'delegated', jsonb_build_object('delegation_id', v_delegation_id, 'child_task_id', v_child.task_id));
|
|
2542
|
+
|
|
2543
|
+
return query
|
|
2544
|
+
select v_delegation_id, v_child.task_id, p_parent_task_id, v_child.root_task_id, 'requested'::text, v_child.created_event_sequence, v_parent_event.event_sequence, now();
|
|
2545
|
+
end;
|
|
2546
|
+
$function$;
|
|
2547
|
+
|
|
2548
|
+
CREATE OR REPLACE FUNCTION agent_board.raise_escalation(p_thread_id uuid, p_raised_by_agent_id uuid, p_summary text, p_escalation_type text DEFAULT 'other'::text, p_severity text DEFAULT 'medium'::text, p_task_id uuid DEFAULT NULL::uuid, p_assigned_agent_id uuid DEFAULT NULL::uuid, p_details jsonb DEFAULT '{}'::jsonb)
|
|
2549
|
+
RETURNS TABLE(escalation_id uuid, status text, task_event_sequence bigint, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
2550
|
+
LANGUAGE plpgsql
|
|
2551
|
+
SECURITY DEFINER
|
|
2552
|
+
SET search_path TO 'agent_board', 'public'
|
|
2553
|
+
AS $function$
|
|
2554
|
+
declare
|
|
2555
|
+
v_escalation_id uuid;
|
|
2556
|
+
v_event_sequence bigint;
|
|
2557
|
+
v_actor_agent_id uuid;
|
|
2558
|
+
begin
|
|
2559
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_raised_by_agent_id, 'p_raised_by_agent_id');
|
|
2560
|
+
|
|
2561
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
2562
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
2563
|
+
end if;
|
|
2564
|
+
-- OBSGATE-2 (#123): a board-linked thread requires a non-observer board role.
|
|
2565
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
2566
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
2567
|
+
end if;
|
|
2568
|
+
|
|
2569
|
+
-- HOSTED (D16): a hosted delegate can act on no escalation, so it is never an assignee.
|
|
2570
|
+
perform agent_board._refuse_delegate_addressee(p_assigned_agent_id, null, 'escalation assignment');
|
|
2571
|
+
|
|
2572
|
+
insert into agent_board.escalations(thread_id, task_id, raised_by_agent_id, assigned_agent_id, summary, escalation_type, severity, details)
|
|
2573
|
+
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))
|
|
2574
|
+
returning id into v_escalation_id;
|
|
2575
|
+
|
|
2576
|
+
-- v_event_sequence stays NULL for a thread-level escalation (no task) — fixes the same latent
|
|
2577
|
+
-- "record not assigned yet" bug on the no-task path.
|
|
2578
|
+
if p_task_id is not null then
|
|
2579
|
+
select event_sequence into v_event_sequence
|
|
2580
|
+
from agent_board._append_task_event('unattested:unsigned-deliberate-rpc', p_task_id, v_actor_agent_id, 'escalated', jsonb_build_object('escalation_id', v_escalation_id));
|
|
2581
|
+
end if;
|
|
2582
|
+
|
|
2583
|
+
return query
|
|
2584
|
+
select e.id, e.status, v_event_sequence, e.created_at, e.updated_at
|
|
2585
|
+
from agent_board.escalations e
|
|
2586
|
+
where e.id = v_escalation_id;
|
|
2587
|
+
end;
|
|
2588
|
+
$function$;
|
|
2589
|
+
|
|
2590
|
+
CREATE OR REPLACE FUNCTION agent_board._upsert_delivery_recipient(p_delivery_mode text, p_recipient_agent_id uuid, p_source_kind text, p_source_message_id uuid DEFAULT NULL::uuid, p_source_task_id uuid DEFAULT NULL::uuid, p_source_delegation_id uuid DEFAULT NULL::uuid, p_board_id uuid DEFAULT NULL::uuid, p_board_membership_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
2591
|
+
RETURNS agent_board.delivery_recipients
|
|
2592
|
+
LANGUAGE plpgsql
|
|
2593
|
+
SECURITY DEFINER
|
|
2594
|
+
SET search_path TO 'agent_board', 'public'
|
|
2595
|
+
AS $function$
|
|
2596
|
+
declare
|
|
2597
|
+
v_row agent_board.delivery_recipients%rowtype;
|
|
2598
|
+
begin
|
|
2599
|
+
-- HOSTED (D16): a delivery reaches a hosted delegate only through a board it is active on
|
|
2600
|
+
-- (the board-post fan-out to members). It can read nothing without a board.
|
|
2601
|
+
perform agent_board._refuse_delegate_addressee(p_recipient_agent_id, p_board_id, 'delivery');
|
|
2602
|
+
-- OBSGATE (#78): stamp the thread's owning board on first board delivery so
|
|
2603
|
+
-- post_message's posting gate can resolve it. Resolve the thread from WHICHEVER source the
|
|
2604
|
+
-- delivery carries — message (post_board_message), task (create_tasking_delivery), or
|
|
2605
|
+
-- delegation — so EVERY board-fanning path links the thread, not just the message-sourced one.
|
|
2606
|
+
-- First-board-wins (never overwrites); board-less deliveries (p_board_id null) never stamp.
|
|
2607
|
+
if p_board_id is not null then
|
|
2608
|
+
update agent_board.threads t
|
|
2609
|
+
set board_id = p_board_id
|
|
2610
|
+
where t.board_id is null
|
|
2611
|
+
and t.id = coalesce(
|
|
2612
|
+
(select m.thread_id from agent_board.messages m where m.id = p_source_message_id),
|
|
2613
|
+
(select tk.thread_id from agent_board.tasks tk where tk.id = p_source_task_id),
|
|
2614
|
+
(select tk.thread_id
|
|
2615
|
+
from agent_board.delegations d
|
|
2616
|
+
join agent_board.tasks tk on tk.id = d.child_task_id
|
|
2617
|
+
where d.id = p_source_delegation_id)
|
|
2618
|
+
);
|
|
2619
|
+
end if;
|
|
2620
|
+
|
|
2621
|
+
insert into agent_board.delivery_recipients(
|
|
2622
|
+
delivery_mode,
|
|
2623
|
+
recipient_agent_id,
|
|
2624
|
+
source_kind,
|
|
2625
|
+
source_message_id,
|
|
2626
|
+
source_task_id,
|
|
2627
|
+
source_delegation_id,
|
|
2628
|
+
board_id,
|
|
2629
|
+
board_membership_id,
|
|
2630
|
+
metadata
|
|
2631
|
+
)
|
|
2632
|
+
values (
|
|
2633
|
+
p_delivery_mode,
|
|
2634
|
+
p_recipient_agent_id,
|
|
2635
|
+
p_source_kind,
|
|
2636
|
+
p_source_message_id,
|
|
2637
|
+
p_source_task_id,
|
|
2638
|
+
p_source_delegation_id,
|
|
2639
|
+
p_board_id,
|
|
2640
|
+
p_board_membership_id,
|
|
2641
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
2642
|
+
)
|
|
2643
|
+
on conflict (recipient_agent_id, canonical_source_key) do update
|
|
2644
|
+
set metadata = coalesce(agent_board.delivery_recipients.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb)
|
|
2645
|
+
returning * into v_row;
|
|
2646
|
+
|
|
2647
|
+
return v_row;
|
|
2648
|
+
end;
|
|
2649
|
+
$function$;
|
|
2650
|
+
|
|
2651
|
+
CREATE OR REPLACE FUNCTION agent_board._upsert_inbox_item(p_delivery_recipient_id uuid, p_recipient_agent_id uuid, p_board_id uuid, p_delivery_mode text, p_source_kind text, p_source_message_id uuid DEFAULT NULL::uuid, p_source_task_id uuid DEFAULT NULL::uuid, p_source_delegation_id uuid DEFAULT NULL::uuid, p_subject text DEFAULT NULL::text, p_preview text DEFAULT NULL::text, p_priority text DEFAULT 'medium'::text, p_visible_in_inbox boolean DEFAULT true, p_visible_in_board_feed boolean DEFAULT false, p_fetch_descriptor jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
2652
|
+
RETURNS agent_board.inbox_items
|
|
2653
|
+
LANGUAGE plpgsql
|
|
2654
|
+
SECURITY DEFINER
|
|
2655
|
+
SET search_path TO 'agent_board', 'public'
|
|
2656
|
+
AS $function$
|
|
2657
|
+
declare
|
|
2658
|
+
v_row agent_board.inbox_items%rowtype;
|
|
2659
|
+
begin
|
|
2660
|
+
-- HOSTED (D16): as for deliveries, only through a board the delegate is active on.
|
|
2661
|
+
perform agent_board._refuse_delegate_addressee(p_recipient_agent_id, p_board_id, 'inbox item');
|
|
2662
|
+
insert into agent_board.inbox_items(
|
|
2663
|
+
delivery_recipient_id,
|
|
2664
|
+
recipient_agent_id,
|
|
2665
|
+
board_id,
|
|
2666
|
+
delivery_mode,
|
|
2667
|
+
source_kind,
|
|
2668
|
+
source_message_id,
|
|
2669
|
+
source_task_id,
|
|
2670
|
+
source_delegation_id,
|
|
2671
|
+
subject,
|
|
2672
|
+
preview,
|
|
2673
|
+
priority,
|
|
2674
|
+
visible_in_inbox,
|
|
2675
|
+
visible_in_board_feed,
|
|
2676
|
+
fetch_descriptor,
|
|
2677
|
+
metadata
|
|
2678
|
+
)
|
|
2679
|
+
values (
|
|
2680
|
+
p_delivery_recipient_id,
|
|
2681
|
+
p_recipient_agent_id,
|
|
2682
|
+
p_board_id,
|
|
2683
|
+
p_delivery_mode,
|
|
2684
|
+
p_source_kind,
|
|
2685
|
+
p_source_message_id,
|
|
2686
|
+
p_source_task_id,
|
|
2687
|
+
p_source_delegation_id,
|
|
2688
|
+
p_subject,
|
|
2689
|
+
p_preview,
|
|
2690
|
+
p_priority,
|
|
2691
|
+
p_visible_in_inbox,
|
|
2692
|
+
p_visible_in_board_feed,
|
|
2693
|
+
coalesce(p_fetch_descriptor, '{}'::jsonb),
|
|
2694
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
2695
|
+
)
|
|
2696
|
+
on conflict (delivery_recipient_id) do update
|
|
2697
|
+
set board_id = excluded.board_id,
|
|
2698
|
+
subject = excluded.subject,
|
|
2699
|
+
preview = excluded.preview,
|
|
2700
|
+
priority = excluded.priority,
|
|
2701
|
+
visible_in_inbox = excluded.visible_in_inbox,
|
|
2702
|
+
visible_in_board_feed = excluded.visible_in_board_feed,
|
|
2703
|
+
fetch_descriptor = excluded.fetch_descriptor,
|
|
2704
|
+
metadata = coalesce(agent_board.inbox_items.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb),
|
|
2705
|
+
updated_at = now()
|
|
2706
|
+
returning * into v_row;
|
|
2707
|
+
|
|
2708
|
+
return v_row;
|
|
2709
|
+
end;
|
|
2710
|
+
$function$;
|
|
2711
|
+
|
|
2712
|
+
CREATE OR REPLACE FUNCTION agent_board._resolve_addressing_recipient_rows(p_actor_agent_id uuid, p_recipient_descriptors jsonb)
|
|
2713
|
+
RETURNS TABLE(ordinal integer, recipient_agent_id uuid, handle text, display_name text, descriptor jsonb, warning text, denied_reason text)
|
|
2714
|
+
LANGUAGE plpgsql
|
|
2715
|
+
SECURITY DEFINER
|
|
2716
|
+
SET search_path TO 'agent_board', 'public'
|
|
2717
|
+
AS $function$
|
|
2718
|
+
declare
|
|
2719
|
+
v_descriptor jsonb;
|
|
2720
|
+
v_kind text;
|
|
2721
|
+
v_index integer := 0;
|
|
2722
|
+
v_match_count integer;
|
|
2723
|
+
v_group_alias text;
|
|
2724
|
+
v_role text;
|
|
2725
|
+
begin
|
|
2726
|
+
for v_descriptor in
|
|
2727
|
+
select value
|
|
2728
|
+
from jsonb_array_elements(agent_board._addressing_descriptors_array(p_recipient_descriptors))
|
|
2729
|
+
loop
|
|
2730
|
+
v_index := v_index + 1;
|
|
2731
|
+
v_kind := v_descriptor ->> 'kind';
|
|
2732
|
+
|
|
2733
|
+
if v_kind = 'explicit_set' then
|
|
2734
|
+
return query
|
|
2735
|
+
select v_index * 100000 + nested.ordinal,
|
|
2736
|
+
nested.recipient_agent_id,
|
|
2737
|
+
nested.handle,
|
|
2738
|
+
nested.display_name,
|
|
2739
|
+
nested.descriptor,
|
|
2740
|
+
nested.warning,
|
|
2741
|
+
nested.denied_reason
|
|
2742
|
+
from agent_board._resolve_addressing_recipient_rows(
|
|
2743
|
+
p_actor_agent_id,
|
|
2744
|
+
coalesce(v_descriptor -> 'recipients', '[]'::jsonb)
|
|
2745
|
+
) nested;
|
|
2746
|
+
elsif v_kind = 'agent_id' then
|
|
2747
|
+
select count(*) into v_match_count
|
|
2748
|
+
from agent_board.agents a
|
|
2749
|
+
where a.id = (v_descriptor ->> 'agentId')::uuid
|
|
2750
|
+
and a.status = 'active';
|
|
2751
|
+
|
|
2752
|
+
if v_match_count = 1 then
|
|
2753
|
+
-- HOSTED (D16, NEW-4): naming a hosted delegate explicitly is DENIED, so the dry run
|
|
2754
|
+
-- shows it and the send refuses the whole call (_addressing_agent_ids_or_raise).
|
|
2755
|
+
return query
|
|
2756
|
+
select v_index,
|
|
2757
|
+
case when a.is_delegate then null::uuid else a.id end,
|
|
2758
|
+
case when a.is_delegate then null::text else a.handle end,
|
|
2759
|
+
case when a.is_delegate then null::text else a.display_name end,
|
|
2760
|
+
v_descriptor, null::text,
|
|
2761
|
+
case when a.is_delegate then 'recipient_is_hosted_delegate'::text else null::text end
|
|
2762
|
+
from agent_board.agents a
|
|
2763
|
+
where a.id = (v_descriptor ->> 'agentId')::uuid
|
|
2764
|
+
and a.status = 'active';
|
|
2765
|
+
else
|
|
2766
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown agent_id'::text;
|
|
2767
|
+
end if;
|
|
2768
|
+
elsif v_kind in ('agent_handle', 'human_handle') then
|
|
2769
|
+
select count(*) into v_match_count
|
|
2770
|
+
from agent_board.agents a
|
|
2771
|
+
where a.handle = v_descriptor ->> 'handle'
|
|
2772
|
+
and a.status = 'active'
|
|
2773
|
+
and (
|
|
2774
|
+
(v_kind = 'agent_handle' and a.agent_type <> 'human')
|
|
2775
|
+
or (v_kind = 'human_handle' and a.agent_type = 'human')
|
|
2776
|
+
);
|
|
2777
|
+
|
|
2778
|
+
if v_match_count = 1 then
|
|
2779
|
+
-- HOSTED (D16, NEW-4): as for agent_id.
|
|
2780
|
+
return query
|
|
2781
|
+
select v_index,
|
|
2782
|
+
case when a.is_delegate then null::uuid else a.id end,
|
|
2783
|
+
case when a.is_delegate then null::text else a.handle end,
|
|
2784
|
+
case when a.is_delegate then null::text else a.display_name end,
|
|
2785
|
+
v_descriptor, null::text,
|
|
2786
|
+
case when a.is_delegate then 'recipient_is_hosted_delegate'::text else null::text end
|
|
2787
|
+
from agent_board.agents a
|
|
2788
|
+
where a.handle = v_descriptor ->> 'handle'
|
|
2789
|
+
and a.status = 'active'
|
|
2790
|
+
and (
|
|
2791
|
+
(v_kind = 'agent_handle' and a.agent_type <> 'human')
|
|
2792
|
+
or (v_kind = 'human_handle' and a.agent_type = 'human')
|
|
2793
|
+
);
|
|
2794
|
+
elsif v_match_count = 0 then
|
|
2795
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown handle'::text;
|
|
2796
|
+
else
|
|
2797
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'ambiguous handle'::text;
|
|
2798
|
+
end if;
|
|
2799
|
+
elsif v_kind = 'group_alias' then
|
|
2800
|
+
v_group_alias := nullif(trim(v_descriptor ->> 'groupAlias'), '');
|
|
2801
|
+
return query
|
|
2802
|
+
select v_index,
|
|
2803
|
+
a.id,
|
|
2804
|
+
a.handle,
|
|
2805
|
+
a.display_name,
|
|
2806
|
+
v_descriptor,
|
|
2807
|
+
null::text,
|
|
2808
|
+
null::text
|
|
2809
|
+
from agent_board.agents a
|
|
2810
|
+
where a.status = 'active'
|
|
2811
|
+
-- HOSTED (D16, NEW-4): an expansion names a set, not an agent; it never includes a
|
|
2812
|
+
-- hosted delegate, so one delegate cannot break every send to the set.
|
|
2813
|
+
and not a.is_delegate
|
|
2814
|
+
and (
|
|
2815
|
+
a.metadata ->> 'groupAlias' = v_group_alias
|
|
2816
|
+
or a.metadata ->> 'group_alias' = v_group_alias
|
|
2817
|
+
or coalesce(a.metadata -> 'groupAliases', '[]'::jsonb) ? v_group_alias
|
|
2818
|
+
or coalesce(a.metadata -> 'group_aliases', '[]'::jsonb) ? v_group_alias
|
|
2819
|
+
)
|
|
2820
|
+
order by a.handle, a.id;
|
|
2821
|
+
|
|
2822
|
+
if not found then
|
|
2823
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown group_alias'::text;
|
|
2824
|
+
end if;
|
|
2825
|
+
elsif v_kind = 'role_selector' then
|
|
2826
|
+
v_role := nullif(trim(v_descriptor ->> 'role'), '');
|
|
2827
|
+
return query
|
|
2828
|
+
select v_index,
|
|
2829
|
+
a.id,
|
|
2830
|
+
a.handle,
|
|
2831
|
+
a.display_name,
|
|
2832
|
+
v_descriptor,
|
|
2833
|
+
null::text,
|
|
2834
|
+
null::text
|
|
2835
|
+
from agent_board.agents a
|
|
2836
|
+
where a.status = 'active'
|
|
2837
|
+
-- HOSTED (D16, NEW-4): as for group_alias.
|
|
2838
|
+
and not a.is_delegate
|
|
2839
|
+
and (
|
|
2840
|
+
a.agent_type = v_role
|
|
2841
|
+
or a.kind::text = v_role
|
|
2842
|
+
or a.metadata ->> 'role' = v_role
|
|
2843
|
+
or coalesce(a.metadata -> 'roles', '[]'::jsonb) ? v_role
|
|
2844
|
+
or coalesce(a.metadata -> 'scopes', '[]'::jsonb) ? v_role
|
|
2845
|
+
)
|
|
2846
|
+
order by a.handle, a.id;
|
|
2847
|
+
|
|
2848
|
+
if not found then
|
|
2849
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unknown role_selector'::text;
|
|
2850
|
+
end if;
|
|
2851
|
+
else
|
|
2852
|
+
return query select v_index, null::uuid, null::text, null::text, v_descriptor, null::text, 'unsupported descriptor kind'::text;
|
|
2853
|
+
end if;
|
|
2854
|
+
end loop;
|
|
2855
|
+
end;
|
|
2856
|
+
$function$;
|
|
2857
|
+
|
|
2858
|
+
CREATE OR REPLACE FUNCTION agent_board._addressing_agent_ids_or_raise(p_actor_agent_id uuid, p_recipient_descriptors jsonb)
|
|
2859
|
+
RETURNS uuid[]
|
|
2860
|
+
LANGUAGE plpgsql
|
|
2861
|
+
SECURITY DEFINER
|
|
2862
|
+
SET search_path TO 'agent_board', 'public'
|
|
2863
|
+
AS $function$
|
|
2864
|
+
declare
|
|
2865
|
+
v_result record;
|
|
2866
|
+
v_ids uuid[];
|
|
2867
|
+
begin
|
|
2868
|
+
select * into v_result
|
|
2869
|
+
from agent_board.resolve_recipients(p_actor_agent_id, p_recipient_descriptors);
|
|
2870
|
+
|
|
2871
|
+
-- HOSTED (D16, NEW-4): naming a hosted delegate refuses the whole send as an authorization
|
|
2872
|
+
-- failure, before any delivery is written.
|
|
2873
|
+
if exists (
|
|
2874
|
+
select 1
|
|
2875
|
+
from jsonb_array_elements(v_result."deniedRecipients") d
|
|
2876
|
+
where d ->> 'reason' = 'recipient_is_hosted_delegate'
|
|
2877
|
+
) then
|
|
2878
|
+
raise exception 'a hosted delegate is reached only through a board it is on' using errcode = '42501', detail = v_result."deniedRecipients"::text;
|
|
2879
|
+
end if;
|
|
2880
|
+
|
|
2881
|
+
if jsonb_array_length(v_result."deniedRecipients") > 0 then
|
|
2882
|
+
raise exception 'recipient descriptor resolution failed' using errcode = '22023', detail = v_result."deniedRecipients"::text;
|
|
2883
|
+
end if;
|
|
2884
|
+
|
|
2885
|
+
select array_agg((value ->> 'agentId')::uuid)
|
|
2886
|
+
into v_ids
|
|
2887
|
+
from jsonb_array_elements(v_result."resolvedRecipients") as value;
|
|
2888
|
+
|
|
2889
|
+
if v_ids is null or cardinality(v_ids) = 0 then
|
|
2890
|
+
raise exception 'recipient descriptors resolved no recipients' using errcode = '22023';
|
|
2891
|
+
end if;
|
|
2892
|
+
|
|
2893
|
+
return v_ids;
|
|
2894
|
+
end;
|
|
2895
|
+
$function$;
|
|
2896
|
+
|
|
2897
|
+
CREATE OR REPLACE FUNCTION agent_board.post_message(p_thread_id uuid, p_author_agent_id uuid, p_body text, p_message_type text DEFAULT 'comment'::text, p_task_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
2898
|
+
RETURNS TABLE(message_id uuid, thread_id uuid, task_id uuid, message_sequence bigint, created_at timestamp with time zone)
|
|
2899
|
+
LANGUAGE plpgsql
|
|
2900
|
+
SECURITY DEFINER
|
|
2901
|
+
SET search_path TO 'agent_board', 'public'
|
|
2902
|
+
AS $function$
|
|
2903
|
+
declare
|
|
2904
|
+
v_sequence bigint;
|
|
2905
|
+
v_actor_agent_id uuid;
|
|
2906
|
+
v_metadata jsonb;
|
|
2907
|
+
v_instance agent_board.delegate_instances%rowtype;
|
|
2908
|
+
begin
|
|
2909
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
|
|
2910
|
+
|
|
2911
|
+
if nullif(trim(p_body), '') is null then
|
|
2912
|
+
raise exception 'message body is required' using errcode = '22023';
|
|
2913
|
+
end if;
|
|
2914
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
2915
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
2916
|
+
end if;
|
|
2917
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
2918
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
2919
|
+
end if;
|
|
2920
|
+
-- OBSGATE (#78): a board-linked thread under 'inherit' requires a non-observer board role.
|
|
2921
|
+
-- post_message is the sole authenticated writer to messages, so this gate cannot be bypassed
|
|
2922
|
+
-- by any send path. Board-less threads and service_role fall through (access check above).
|
|
2923
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
2924
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
2925
|
+
end if;
|
|
2926
|
+
if p_task_id is not null and not exists (select 1 from agent_board.tasks where id = p_task_id and tasks.thread_id = p_thread_id) then
|
|
2927
|
+
raise exception 'task not found in thread' using errcode = '22023';
|
|
2928
|
+
end if;
|
|
2929
|
+
|
|
2930
|
+
-- HOSTED attribution: the `delegate` key is written by the server, never by a caller. A
|
|
2931
|
+
-- hosted delegate's post carries it (overwriting anything supplied); every other author's
|
|
2932
|
+
-- caller-supplied `delegate` key is removed, so no keyed agent can forge the mark.
|
|
2933
|
+
-- client_verified is always false: under dynamic client registration anyone can name a client.
|
|
2934
|
+
v_metadata := coalesce(p_metadata, '{}'::jsonb);
|
|
2935
|
+
if jsonb_typeof(v_metadata) = 'object' then
|
|
2936
|
+
v_metadata := v_metadata - 'delegate';
|
|
2937
|
+
end if;
|
|
2938
|
+
select di.* into v_instance
|
|
2939
|
+
from agent_board.agents a
|
|
2940
|
+
join agent_board.delegate_instances di on di.agent_id = a.id
|
|
2941
|
+
where a.id = v_actor_agent_id
|
|
2942
|
+
and a.is_delegate;
|
|
2943
|
+
if found then
|
|
2944
|
+
if jsonb_typeof(v_metadata) <> 'object' then
|
|
2945
|
+
raise exception 'a hosted delegate''s message metadata must be a JSON object' using errcode = '22023';
|
|
2946
|
+
end if;
|
|
2947
|
+
v_metadata := v_metadata || jsonb_build_object('delegate', jsonb_build_object(
|
|
2948
|
+
'delegate', true,
|
|
2949
|
+
'instance_id', v_instance.instance_id,
|
|
2950
|
+
'owner_agent_id', agent_board._delegate_principal_agent_id(v_actor_agent_id),
|
|
2951
|
+
'client_id', v_instance.client_id,
|
|
2952
|
+
'client_verified', false));
|
|
2953
|
+
end if;
|
|
2954
|
+
|
|
2955
|
+
v_sequence := agent_board._next_thread_message_sequence(p_thread_id);
|
|
2956
|
+
|
|
2957
|
+
return query
|
|
2958
|
+
insert into agent_board.messages(thread_id, author_agent_id, task_id, message_sequence, message_type, body, metadata)
|
|
2959
|
+
values (p_thread_id, v_actor_agent_id, p_task_id, v_sequence, coalesce(p_message_type, 'comment'), p_body, v_metadata)
|
|
2960
|
+
returning id, messages.thread_id, messages.task_id, messages.message_sequence, messages.created_at;
|
|
2961
|
+
end;
|
|
2962
|
+
$function$;
|
|
2963
|
+
|
|
2964
|
+
CREATE OR REPLACE FUNCTION agent_board.get_bundle_compatibility()
|
|
2965
|
+
RETURNS jsonb
|
|
2966
|
+
LANGUAGE sql
|
|
2967
|
+
STABLE SECURITY DEFINER
|
|
2968
|
+
SET search_path TO 'agent_board', 'pg_temp'
|
|
2969
|
+
AS $function$
|
|
2970
|
+
select jsonb_build_object(
|
|
2971
|
+
'schema_version', (select version from agent_board.schema_version limit 1),
|
|
2972
|
+
'installed_bundle_version', (select package_version from agent_board.bundle_installation limit 1),
|
|
2973
|
+
'installed_bundle_digest', (select bundle_digest from agent_board.bundle_installation limit 1),
|
|
2974
|
+
'installed_schema_version', (select schema_version from agent_board.bundle_installation limit 1),
|
|
2975
|
+
'source_commit', (select source_commit from agent_board.bundle_installation limit 1),
|
|
2976
|
+
-- #326: which PROPOSITION the boolean below is answering, said out loud, so a
|
|
2977
|
+
-- reader does not have to consult a version boundary to decode it. A constant
|
|
2978
|
+
-- rather than a derivation: it names the model this bundle's predicate encodes,
|
|
2979
|
+
-- which is a fact about the migration you are reading, not about the database.
|
|
2980
|
+
'signed_write_model', 'operator-only-gateway',
|
|
2981
|
+
-- #310: enforcement means the SIGNED path stays operator-only, not that the unsigned path is
|
|
2982
|
+
-- unreachable. See 20260918000100's header for what this does and does not establish.
|
|
2983
|
+
'signed_write_enforced', (
|
|
2984
|
+
-- The anchor. A schema with no signed gateway grants none of them, which would make the
|
|
2985
|
+
-- second clause vacuously true; requiring the gateway to exist is what stops "enforced"
|
|
2986
|
+
-- from meaning "nothing to enforce".
|
|
2987
|
+
exists (
|
|
2988
|
+
select 1
|
|
2989
|
+
from pg_catalog.pg_proc p
|
|
2990
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
2991
|
+
where n.nspname = 'agent_board'
|
|
2992
|
+
and p.proname = 'signed_send_direct_message'
|
|
2993
|
+
)
|
|
2994
|
+
and not exists (
|
|
2995
|
+
-- Catalog-derived over pg_proc ROWS: covers all seven gateways and every overload of
|
|
2996
|
+
-- each, and asks the privilege RESOLVER rather than reading a direct-grant view.
|
|
2997
|
+
select 1
|
|
2998
|
+
from pg_catalog.pg_proc p
|
|
2999
|
+
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
|
|
3000
|
+
cross join (values ('authenticated'), ('anon'), ('agent_board_delegate')) as r(rolname)
|
|
3001
|
+
where n.nspname = 'agent_board'
|
|
3002
|
+
and p.proname like 'signed\_%'
|
|
3003
|
+
and pg_catalog.has_function_privilege(r.rolname, p.oid, 'EXECUTE')
|
|
3004
|
+
)
|
|
3005
|
+
),
|
|
3006
|
+
-- Ready only once the materializer has recorded an install AND the schema
|
|
3007
|
+
-- version handshake resolves.
|
|
3008
|
+
'ready', (
|
|
3009
|
+
(select version from agent_board.schema_version limit 1) is not null
|
|
3010
|
+
and exists (select 1 from agent_board.bundle_installation)
|
|
3011
|
+
),
|
|
3012
|
+
'checked_at', now()
|
|
3013
|
+
);
|
|
3014
|
+
$function$;
|
|
3015
|
+
|
|
3016
|
+
-- =============================================================================
|
|
3017
|
+
-- 12. The delegate read policies (D14, §The read closure)
|
|
3018
|
+
-- =============================================================================
|
|
3019
|
+
-- One SELECT policy per read_table, TO agent_board_delegate only. No authenticated predicate
|
|
3020
|
+
-- is reused. The caller's board set and identity are read through UNCORRELATED subqueries, so
|
|
3021
|
+
-- each is computed once per query rather than once per row (see section 6). No policy FOR INSERT, UPDATE, DELETE or ALL is created for the role.
|
|
3022
|
+
-- Each expression is stated ONCE, here; agent_board_delegate_role_class pins its deparse.
|
|
3023
|
+
create policy agent_board_delegate_select_boards
|
|
3024
|
+
on agent_board.boards for select to agent_board_delegate
|
|
3025
|
+
using (id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[])));
|
|
3026
|
+
|
|
3027
|
+
create policy agent_board_delegate_select_board_memberships
|
|
3028
|
+
on agent_board.board_memberships for select to agent_board_delegate
|
|
3029
|
+
using (agent_id = (select agent_board.current_agent_id()) and membership_state in ('joined', 'subscribed'));
|
|
3030
|
+
|
|
3031
|
+
create policy agent_board_delegate_select_agents
|
|
3032
|
+
on agent_board.agents for select to agent_board_delegate
|
|
3033
|
+
using (agent_board._delegate_visible_agent(id));
|
|
3034
|
+
|
|
3035
|
+
create policy agent_board_delegate_select_messages
|
|
3036
|
+
on agent_board.messages for select to agent_board_delegate
|
|
3037
|
+
using (exists (select 1 from agent_board.delivery_recipients dr
|
|
3038
|
+
where dr.source_message_id = messages.id
|
|
3039
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
|
|
3040
|
+
|
|
3041
|
+
create policy agent_board_delegate_select_delivery_recipients
|
|
3042
|
+
on agent_board.delivery_recipients for select to agent_board_delegate
|
|
3043
|
+
using (board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[])));
|
|
3044
|
+
|
|
3045
|
+
-- The own-or-feed conjunct is the authenticated policy's (20260713000000:80): per-recipient
|
|
3046
|
+
-- rows carry a board_id with visible_in_board_feed = false, and without it a delegate would
|
|
3047
|
+
-- read every co-member's triage rows. Recipiency here only NARROWS.
|
|
3048
|
+
create policy agent_board_delegate_select_inbox_items
|
|
3049
|
+
on agent_board.inbox_items for select to agent_board_delegate
|
|
3050
|
+
using (board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))
|
|
3051
|
+
and (recipient_agent_id = (select agent_board.current_agent_id()) or visible_in_board_feed));
|
|
3052
|
+
|
|
3053
|
+
create policy agent_board_delegate_select_tasks
|
|
3054
|
+
on agent_board.tasks for select to agent_board_delegate
|
|
3055
|
+
using (exists (select 1 from agent_board.delivery_recipients dr
|
|
3056
|
+
where dr.source_task_id = tasks.id
|
|
3057
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
|
|
3058
|
+
|
|
3059
|
+
create policy agent_board_delegate_select_task_events
|
|
3060
|
+
on agent_board.task_events for select to agent_board_delegate
|
|
3061
|
+
using (exists (select 1 from agent_board.delivery_recipients dr
|
|
3062
|
+
where dr.source_task_id = task_events.task_id
|
|
3063
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
|
|
3064
|
+
|
|
3065
|
+
create policy agent_board_delegate_select_claims
|
|
3066
|
+
on agent_board.claims for select to agent_board_delegate
|
|
3067
|
+
using (task_id is not null and exists (select 1 from agent_board.delivery_recipients dr
|
|
3068
|
+
where dr.source_task_id = claims.task_id
|
|
3069
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
|
|
3070
|
+
|
|
3071
|
+
create policy agent_board_delegate_select_escalations
|
|
3072
|
+
on agent_board.escalations for select to agent_board_delegate
|
|
3073
|
+
using (task_id is not null and exists (select 1 from agent_board.delivery_recipients dr
|
|
3074
|
+
where dr.source_task_id = escalations.task_id
|
|
3075
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))));
|
|
3076
|
+
|
|
3077
|
+
-- At least one source is non-null, and EVERY non-null source is visible. source_artifact_id is
|
|
3078
|
+
-- a source too, and artifacts are outside the read closure, so evidence over an artifact is
|
|
3079
|
+
-- invisible. The task-event and delivery subqueries are filtered by their own delegate
|
|
3080
|
+
-- policies. The authenticated policy's own-agent_id branch is not kept.
|
|
3081
|
+
create policy agent_board_delegate_select_signature_evidence
|
|
3082
|
+
on agent_board.signature_evidence for select to agent_board_delegate
|
|
3083
|
+
using ((source_message_id is not null or source_task_event_id is not null or source_delivery_recipient_id is not null)
|
|
3084
|
+
and source_artifact_id is null
|
|
3085
|
+
and (source_message_id is null
|
|
3086
|
+
or exists (select 1 from agent_board.delivery_recipients dr
|
|
3087
|
+
where dr.source_message_id = signature_evidence.source_message_id
|
|
3088
|
+
and dr.board_id = any (coalesce((select agent_board._delegate_visible_board_ids()), '{}'::uuid[]))))
|
|
3089
|
+
and (source_task_event_id is null
|
|
3090
|
+
or exists (select 1 from agent_board.task_events te where te.id = source_task_event_id))
|
|
3091
|
+
and (source_delivery_recipient_id is null
|
|
3092
|
+
or exists (select 1 from agent_board.delivery_recipients dr where dr.id = source_delivery_recipient_id)));
|
|
3093
|
+
|
|
3094
|
+
create policy agent_board_delegate_select_presence
|
|
3095
|
+
on agent_board.presence for select to agent_board_delegate
|
|
3096
|
+
using (false);
|
|
3097
|
+
|
|
3098
|
+
create policy agent_board_delegate_select_agent_signing_keys
|
|
3099
|
+
on agent_board.agent_signing_keys for select to agent_board_delegate
|
|
3100
|
+
using (false);
|
|
3101
|
+
|
|
3102
|
+
-- =============================================================================
|
|
3103
|
+
-- 13. Grants, FROM the allow set (§What it holds, exactly)
|
|
3104
|
+
-- =============================================================================
|
|
3105
|
+
-- EXECUTE on each rpc / ambient_rpc / predicate row, SELECT on each read_view / read_table
|
|
3106
|
+
-- row, and on agents only the eight columns authenticated holds (never auth_user_id or
|
|
3107
|
+
-- owner_user_id). Nothing else: no INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER,
|
|
3108
|
+
-- and no sequence privilege. A signature that names no function fails the apply, loudly.
|
|
3109
|
+
do $grants$
|
|
3110
|
+
declare
|
|
3111
|
+
r record;
|
|
3112
|
+
begin
|
|
3113
|
+
for r in
|
|
3114
|
+
select kind, name, signature from agent_board.delegate_allowed_operations order by kind, signature
|
|
3115
|
+
loop
|
|
3116
|
+
if r.kind in ('rpc', 'ambient_rpc', 'predicate') then
|
|
3117
|
+
execute format('grant execute on function agent_board.%s to agent_board_delegate', r.signature);
|
|
3118
|
+
elsif r.name = 'agents' then
|
|
3119
|
+
grant select (kind, display_name, agent_type, created_at, updated_at, id, handle, status)
|
|
3120
|
+
on agent_board.agents to agent_board_delegate;
|
|
3121
|
+
else
|
|
3122
|
+
execute format('grant select on agent_board.%I to agent_board_delegate', r.name);
|
|
3123
|
+
end if;
|
|
3124
|
+
end loop;
|
|
3125
|
+
end
|
|
3126
|
+
$grants$;
|
|
3127
|
+
|
|
3128
|
+
-- =============================================================================
|
|
3129
|
+
-- 14. Indexes for the per-row predicates (NEW-5, §Class assertions: the query plan)
|
|
3130
|
+
-- =============================================================================
|
|
3131
|
+
-- The predicates are STABLE SECURITY DEFINER functions the planner cannot inline into the
|
|
3132
|
+
-- feeds; each is an index probe per row. The plan evidence is in plans/evidence/hosted/.
|
|
3133
|
+
create index if not exists idx_agent_board_delivery_recipients_source_message_board
|
|
3134
|
+
on agent_board.delivery_recipients (source_message_id, board_id)
|
|
3135
|
+
where source_message_id is not null;
|
|
3136
|
+
|
|
3137
|
+
create index if not exists idx_agent_board_delivery_recipients_source_task_board
|
|
3138
|
+
on agent_board.delivery_recipients (source_task_id, board_id)
|
|
3139
|
+
where source_task_id is not null;
|
|
3140
|
+
|
|
3141
|
+
-- _delegate_visible_agent's past-author branch.
|
|
3142
|
+
create index if not exists idx_agent_board_messages_author
|
|
3143
|
+
on agent_board.messages (author_agent_id);
|
|
3144
|
+
|
|
3145
|
+
-- =============================================================================
|
|
3146
|
+
-- 15. The ceiling lock's machine-read COMMENT gains its new reader
|
|
3147
|
+
-- =============================================================================
|
|
3148
|
+
-- add_delegate_to_board reads the ceiling for authorization (_grant_within_ceiling) and takes
|
|
3149
|
+
-- the lock. agent_board_atomic_fleet_onboarding.test.sql compares these lists with pg_proc.
|
|
3150
|
+
-- Restated in full: COMMENT ON replaces, it does not append.
|
|
3151
|
+
comment on function agent_board._lock_board_authority_ceiling(uuid) is
|
|
3152
|
+
'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row. Held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] -- the RPCs that write board_authority_ceilings. set_board_authority_ceiling takes this lock before its owner gate and its upsert, and therefore before the boards row that upsert reaches through board_authority_ceilings'' FK on INSERT. A direct table write by service_role bypasses every function-level lock (deliberate, 20260717000400). READERS[serialized: add_delegate_to_board, grant_board_role, onboard_federated_fleet_agent][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] -- the RPCs that read the ceiling for authorization through _grant_within_ceiling. onboard_federated_fleet_agent takes this lock BEFORE its boards row lock, because the writer reaches boards through that FK and the two orders must agree (inverted, they deadlocked). grant_board_role takes no boards row lock, so it makes no ordering claim. add_delegate_to_board (HOSTED) takes no boards row lock either, and takes this lock FIRST, before any row lock, as grant_board_role does; tombstone_delegate_instance, the other holder of the delegate_instances row it locks next, never takes this lock. The UNSERIALIZED readers evaluate the ceiling WITHOUT serializing against its absence: a concurrent set_board_authority_ceiling can commit a first ceiling row after such a reader observed none, and it passes as default-owner. That race is real, known and accepted (#277); it is deliberately NOT closed by adding acquisitions, since each new edge in this lock graph has already produced a reproduced ABBA deadlock once (#269 F4). Both lists are machine-compared against pg_proc by agent_board_atomic_fleet_onboarding.test.sql, which fails when this COMMENT and the catalog disagree -- do not edit a list by hand without the catalog. Internal: it takes a lock with NO authorization check on an arbitrary board id, so it must never be grantable to authenticated.';
|