@consiliency/agent-board-schema 1.10.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +136 -0
- package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
- package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
- package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +309 -3
- package/src/tooling.test.mjs +123 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_gatenull_membstate.test.sql +352 -0
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
-- INVITEPOLICY (#90) — a target can block a specific inviter on a specific board.
|
|
2
|
+
--
|
|
3
|
+
-- Defect: agent_board.invite_board_member has an AUTHORIZATION gate (who may invite) but no
|
|
4
|
+
-- TARGET-side policy. A passing inviter can write 'invited' for the same target unboundedly,
|
|
5
|
+
-- and re-invite the instant after the target sets 'left' — each 'invited' row grants the
|
|
6
|
+
-- target board-row SELECT + directory presence, so it is a repeated inbound-disclosure /
|
|
7
|
+
-- harassment vector. No block concept existed anywhere in the schema.
|
|
8
|
+
--
|
|
9
|
+
-- Fix (maintainer-approved shape):
|
|
10
|
+
-- * A target-authored block table, keyed per (board_id, target_agent_id, inviter_agent_id)
|
|
11
|
+
-- — the target blocks ONE specific inviter on ONE board; other inviters are unaffected.
|
|
12
|
+
-- * Blocks are PERMANENT until the target unblocks (no TTL / cooldown) and there is NO
|
|
13
|
+
-- invite cap (the "uncapped invites" half is out of scope; agency, not rate-limiting, is
|
|
14
|
+
-- the fix).
|
|
15
|
+
-- * A frozen predicate _invite_permitted(board, target, inviter) — default PERMISSIVE
|
|
16
|
+
-- (true) with no block row, so fresh boards and the service_role operator carve-out are
|
|
17
|
+
-- unaffected.
|
|
18
|
+
-- * block_board_inviter / unblock_board_inviter — self-only (the actor is bound through
|
|
19
|
+
-- _resolved_actor_agent_id, so a target may block only its OWN inviters; no third-party
|
|
20
|
+
-- blocking).
|
|
21
|
+
-- * ONE guard line in invite_board_member, gating on the SESSION actor (the real inviter),
|
|
22
|
+
-- never the caller-supplied p_created_by_agent_id (an assertion, not evidence).
|
|
23
|
+
--
|
|
24
|
+
-- Deliberately does NOT refuse re-invite based on a latest 'left' state: REJECTSEM (#89) made
|
|
25
|
+
-- 'left' the universal recoverable landing (voluntary leave, declined invite, rejected
|
|
26
|
+
-- request all converge there), so a latest-'left' refusal would trap a voluntary leaver who
|
|
27
|
+
-- wants back in. The block is a SEPARATE explicit signal.
|
|
28
|
+
--
|
|
29
|
+
-- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
|
|
30
|
+
|
|
31
|
+
-- 1. table ------------------------------------------------------------------
|
|
32
|
+
create table if not exists agent_board.board_invite_blocks (
|
|
33
|
+
id uuid primary key default gen_random_uuid(),
|
|
34
|
+
board_id uuid not null references agent_board.boards(id) on delete cascade,
|
|
35
|
+
target_agent_id uuid not null references agent_board.agents(id) on delete cascade,
|
|
36
|
+
inviter_agent_id uuid not null references agent_board.agents(id) on delete cascade,
|
|
37
|
+
created_at timestamptz not null default now(),
|
|
38
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
39
|
+
constraint board_invite_blocks_unique unique (board_id, target_agent_id, inviter_agent_id),
|
|
40
|
+
constraint board_invite_blocks_not_self check (target_agent_id <> inviter_agent_id)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
comment on table agent_board.board_invite_blocks is
|
|
44
|
+
'INVITEPOLICY (#90): target-authored inbound-invite blocks. A row (board, target, inviter) means the target has refused invitations from that specific inviter on that board — permanent until unblocked. Written exclusively by the SECURITY DEFINER block/unblock RPCs; read by _invite_permitted (bypasses RLS as owner) and, RLS-scoped, by the target itself.';
|
|
45
|
+
|
|
46
|
+
create index if not exists idx_agent_board_board_invite_blocks_target
|
|
47
|
+
on agent_board.board_invite_blocks(target_agent_id);
|
|
48
|
+
create index if not exists idx_agent_board_board_invite_blocks_lookup
|
|
49
|
+
on agent_board.board_invite_blocks(board_id, target_agent_id, inviter_agent_id);
|
|
50
|
+
|
|
51
|
+
-- 2. RLS --------------------------------------------------------------------
|
|
52
|
+
alter table agent_board.board_invite_blocks enable row level security;
|
|
53
|
+
|
|
54
|
+
drop policy if exists board_invite_blocks_deny_anon on agent_board.board_invite_blocks;
|
|
55
|
+
create policy board_invite_blocks_deny_anon on agent_board.board_invite_blocks
|
|
56
|
+
as permissive for select to anon using (false);
|
|
57
|
+
|
|
58
|
+
-- A target may read only its OWN blocks. Writes go exclusively through the RPCs.
|
|
59
|
+
drop policy if exists board_invite_blocks_target_read on agent_board.board_invite_blocks;
|
|
60
|
+
create policy board_invite_blocks_target_read on agent_board.board_invite_blocks
|
|
61
|
+
as permissive for select to authenticated
|
|
62
|
+
using (target_agent_id = agent_board.current_agent_id());
|
|
63
|
+
|
|
64
|
+
revoke all on agent_board.board_invite_blocks from public, anon, authenticated;
|
|
65
|
+
grant select on agent_board.board_invite_blocks to authenticated;
|
|
66
|
+
grant select, insert, delete on agent_board.board_invite_blocks to service_role;
|
|
67
|
+
|
|
68
|
+
-- 3. predicate --------------------------------------------------------------
|
|
69
|
+
-- Frozen, default-permissive. True (invite permitted) unless a matching block row exists.
|
|
70
|
+
-- A null inviter (the service_role operator carve-out, gated at the call site) is permitted.
|
|
71
|
+
-- SECURITY DEFINER so it reads the RLS-protected table as owner; internal-only, so PUBLIC
|
|
72
|
+
-- EXECUTE is revoked (keeps the MBHARDEN #118 class invariant green).
|
|
73
|
+
create or replace function agent_board._invite_permitted(
|
|
74
|
+
p_board_id uuid,
|
|
75
|
+
p_target_agent_id uuid,
|
|
76
|
+
p_inviter_agent_id uuid
|
|
77
|
+
)
|
|
78
|
+
returns boolean
|
|
79
|
+
language sql
|
|
80
|
+
stable
|
|
81
|
+
security definer
|
|
82
|
+
set search_path = ''
|
|
83
|
+
as $$
|
|
84
|
+
select p_inviter_agent_id is null
|
|
85
|
+
or not exists (
|
|
86
|
+
select 1
|
|
87
|
+
from agent_board.board_invite_blocks b
|
|
88
|
+
where b.board_id = p_board_id
|
|
89
|
+
and b.target_agent_id = p_target_agent_id
|
|
90
|
+
and b.inviter_agent_id = p_inviter_agent_id
|
|
91
|
+
)
|
|
92
|
+
$$;
|
|
93
|
+
comment on function agent_board._invite_permitted(uuid, uuid, uuid) is
|
|
94
|
+
'INVITEPOLICY (#90) frozen predicate: true unless the target has blocked this inviter on this board. Default-permissive (no block row -> true; null inviter -> true). Internal-only helper for invite_board_member; PUBLIC EXECUTE revoked.';
|
|
95
|
+
revoke all on function agent_board._invite_permitted(uuid, uuid, uuid) from public, anon, authenticated;
|
|
96
|
+
|
|
97
|
+
-- 4. target-facing RPCs -----------------------------------------------------
|
|
98
|
+
create or replace function agent_board.block_board_inviter(
|
|
99
|
+
p_board_id uuid,
|
|
100
|
+
p_inviter_agent_id uuid,
|
|
101
|
+
p_target_agent_id uuid default null,
|
|
102
|
+
p_metadata jsonb default '{}'::jsonb
|
|
103
|
+
)
|
|
104
|
+
returns table(
|
|
105
|
+
id uuid,
|
|
106
|
+
board_id uuid,
|
|
107
|
+
target_agent_id uuid,
|
|
108
|
+
inviter_agent_id uuid,
|
|
109
|
+
created_at timestamptz
|
|
110
|
+
)
|
|
111
|
+
language plpgsql
|
|
112
|
+
security definer
|
|
113
|
+
set search_path = 'agent_board', 'public'
|
|
114
|
+
as $function$
|
|
115
|
+
declare
|
|
116
|
+
v_target uuid := agent_board._resolved_actor_agent_id(p_target_agent_id, 'p_target_agent_id');
|
|
117
|
+
v_row agent_board.board_invite_blocks%rowtype;
|
|
118
|
+
begin
|
|
119
|
+
if not exists (select 1 from agent_board.boards bd where bd.id = p_board_id and bd.status = 'active') then
|
|
120
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
121
|
+
end if;
|
|
122
|
+
if not exists (select 1 from agent_board.agents ag where ag.id = p_inviter_agent_id) then
|
|
123
|
+
raise exception 'inviter agent not found' using errcode = 'P0002';
|
|
124
|
+
end if;
|
|
125
|
+
if v_target = p_inviter_agent_id then
|
|
126
|
+
raise exception 'cannot block yourself' using errcode = '22023';
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
insert into agent_board.board_invite_blocks(board_id, target_agent_id, inviter_agent_id, metadata)
|
|
130
|
+
values (p_board_id, v_target, p_inviter_agent_id, coalesce(p_metadata, '{}'::jsonb))
|
|
131
|
+
on conflict on constraint board_invite_blocks_unique do nothing;
|
|
132
|
+
|
|
133
|
+
select * into v_row
|
|
134
|
+
from agent_board.board_invite_blocks b
|
|
135
|
+
where b.board_id = p_board_id
|
|
136
|
+
and b.target_agent_id = v_target
|
|
137
|
+
and b.inviter_agent_id = p_inviter_agent_id;
|
|
138
|
+
|
|
139
|
+
return query
|
|
140
|
+
select v_row.id, v_row.board_id, v_row.target_agent_id, v_row.inviter_agent_id, v_row.created_at;
|
|
141
|
+
end;
|
|
142
|
+
$function$;
|
|
143
|
+
comment on function agent_board.block_board_inviter(uuid, uuid, uuid, jsonb) is
|
|
144
|
+
'INVITEPOLICY (#90): the target blocks inbound invitations from a specific inviter on a specific board. Self-only (actor bound via _resolved_actor_agent_id). Idempotent. Permanent until unblock_board_inviter.';
|
|
145
|
+
revoke all on function agent_board.block_board_inviter(uuid, uuid, uuid, jsonb) from public, anon;
|
|
146
|
+
grant execute on function agent_board.block_board_inviter(uuid, uuid, uuid, jsonb) to authenticated, service_role;
|
|
147
|
+
|
|
148
|
+
create or replace function agent_board.unblock_board_inviter(
|
|
149
|
+
p_board_id uuid,
|
|
150
|
+
p_inviter_agent_id uuid,
|
|
151
|
+
p_target_agent_id uuid default null
|
|
152
|
+
)
|
|
153
|
+
returns boolean
|
|
154
|
+
language plpgsql
|
|
155
|
+
security definer
|
|
156
|
+
set search_path = 'agent_board', 'public'
|
|
157
|
+
as $function$
|
|
158
|
+
declare
|
|
159
|
+
v_target uuid := agent_board._resolved_actor_agent_id(p_target_agent_id, 'p_target_agent_id');
|
|
160
|
+
v_deleted int;
|
|
161
|
+
begin
|
|
162
|
+
delete from agent_board.board_invite_blocks b
|
|
163
|
+
where b.board_id = p_board_id
|
|
164
|
+
and b.target_agent_id = v_target
|
|
165
|
+
and b.inviter_agent_id = p_inviter_agent_id;
|
|
166
|
+
get diagnostics v_deleted = row_count;
|
|
167
|
+
return v_deleted > 0;
|
|
168
|
+
end;
|
|
169
|
+
$function$;
|
|
170
|
+
comment on function agent_board.unblock_board_inviter(uuid, uuid, uuid) is
|
|
171
|
+
'INVITEPOLICY (#90): the target lifts a previously set inbound-invite block. Self-only (actor bound via _resolved_actor_agent_id). Returns true iff a block row was removed.';
|
|
172
|
+
revoke all on function agent_board.unblock_board_inviter(uuid, uuid, uuid) from public, anon;
|
|
173
|
+
grant execute on function agent_board.unblock_board_inviter(uuid, uuid, uuid) to authenticated, service_role;
|
|
174
|
+
|
|
175
|
+
-- 5. gate invite_board_member ----------------------------------------------
|
|
176
|
+
-- CREATE OR REPLACE preserves the existing ACL + comment; grants are re-asserted below per
|
|
177
|
+
-- house convention (replay-order independence). The ONLY change is the INVITEPOLICY guard,
|
|
178
|
+
-- gating on v_actor (the session inviter). A null actor is the service_role operator
|
|
179
|
+
-- carve-out and skips the check (permissive), matching the existing gate structure.
|
|
180
|
+
create or replace function agent_board.invite_board_member(
|
|
181
|
+
p_board_id uuid,
|
|
182
|
+
p_agent_id uuid,
|
|
183
|
+
p_created_by_agent_id uuid,
|
|
184
|
+
p_metadata jsonb default '{}'::jsonb
|
|
185
|
+
)
|
|
186
|
+
returns table(
|
|
187
|
+
membership_id uuid,
|
|
188
|
+
board_id uuid,
|
|
189
|
+
agent_id uuid,
|
|
190
|
+
membership_state text,
|
|
191
|
+
created_at timestamptz,
|
|
192
|
+
updated_at timestamptz
|
|
193
|
+
)
|
|
194
|
+
language plpgsql
|
|
195
|
+
security definer
|
|
196
|
+
set search_path = 'agent_board', 'public'
|
|
197
|
+
as $function$
|
|
198
|
+
declare
|
|
199
|
+
v_board agent_board.boards%rowtype;
|
|
200
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
201
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
202
|
+
begin
|
|
203
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
204
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
205
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
206
|
+
end if;
|
|
207
|
+
|
|
208
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
209
|
+
if v_board.id is null then
|
|
210
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
211
|
+
end if;
|
|
212
|
+
if v_actor is not null then
|
|
213
|
+
if v_board.role_gate_enforced then
|
|
214
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
215
|
+
-- must hold an active owner/admin membership.
|
|
216
|
+
if not exists (
|
|
217
|
+
select 1
|
|
218
|
+
from agent_board.board_memberships bm
|
|
219
|
+
where bm.board_id = p_board_id
|
|
220
|
+
and bm.agent_id = v_actor
|
|
221
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
222
|
+
and bm.role in ('owner', 'admin')
|
|
223
|
+
) then
|
|
224
|
+
raise exception 'board invite requires owner or admin role' using errcode = '42501';
|
|
225
|
+
end if;
|
|
226
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
227
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
228
|
+
if not exists (
|
|
229
|
+
select 1
|
|
230
|
+
from agent_board.board_memberships bm
|
|
231
|
+
where bm.board_id = p_board_id
|
|
232
|
+
and bm.agent_id = v_actor
|
|
233
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
234
|
+
) then
|
|
235
|
+
raise exception 'board invite requires active membership' using errcode = '42501';
|
|
236
|
+
end if;
|
|
237
|
+
end if;
|
|
238
|
+
end if;
|
|
239
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
240
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
241
|
+
end if;
|
|
242
|
+
if not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
243
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
244
|
+
end if;
|
|
245
|
+
|
|
246
|
+
-- INVITEPOLICY (#90): a target may block a specific inviter on a specific board.
|
|
247
|
+
-- Gate on the SESSION actor (the real inviter), never p_created_by_agent_id (an
|
|
248
|
+
-- assertion). A null actor is the service_role operator carve-out (permissive).
|
|
249
|
+
if v_actor is not null
|
|
250
|
+
and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
|
|
251
|
+
raise exception 'target has blocked invitations from this inviter on this board'
|
|
252
|
+
using errcode = '42501';
|
|
253
|
+
end if;
|
|
254
|
+
|
|
255
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
256
|
+
p_board_id,
|
|
257
|
+
p_agent_id,
|
|
258
|
+
'invited',
|
|
259
|
+
p_created_by_agent_id,
|
|
260
|
+
p_metadata
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
return query
|
|
264
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
265
|
+
end;
|
|
266
|
+
$function$;
|
|
267
|
+
|
|
268
|
+
revoke all on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) from public, anon;
|
|
269
|
+
grant execute on function agent_board.invite_board_member(uuid, uuid, uuid, jsonb) to authenticated, service_role;
|
|
270
|
+
|
|
271
|
+
-- 6. close the update_board_membership disclosure bypass --------------------
|
|
272
|
+
-- invite_board_member is not the only path that can seat an inbound-disclosure membership for
|
|
273
|
+
-- a target: update_board_membership (an owner/admin tool) writes an arbitrary p_membership_state
|
|
274
|
+
-- for another agent. Its consent gate (20260809000100) only covers ACTIVE admission
|
|
275
|
+
-- (joined/subscribed via _membership_consent_present); a non-target actor writing 'invited' or
|
|
276
|
+
-- 'requested' is ungated, so a blocked owner/admin could re-seat the exact disclosure row the
|
|
277
|
+
-- block is meant to refuse. CREATE OR REPLACE with the SAME body plus one INVITEPOLICY guard in
|
|
278
|
+
-- the acting-on-another-agent branch (both 'invited' and 'requested' restore board SELECT for the
|
|
279
|
+
-- target, so both are gated). service_role/self paths are unaffected (the guard sits inside the
|
|
280
|
+
-- authenticated, other-agent branch where v_actor is non-null). Grants re-asserted per convention.
|
|
281
|
+
create or replace function agent_board.update_board_membership(
|
|
282
|
+
p_board_id uuid,
|
|
283
|
+
p_agent_id uuid,
|
|
284
|
+
p_membership_state text,
|
|
285
|
+
p_metadata jsonb default '{}'::jsonb
|
|
286
|
+
)
|
|
287
|
+
returns table(
|
|
288
|
+
membership_id uuid,
|
|
289
|
+
board_id uuid,
|
|
290
|
+
agent_id uuid,
|
|
291
|
+
membership_state text,
|
|
292
|
+
left_at timestamp with time zone,
|
|
293
|
+
updated_at timestamp with time zone
|
|
294
|
+
)
|
|
295
|
+
language plpgsql
|
|
296
|
+
security definer
|
|
297
|
+
set search_path to 'agent_board', 'public'
|
|
298
|
+
as $function$
|
|
299
|
+
declare
|
|
300
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
301
|
+
v_board agent_board.boards%rowtype;
|
|
302
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
303
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
304
|
+
v_existing_state text;
|
|
305
|
+
v_target_role text;
|
|
306
|
+
v_actor_role text;
|
|
307
|
+
begin
|
|
308
|
+
if v_actor is null and not v_is_service then
|
|
309
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
310
|
+
end if;
|
|
311
|
+
|
|
312
|
+
if not v_is_service and v_actor <> p_agent_id then
|
|
313
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
314
|
+
if v_board.id is null then
|
|
315
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
316
|
+
end if;
|
|
317
|
+
select bm.role
|
|
318
|
+
into v_actor_role
|
|
319
|
+
from agent_board.board_memberships bm
|
|
320
|
+
where bm.board_id = p_board_id
|
|
321
|
+
and bm.agent_id = v_actor
|
|
322
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
323
|
+
limit 1;
|
|
324
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
325
|
+
raise exception 'board membership change requires owner or admin role'
|
|
326
|
+
using errcode = '42501';
|
|
327
|
+
end if;
|
|
328
|
+
end if;
|
|
329
|
+
|
|
330
|
+
select bm.membership_state, bm.role
|
|
331
|
+
into v_existing_state, v_target_role
|
|
332
|
+
from agent_board.board_memberships bm
|
|
333
|
+
where bm.board_id = p_board_id
|
|
334
|
+
and bm.agent_id = p_agent_id
|
|
335
|
+
order by
|
|
336
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
337
|
+
bm.created_at desc,
|
|
338
|
+
bm.id desc
|
|
339
|
+
limit 1;
|
|
340
|
+
if v_existing_state is null then
|
|
341
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
342
|
+
end if;
|
|
343
|
+
|
|
344
|
+
if not v_is_service then
|
|
345
|
+
if v_actor = p_agent_id then
|
|
346
|
+
if not (
|
|
347
|
+
(p_membership_state = 'left'
|
|
348
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
349
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
350
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
351
|
+
or (p_membership_state = v_existing_state
|
|
352
|
+
and p_membership_state in ('left', 'removed'))
|
|
353
|
+
) then
|
|
354
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
355
|
+
using errcode = '42501';
|
|
356
|
+
end if;
|
|
357
|
+
else
|
|
358
|
+
if agent_board._role_rank(v_actor_role)
|
|
359
|
+
<= agent_board._role_rank(
|
|
360
|
+
case
|
|
361
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
362
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
363
|
+
then 'member'
|
|
364
|
+
else coalesce(v_target_role, '')
|
|
365
|
+
end) then
|
|
366
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
367
|
+
using errcode = '42501';
|
|
368
|
+
end if;
|
|
369
|
+
|
|
370
|
+
if p_membership_state in ('joined', 'subscribed')
|
|
371
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
372
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
373
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
374
|
+
using errcode = '42501';
|
|
375
|
+
end if;
|
|
376
|
+
|
|
377
|
+
-- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
|
|
378
|
+
-- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
|
|
379
|
+
-- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
|
|
380
|
+
-- states are already consent-gated above; service_role/self never reach this branch.
|
|
381
|
+
if p_membership_state in ('invited', 'requested')
|
|
382
|
+
and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
|
|
383
|
+
raise exception 'target has blocked invitations from this inviter on this board'
|
|
384
|
+
using errcode = '42501';
|
|
385
|
+
end if;
|
|
386
|
+
end if;
|
|
387
|
+
end if;
|
|
388
|
+
|
|
389
|
+
if not v_is_service
|
|
390
|
+
and p_membership_state = v_existing_state
|
|
391
|
+
and p_membership_state in ('left', 'removed') then
|
|
392
|
+
return query
|
|
393
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
394
|
+
from agent_board.board_memberships bm
|
|
395
|
+
where bm.board_id = p_board_id
|
|
396
|
+
and bm.agent_id = p_agent_id
|
|
397
|
+
order by
|
|
398
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
399
|
+
bm.created_at desc,
|
|
400
|
+
bm.id desc
|
|
401
|
+
limit 1;
|
|
402
|
+
return;
|
|
403
|
+
end if;
|
|
404
|
+
|
|
405
|
+
select * into v_membership
|
|
406
|
+
from agent_board._upsert_board_membership_state(
|
|
407
|
+
p_board_id,
|
|
408
|
+
p_agent_id,
|
|
409
|
+
p_membership_state,
|
|
410
|
+
null,
|
|
411
|
+
p_metadata
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
return query
|
|
415
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
416
|
+
end;
|
|
417
|
+
$function$;
|
|
418
|
+
|
|
419
|
+
revoke all on function agent_board.update_board_membership(uuid, uuid, text, jsonb) from public, anon;
|
|
420
|
+
grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb) to authenticated, service_role;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
-- OBSGATE (#78) — threads carry their own posting rule; an observer can no longer author
|
|
2
|
+
-- into a board thread.
|
|
3
|
+
--
|
|
4
|
+
-- Defect: post_message is the SOLE authenticated writer to agent_board.messages (INSERT is
|
|
5
|
+
-- granted only to postgres/service_role; the only authenticated RLS policy is SELECT), and it
|
|
6
|
+
-- gates only on _current_agent_can_access_thread — NO role check. An observer who has thread
|
|
7
|
+
-- access (via a board-fanned message) can therefore call post_message directly (SDK/CLI/MCP all
|
|
8
|
+
-- expose it) and author into the board thread, bypassing the non-observer send-role gate that
|
|
9
|
+
-- post_board_message enforces. MBHYGIENE (20260804000100) explicitly punted this because
|
|
10
|
+
-- agent_board.threads had no board linkage.
|
|
11
|
+
--
|
|
12
|
+
-- Fix (maintainer-approved: "threads carry their own posting rules, independent of the board"):
|
|
13
|
+
-- * threads.post_policy — the thread's own posting rule. Ships with 'inherit' only.
|
|
14
|
+
-- * threads.board_id — a SINGLE-VALUED stamped board link (the model that dissolves the
|
|
15
|
+
-- MBHYGIENE blocker). Stamped first-board-wins at the universal board-fan chokepoint
|
|
16
|
+
-- (_upsert_delivery_recipient), so EVERY board-fanning path — post_board_message,
|
|
17
|
+
-- create_tasking_delivery — links the thread, not just post_board_message.
|
|
18
|
+
-- * _current_agent_can_post_to_thread — the shared predicate: 'inherit' + a stamped board
|
|
19
|
+
-- requires _current_agent_can_send_to_board (observer denied); 'inherit' + no board falls
|
|
20
|
+
-- through to the thread access check (DM / multicast / restricted threads: no role gate, no
|
|
21
|
+
-- regression). service_role short-circuits.
|
|
22
|
+
-- * The gate is enforced IN post_message (the chokepoint no send path can skip).
|
|
23
|
+
-- post_board_message keeps its own board send-role gate (defense-in-depth).
|
|
24
|
+
--
|
|
25
|
+
-- Additive, forward-only. schema_version stays 8 (consistent with thread_acl / send_role_gate,
|
|
26
|
+
-- which added columns/tables without a read-model bump). SDK/CLI/MCP need no change — the gate
|
|
27
|
+
-- is server-side and simply surfaces a 42501.
|
|
28
|
+
|
|
29
|
+
-- 1. columns ----------------------------------------------------------------
|
|
30
|
+
alter table agent_board.threads
|
|
31
|
+
add column if not exists board_id uuid references agent_board.boards(id);
|
|
32
|
+
alter table agent_board.threads
|
|
33
|
+
add column if not exists post_policy text not null default 'inherit';
|
|
34
|
+
|
|
35
|
+
-- Only 'inherit' ships now; 'restricted' (thread-role-driven posting) is a future policy that
|
|
36
|
+
-- will widen this constraint when implemented. Constraining the domain now prevents a caller
|
|
37
|
+
-- setting an unenforced policy value.
|
|
38
|
+
alter table agent_board.threads
|
|
39
|
+
drop constraint if exists threads_post_policy_check;
|
|
40
|
+
alter table agent_board.threads
|
|
41
|
+
add constraint threads_post_policy_check check (post_policy in ('inherit'));
|
|
42
|
+
|
|
43
|
+
create index if not exists idx_agent_board_threads_board_id
|
|
44
|
+
on agent_board.threads(board_id);
|
|
45
|
+
|
|
46
|
+
comment on column agent_board.threads.board_id is
|
|
47
|
+
'OBSGATE (#78): the single owning board this thread posts under, stamped first-board-wins on first board delivery (_upsert_delivery_recipient). NULL for board-less threads (DM/multicast). Drives the post_message posting gate.';
|
|
48
|
+
comment on column agent_board.threads.post_policy is
|
|
49
|
+
'OBSGATE (#78): the thread''s own posting rule. ''inherit'' (only value shipped): a board-linked thread requires a non-observer board role to post; a board-less thread is access-only.';
|
|
50
|
+
|
|
51
|
+
-- 2. backfill ---------------------------------------------------------------
|
|
52
|
+
-- Stamp EVERY existing thread that has any board delivery, resolving the thread from every
|
|
53
|
+
-- source kind (message / task / delegation) so tasking-fanned threads are covered. Multi-board
|
|
54
|
+
-- threads are stamped to their EARLIEST-delivery board — the same first-board-wins rule the
|
|
55
|
+
-- forward stamp uses — rather than left NULL: leaving them NULL would drop them to the
|
|
56
|
+
-- access-only branch and preserve the original observer escalation (a board member reaching the
|
|
57
|
+
-- thread could author). A denial for a member of a non-chosen board is a false-deny, not an
|
|
58
|
+
-- escalation, and matches the forward single-valued model.
|
|
59
|
+
update agent_board.threads t
|
|
60
|
+
set board_id = sub.board_id
|
|
61
|
+
from (
|
|
62
|
+
select distinct on (thr) thr as thread_id, bid as board_id
|
|
63
|
+
from (
|
|
64
|
+
select m.thread_id as thr, dr.board_id as bid, dr.created_at as ca
|
|
65
|
+
from agent_board.delivery_recipients dr
|
|
66
|
+
join agent_board.messages m on m.id = dr.source_message_id
|
|
67
|
+
where dr.board_id is not null
|
|
68
|
+
union all
|
|
69
|
+
select tk.thread_id as thr, dr.board_id as bid, dr.created_at as ca
|
|
70
|
+
from agent_board.delivery_recipients dr
|
|
71
|
+
join agent_board.tasks tk on tk.id = dr.source_task_id
|
|
72
|
+
where dr.board_id is not null
|
|
73
|
+
union all
|
|
74
|
+
select tk.thread_id as thr, dr.board_id as bid, dr.created_at as ca
|
|
75
|
+
from agent_board.delivery_recipients dr
|
|
76
|
+
join agent_board.delegations d on d.id = dr.source_delegation_id
|
|
77
|
+
join agent_board.tasks tk on tk.id = d.child_task_id
|
|
78
|
+
where dr.board_id is not null
|
|
79
|
+
) all_src
|
|
80
|
+
order by thr, ca, bid -- earliest delivery wins; board_id tiebreaks for determinism
|
|
81
|
+
) sub
|
|
82
|
+
where t.id = sub.thread_id
|
|
83
|
+
and t.board_id is null;
|
|
84
|
+
|
|
85
|
+
-- 3. posting predicate ------------------------------------------------------
|
|
86
|
+
create or replace function agent_board._current_agent_can_post_to_thread(p_thread_id uuid)
|
|
87
|
+
returns boolean
|
|
88
|
+
language sql
|
|
89
|
+
stable
|
|
90
|
+
security definer
|
|
91
|
+
set search_path = ''
|
|
92
|
+
as $$
|
|
93
|
+
select agent_board._is_service_role()
|
|
94
|
+
or coalesce(
|
|
95
|
+
(
|
|
96
|
+
select case
|
|
97
|
+
when t.post_policy = 'inherit' and t.board_id is not null
|
|
98
|
+
then agent_board._current_agent_can_send_to_board(t.board_id)
|
|
99
|
+
else agent_board._current_agent_can_access_thread(p_thread_id)
|
|
100
|
+
end
|
|
101
|
+
from agent_board.threads t
|
|
102
|
+
where t.id = p_thread_id
|
|
103
|
+
),
|
|
104
|
+
false
|
|
105
|
+
)
|
|
106
|
+
$$;
|
|
107
|
+
comment on function agent_board._current_agent_can_post_to_thread(uuid) is
|
|
108
|
+
'OBSGATE (#78): may the current agent post to this thread? inherit + stamped board -> non-observer board send role; inherit + no board -> thread access (DM/multicast unaffected); service_role -> true. Internal-only; PUBLIC EXECUTE revoked.';
|
|
109
|
+
revoke all on function agent_board._current_agent_can_post_to_thread(uuid) from public, anon, authenticated;
|
|
110
|
+
|
|
111
|
+
-- 4. universal board-link stamp at the delivery-recipient sink ---------------
|
|
112
|
+
-- CREATE OR REPLACE preserves the body; adds only the first-board-wins stamp so that EVERY
|
|
113
|
+
-- board-fanning caller (post_board_message, create_tasking_delivery) links the thread.
|
|
114
|
+
create or replace function agent_board._upsert_delivery_recipient(
|
|
115
|
+
p_delivery_mode text,
|
|
116
|
+
p_recipient_agent_id uuid,
|
|
117
|
+
p_source_kind text,
|
|
118
|
+
p_source_message_id uuid default null,
|
|
119
|
+
p_source_task_id uuid default null,
|
|
120
|
+
p_source_delegation_id uuid default null,
|
|
121
|
+
p_board_id uuid default null,
|
|
122
|
+
p_board_membership_id uuid default null,
|
|
123
|
+
p_metadata jsonb default '{}'::jsonb
|
|
124
|
+
)
|
|
125
|
+
returns agent_board.delivery_recipients
|
|
126
|
+
language plpgsql
|
|
127
|
+
security definer
|
|
128
|
+
set search_path to 'agent_board', 'public'
|
|
129
|
+
as $function$
|
|
130
|
+
declare
|
|
131
|
+
v_row agent_board.delivery_recipients%rowtype;
|
|
132
|
+
begin
|
|
133
|
+
insert into agent_board.delivery_recipients(
|
|
134
|
+
delivery_mode,
|
|
135
|
+
recipient_agent_id,
|
|
136
|
+
source_kind,
|
|
137
|
+
source_message_id,
|
|
138
|
+
source_task_id,
|
|
139
|
+
source_delegation_id,
|
|
140
|
+
board_id,
|
|
141
|
+
board_membership_id,
|
|
142
|
+
metadata
|
|
143
|
+
)
|
|
144
|
+
values (
|
|
145
|
+
p_delivery_mode,
|
|
146
|
+
p_recipient_agent_id,
|
|
147
|
+
p_source_kind,
|
|
148
|
+
p_source_message_id,
|
|
149
|
+
p_source_task_id,
|
|
150
|
+
p_source_delegation_id,
|
|
151
|
+
p_board_id,
|
|
152
|
+
p_board_membership_id,
|
|
153
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
154
|
+
)
|
|
155
|
+
on conflict (recipient_agent_id, canonical_source_key) do update
|
|
156
|
+
set metadata = coalesce(agent_board.delivery_recipients.metadata, '{}'::jsonb) || coalesce(excluded.metadata, '{}'::jsonb)
|
|
157
|
+
returning * into v_row;
|
|
158
|
+
|
|
159
|
+
-- OBSGATE (#78): stamp the thread's owning board on first board delivery so
|
|
160
|
+
-- post_message's posting gate can resolve it. Resolve the thread from WHICHEVER source the
|
|
161
|
+
-- delivery carries — message (post_board_message), task (create_tasking_delivery), or
|
|
162
|
+
-- delegation — so EVERY board-fanning path links the thread, not just the message-sourced one.
|
|
163
|
+
-- First-board-wins (never overwrites); board-less deliveries (p_board_id null) never stamp.
|
|
164
|
+
if p_board_id is not null then
|
|
165
|
+
update agent_board.threads t
|
|
166
|
+
set board_id = p_board_id
|
|
167
|
+
where t.board_id is null
|
|
168
|
+
and t.id = coalesce(
|
|
169
|
+
(select m.thread_id from agent_board.messages m where m.id = p_source_message_id),
|
|
170
|
+
(select tk.thread_id from agent_board.tasks tk where tk.id = p_source_task_id),
|
|
171
|
+
(select tk.thread_id
|
|
172
|
+
from agent_board.delegations d
|
|
173
|
+
join agent_board.tasks tk on tk.id = d.child_task_id
|
|
174
|
+
where d.id = p_source_delegation_id)
|
|
175
|
+
);
|
|
176
|
+
end if;
|
|
177
|
+
|
|
178
|
+
return v_row;
|
|
179
|
+
end;
|
|
180
|
+
$function$;
|
|
181
|
+
revoke all on function agent_board._upsert_delivery_recipient(text, uuid, text, uuid, uuid, uuid, uuid, uuid, jsonb) from public, anon, authenticated;
|
|
182
|
+
grant execute on function agent_board._upsert_delivery_recipient(text, uuid, text, uuid, uuid, uuid, uuid, uuid, jsonb) to service_role;
|
|
183
|
+
|
|
184
|
+
-- 5. enforce the gate in post_message (the chokepoint) ----------------------
|
|
185
|
+
-- CREATE OR REPLACE with the SAME body plus one OBSGATE gate after the access check.
|
|
186
|
+
create or replace function agent_board.post_message(
|
|
187
|
+
p_thread_id uuid,
|
|
188
|
+
p_author_agent_id uuid,
|
|
189
|
+
p_body text,
|
|
190
|
+
p_message_type text default 'comment',
|
|
191
|
+
p_task_id uuid default null,
|
|
192
|
+
p_metadata jsonb default '{}'::jsonb
|
|
193
|
+
)
|
|
194
|
+
returns table(
|
|
195
|
+
message_id uuid,
|
|
196
|
+
thread_id uuid,
|
|
197
|
+
task_id uuid,
|
|
198
|
+
message_sequence bigint,
|
|
199
|
+
created_at timestamp with time zone
|
|
200
|
+
)
|
|
201
|
+
language plpgsql
|
|
202
|
+
security definer
|
|
203
|
+
set search_path to 'agent_board', 'public'
|
|
204
|
+
as $function$
|
|
205
|
+
declare
|
|
206
|
+
v_sequence bigint;
|
|
207
|
+
v_actor_agent_id uuid;
|
|
208
|
+
begin
|
|
209
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
|
|
210
|
+
|
|
211
|
+
if nullif(trim(p_body), '') is null then
|
|
212
|
+
raise exception 'message body is required' using errcode = '22023';
|
|
213
|
+
end if;
|
|
214
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
215
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
216
|
+
end if;
|
|
217
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
218
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
219
|
+
end if;
|
|
220
|
+
-- OBSGATE (#78): a board-linked thread under 'inherit' requires a non-observer board role.
|
|
221
|
+
-- post_message is the sole authenticated writer to messages, so this gate cannot be bypassed
|
|
222
|
+
-- by any send path. Board-less threads and service_role fall through (access check above).
|
|
223
|
+
if not agent_board._current_agent_can_post_to_thread(p_thread_id) then
|
|
224
|
+
raise exception 'thread posting requires a non-observer board role' using errcode = '42501';
|
|
225
|
+
end if;
|
|
226
|
+
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
|
|
227
|
+
raise exception 'task not found in thread' using errcode = '22023';
|
|
228
|
+
end if;
|
|
229
|
+
|
|
230
|
+
v_sequence := agent_board._next_thread_message_sequence(p_thread_id);
|
|
231
|
+
|
|
232
|
+
return query
|
|
233
|
+
insert into agent_board.messages(thread_id, author_agent_id, task_id, message_sequence, message_type, body, metadata)
|
|
234
|
+
values (p_thread_id, v_actor_agent_id, p_task_id, v_sequence, coalesce(p_message_type, 'comment'), p_body, coalesce(p_metadata, '{}'::jsonb))
|
|
235
|
+
returning id, messages.thread_id, messages.task_id, messages.message_sequence, messages.created_at;
|
|
236
|
+
end;
|
|
237
|
+
$function$;
|
|
238
|
+
revoke all on function agent_board.post_message(uuid, uuid, text, text, uuid, jsonb) from public, anon;
|
|
239
|
+
grant execute on function agent_board.post_message(uuid, uuid, text, text, uuid, jsonb) to authenticated, service_role;
|