@consiliency/agent-board-schema 1.63.0 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +25 -0
  2. package/fixtures/already-applied.json +20 -0
  3. package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
  4. package/migrations/20261015000100_agent_board_session_provenance.sql +367 -0
  5. package/migrations/20261015000200_agent_board_request_preserve_removed.sql +87 -0
  6. package/migrations/20261015000300_agent_board_rls_correlation.sql +132 -0
  7. package/migrations/20261015000400_agent_board_resolution_authority.sql +146 -0
  8. package/package.json +1 -1
  9. package/schema-manifest.json +48 -3
  10. package/src/deployment-drift-main.test.mjs +156 -0
  11. package/src/deployment-drift-script.test.mjs +14 -1
  12. package/src/policy-comment.test.mjs +17 -0
  13. package/src/tooling.test.mjs +10 -2
  14. package/tests/agent_board_atomic_fleet_onboarding.test.sql +3 -1
  15. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
  16. package/tests/agent_board_consent_gating.test.sql +4 -4
  17. package/tests/agent_board_fk_lock_edges.test.sql +3 -2
  18. package/tests/agent_board_gatenull_membstate.test.sql +7 -4
  19. package/tests/agent_board_grant_target_role.test.sql +4 -4
  20. package/tests/agent_board_hosted_delegates.test.sql +5 -3
  21. package/tests/agent_board_invite_no_downgrade.test.sql +5 -3
  22. package/tests/agent_board_ledger_authority.test.sql +7 -0
  23. package/tests/agent_board_policy_correlation.test.sql +189 -0
  24. package/tests/agent_board_request_removed.test.sql +96 -0
  25. package/tests/agent_board_resolution_authority.test.sql +190 -0
  26. package/tests/agent_board_role_guard.test.sql +319 -0
  27. package/tests/agent_board_rolereset.test.sql +17 -27
  28. package/tests/agent_board_roomdeleg.test.sql +8 -0
  29. package/tests/agent_board_roomown.test.sql +3 -1
  30. package/tests/agent_board_rowid.test.sql +4 -1
  31. package/tests/agent_board_session_provenance.test.sql +106 -0
  32. package/tests/catalog/function_execute_privileges.json +11 -2
  33. package/tests/catalog/p_metadata_parameters.json +4 -0
  34. package/tests/isolation/expected/consent_approval_lock.out +1 -1
  35. package/tests/isolation/expected/last_owner_departure.out +562 -0
  36. package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
  37. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
  38. package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
  39. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
  40. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
  41. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
  42. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
  43. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
  44. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
  45. package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
  46. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
  47. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
  48. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
  49. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
  50. package/tests/isolation/specs/consent_approval_lock.spec +13 -8
  51. package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
  52. package/tests/isolation/specs/grant_target_role_race.spec +0 -1
  53. package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
  54. package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
  55. package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
  56. package/tests/isolation/specs/last_owner_departure.spec +137 -0
  57. package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
  58. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
  59. package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
  60. package/tests/isolation/specs/session_identity_links.spec +3 -1
  61. package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +0 -1
  62. package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
  63. package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
package/README.md CHANGED
@@ -7,6 +7,31 @@ and materialize it — they do not author `agent_board` DDL.
7
7
 
8
8
  ## What's in the bundle
9
9
 
10
+ Schema 1.68.0 requires a resolved active owner/admin principal to resolve pending
11
+ memberships, independently of the board role gate or creator identity. Like the
12
+ pending-list reader, it refuses unresolved service-role sessions. It keeps 1.67.0's
13
+ session-derived attribution: the resolution write and redundant-request retirement
14
+ are attributed to the resolving session agent, never to `p_created_by_agent_id`.
15
+
16
+ Schema 1.67.0 derives invitation, request-resolution (including redundant-request
17
+ retirement), and self-join attribution from the session agent. The compatibility
18
+ `p_created_by_agent_id` argument is ignored. An unresolved service operator supplies
19
+ no agent attribution; existing row provenance is retained on NULL updates.
20
+
21
+ Schema 1.66.0 refuses join requests while removed standing remains. The request
22
+ RPC locks the subject's existing rows before deciding standing, preserving
23
+ eviction evidence and the existing active-member no-op.
24
+
25
+ Schema 1.65.0 repairs thread and delegation RLS correlation to the protected
26
+ row and grants authenticated execution of the existing read predicates used
27
+ by ceiling and thread-membership policies. Unrelated rows remain hidden.
28
+
29
+ Schema 1.64.0 adds `20261014000200_agent_board_last_owner.sql`: a refusal-only
30
+ table-level last-active-owner invariant, preserving the 1.63.0 role-grant policy.
31
+ Concurrent departures serialize per board; recovery remains service_role-only.
32
+ SQL schema version remains 8. See `specs/rpc-contracts.md` for refusal/retry
33
+ semantics. This is the working-tree contract, not a deployment receipt.
34
+
10
35
  - `migrations/` — the ordered, immutable SQL union (frozen baseline + lane
11
36
  migrations + the Message Board-owned endpoint compatibility migration).
12
37
  - `schema-manifest.json` — package version, `agent_board.schema_version`, ordered
@@ -644,6 +644,26 @@
644
644
  {
645
645
  "id": "20261014000100_agent_board_grant_target_role",
646
646
  "sha256": "4a6c00546a1d2837f88eece1de082cc76eec1f85cce8cf9ac7998e42bf32a5c5"
647
+ },
648
+ {
649
+ "id": "20261014000200_agent_board_last_owner",
650
+ "sha256": "2fed8fc5a2c7c0ba9acbb12872b329db3f0902d96597c52cc50e21e4682c2fff"
651
+ },
652
+ {
653
+ "id": "20261015000100_agent_board_session_provenance",
654
+ "sha256": "b5e3c7fc859dff7c3a0ccd4c0a7fb22d6ada1df68cb028be138035b586e1462a"
655
+ },
656
+ {
657
+ "id": "20261015000200_agent_board_request_preserve_removed",
658
+ "sha256": "9bad7438909e9c02cc034b59af67fa962e7251bedb53d691e762e651ba1c343c"
659
+ },
660
+ {
661
+ "id": "20261015000300_agent_board_rls_correlation",
662
+ "sha256": "aafe4a4b6eb1b6a5e3f0b5fbd8b4e4d395399de079c6a7b2eb2535ef4514bdc4"
663
+ },
664
+ {
665
+ "id": "20261015000400_agent_board_resolution_authority",
666
+ "sha256": "bfeb7bda1eb41bc431416502db69c33156ff72acfd79cefdfd556d933f79d809"
647
667
  }
648
668
  ]
649
669
  }
@@ -0,0 +1,239 @@
1
+ -- Consiliency/message-board#675: row-level last-active-owner invariant, schema 1.64.0.
2
+ -- Main's grant_board_role body, ACL and COMMENT remain unchanged (schema 1.63.0).
3
+ -- The trigger is refusal-only: it never assigns NEW or clamps a role. The
4
+ -- 20260802000100 / 20260803000100 prohibition concerns a role-changing clamp
5
+ -- that would erase intentional owner/admin seeds; this UPDATE/DELETE guard
6
+ -- leaves all allowed writes and INSERT seeders unchanged.
7
+
8
+ 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)
9
+ 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)
10
+ LANGUAGE plpgsql
11
+ SECURITY DEFINER
12
+ SET search_path TO 'agent_board', 'public'
13
+ AS $function$
14
+ declare
15
+ v_membership agent_board.board_memberships%rowtype;
16
+ v_board agent_board.boards%rowtype;
17
+ v_actor uuid := agent_board.current_agent_id();
18
+ v_is_service boolean := agent_board._is_service_role();
19
+ v_existing_state text;
20
+ v_target_role text;
21
+ v_actor_role text;
22
+ -- HOSTED (D7): the caller is the target hosted delegate's principal asking for `left`.
23
+ v_principal_withdraw boolean;
24
+ begin
25
+ if v_actor is null and not v_is_service then
26
+ raise exception 'current agent principal not resolved' using errcode = '42501';
27
+ end if;
28
+
29
+ -- Board authority lock first, before the target row and the sink's FK locks.
30
+ perform agent_board._lock_board_authority_ceiling(p_board_id);
31
+
32
+ v_principal_withdraw := not v_is_service
33
+ and v_actor <> p_agent_id
34
+ and p_membership_state = 'left'
35
+ and v_actor = agent_board._delegate_principal_agent_id(p_agent_id);
36
+
37
+ if not v_is_service and v_actor <> p_agent_id and not v_principal_withdraw then
38
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
39
+ if v_board.id is null then
40
+ raise exception 'board not found' using errcode = 'P0002';
41
+ end if;
42
+ select bm.role
43
+ into v_actor_role
44
+ from agent_board.board_memberships bm
45
+ where bm.board_id = p_board_id
46
+ and bm.agent_id = v_actor
47
+ and bm.membership_state in ('joined', 'subscribed')
48
+ limit 1;
49
+ if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
50
+ raise exception 'board membership change requires owner or admin role'
51
+ using errcode = '42501';
52
+ end if;
53
+ end if;
54
+
55
+ -- HOSTED: LOCKED, so every transition rule below is decided on the state the sink will
56
+ -- overwrite. Unlocked, an admin's uncommitted `removed` committed after this read, and the D7
57
+ -- withdrawal (and an ordinary self-leave, the same branch shape) then wrote `left` over it in
58
+ -- the sink, erasing the eviction R-LINK-7 reads (hosted_delegate_withdraw_evict_race). The
59
+ -- sink's own first lock is this same row in the same order, so the lock set is unchanged;
60
+ -- it is only taken earlier, before reads that lock nothing.
61
+ select bm.membership_state, bm.role
62
+ into v_existing_state, v_target_role
63
+ from agent_board.board_memberships bm
64
+ where bm.board_id = p_board_id
65
+ and bm.agent_id = p_agent_id
66
+ order by
67
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
68
+ bm.created_at desc,
69
+ bm.id desc
70
+ limit 1
71
+ for update;
72
+ if v_existing_state is null then
73
+ raise exception 'board membership not found' using errcode = 'P0002';
74
+ end if;
75
+
76
+ if v_principal_withdraw then
77
+ -- HOSTED (D7): the owner withdraws its own delegate whatever its board role, but ONLY from
78
+ -- the states a self-leave may leave. Never from `removed`: turning an eviction into `left`
79
+ -- would erase the standing R-LINK-7 reads and let the owner re-add the evicted delegate.
80
+ if v_existing_state not in ('joined', 'subscribed', 'invited', 'requested') then
81
+ raise exception 'a principal may withdraw its delegate only from an active or pending membership'
82
+ using errcode = '42501';
83
+ end if;
84
+ elsif not v_is_service then
85
+ if v_actor = p_agent_id then
86
+ if not (
87
+ (p_membership_state = 'left'
88
+ and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
89
+ or (p_membership_state in ('joined', 'subscribed')
90
+ and v_existing_state in ('joined', 'subscribed'))
91
+ or (p_membership_state = v_existing_state
92
+ and p_membership_state in ('left', 'removed'))
93
+ ) then
94
+ raise exception 'self membership change not permitted; use the join/approval path'
95
+ using errcode = '42501';
96
+ end if;
97
+ else
98
+ if agent_board._role_rank(v_actor_role)
99
+ <= agent_board._role_rank(
100
+ case
101
+ when p_membership_state in ('joined', 'subscribed')
102
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
103
+ then 'member'
104
+ else coalesce(v_target_role, '')
105
+ end) then
106
+ raise exception 'board membership change requires a higher board role than the target'
107
+ using errcode = '42501';
108
+ end if;
109
+
110
+ if p_membership_state in ('joined', 'subscribed')
111
+ and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
112
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
113
+ raise exception 'admitting % requires an act by that agent', p_agent_id
114
+ using errcode = '42501';
115
+ end if;
116
+
117
+ -- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
118
+ -- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
119
+ -- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
120
+ -- states are already consent-gated above; service_role/self never reach this branch.
121
+ if p_membership_state in ('invited', 'requested')
122
+ and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
123
+ raise exception 'target has blocked invitations from this inviter on this board'
124
+ using errcode = '42501';
125
+ end if;
126
+ end if;
127
+ end if;
128
+
129
+ if not v_is_service
130
+ and p_membership_state = v_existing_state
131
+ and p_membership_state in ('left', 'removed') then
132
+ return query
133
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
134
+ from agent_board.board_memberships bm
135
+ where bm.board_id = p_board_id
136
+ and bm.agent_id = p_agent_id
137
+ order by
138
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
139
+ bm.created_at desc,
140
+ bm.id desc
141
+ limit 1;
142
+ return;
143
+ end if;
144
+
145
+ select * into v_membership
146
+ from agent_board._upsert_board_membership_state(
147
+ p_board_id,
148
+ p_agent_id,
149
+ p_membership_state,
150
+ null,
151
+ p_metadata
152
+ );
153
+
154
+ return query
155
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
156
+ end;
157
+ $function$;
158
+
159
+ -- Consiliency/message-board#675: enforce at the table so every RPC, raw writer,
160
+ -- DELETE and FK cascade has the same invariant. Existing ownerless boards remain usable.
161
+ create or replace function agent_board._board_memberships_last_owner_guard()
162
+ returns trigger
163
+ language plpgsql
164
+ security definer
165
+ set search_path = ''
166
+ as $function$
167
+ declare
168
+ v_key bigint := pg_catalog.hashtextextended(
169
+ 'agent_board.board_authority_ceilings:' || old.board_id::text, 0);
170
+ begin
171
+ if old.role <> 'owner' or old.membership_state not in ('joined', 'subscribed') then
172
+ if tg_op = 'DELETE' then return old; else return new; end if;
173
+ end if;
174
+ if tg_op = 'UPDATE' then
175
+ if new.board_id = old.board_id and new.agent_id = old.agent_id and new.role = 'owner'
176
+ and new.membership_state in ('joined', 'subscribed') then
177
+ return new;
178
+ end if;
179
+ end if;
180
+ -- Deleting the board itself is not leaving an existing board ownerless.
181
+ if not exists (select 1 from agent_board.boards where id = old.board_id) then
182
+ if tg_op = 'DELETE' then return old; else return new; end if;
183
+ end if;
184
+ -- A transaction snapshot does not advance after an advisory-lock wait.
185
+ if current_setting('transaction_isolation') <> 'read committed' then
186
+ raise exception 'last_active_board_owner: owner transitions require read committed'
187
+ using errcode = '25001';
188
+ end if;
189
+ -- Require the board mutex to have been acquired BEFORE the row lock. This
190
+ -- covers raw DML and cascades without taking a waiting lock in reverse order.
191
+ if not exists (
192
+ select 1 from pg_catalog.pg_locks l
193
+ where l.locktype = 'advisory' and l.pid = pg_catalog.pg_backend_pid()
194
+ and l.database = (select oid from pg_catalog.pg_database where datname = current_database())
195
+ and l.classid = ((v_key >> 32) & 4294967295)::oid
196
+ and l.objid = (v_key & 4294967295)::oid and l.objsubid = 1
197
+ and l.mode = 'ExclusiveLock' and l.granted
198
+ ) then
199
+ raise exception 'last_active_board_owner: acquire board authority lock before owner transition'
200
+ using errcode = '55000';
201
+ end if;
202
+ -- Re-entrant: it CANNOT wait, since this backend already holds the exclusive
203
+ -- key. Promote a session-level lock to transaction lifetime so releasing the
204
+ -- session lock early cannot let a second departure miss our uncommitted exit.
205
+ perform pg_catalog.pg_advisory_xact_lock(v_key);
206
+ -- A separate volatile statement has a fresh read-committed snapshot.
207
+ if not exists (
208
+ select 1 from agent_board.board_memberships bm
209
+ where bm.board_id = old.board_id and bm.id <> old.id
210
+ and bm.role = 'owner' and bm.membership_state in ('joined', 'subscribed')
211
+ ) then
212
+ raise exception 'last_active_board_owner: cannot remove the last active owner'
213
+ using errcode = '23514';
214
+ end if;
215
+ if tg_op = 'DELETE' then return old; else return new; end if;
216
+ end;
217
+ $function$;
218
+
219
+ revoke all on function agent_board._board_memberships_last_owner_guard()
220
+ from public, anon, authenticated, service_role, agent_board_delegate;
221
+ comment on function agent_board._board_memberships_last_owner_guard() is
222
+ 'Consiliency/message-board#675: table-level UPDATE/DELETE guard against removing the last joined/subscribed owner of an existing board, including raw DML and cascades. No service-role bypass. Owner transitions require read committed and serialize to commit on the board authority advisory key; a pre-held exclusive board advisory lock is required (55000 otherwise) and reacquired re-entrantly for transaction lifetime. All waiting locks precede row locks. Existing ownerless boards and unrelated writes are unaffected; recovery by restoring ownership remains service_role-only.';
223
+
224
+ create trigger trg_board_memberships_last_owner_guard
225
+ before update or delete on agent_board.board_memberships
226
+ for each row execute function agent_board._board_memberships_last_owner_guard();
227
+ comment on trigger trg_board_memberships_last_owner_guard on agent_board.board_memberships is
228
+ 'Refuse only a transition that removes an existing active owner and leaves no other active owner; every membership writer is covered by construction. Refusal-only: NEW is never rewritten. A future hard agent delete must acquire every owned board advisory lock before agent or membership row locks.';
229
+
230
+ revoke all on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
231
+ from public, anon, agent_board_delegate;
232
+ grant execute on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
233
+ to authenticated, service_role;
234
+
235
+ comment on function agent_board.update_board_membership(uuid,uuid,text,jsonb) is
236
+ 'Authorized membership state transitions with existing self, principal-withdrawal, rank, consent and idempotency rules. Consiliency/message-board#675: board advisory lock before target membership/FK locks; the table refuses any transition removing the last active owner.';
237
+
238
+ comment on function agent_board._lock_board_authority_ceiling(uuid) is
239
+ '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)] READERS[serialized: add_delegate_to_board, grant_board_role, link_session_alias, mint_delegated_child, onboard_federated_fleet_agent, readmit_session_identity][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] The SIDSCHEMA readers take the mutexes for every requested or existing descendant room before root, identity and membership locks. The two UNSERIALIZED readers still evaluate a missing ceiling as default-owner without serializing its absence; that known race (message-board#277) is not changed here. Both lists are machine-compared against the catalog; a new reader/writer or changed serialization state fails by name. Internal invoker helper, not an authorization gate; caller privileges determine table access. Consiliency/message-board#675: also the per-board departure mutex, acquired by update_board_membership before row locks and held through commit; all destructive owner transitions require this key, including direct writes and hard agent-deletion cascades.';
@@ -0,0 +1,367 @@
1
+ -- Consiliency/message-board#665: membership attribution comes from the session.
2
+
3
+
4
+ create or replace function agent_board.invite_board_member(
5
+ p_board_id uuid,
6
+ p_agent_id uuid,
7
+ p_created_by_agent_id uuid,
8
+ p_metadata jsonb default '{}'::jsonb
9
+ )
10
+ returns table(
11
+ membership_id uuid,
12
+ board_id uuid,
13
+ agent_id uuid,
14
+ membership_state text,
15
+ created_at timestamptz,
16
+ updated_at timestamptz
17
+ )
18
+ language plpgsql
19
+ security definer
20
+ set search_path = 'agent_board', 'public'
21
+ as $function$
22
+ declare
23
+ v_board agent_board.boards%rowtype;
24
+ v_membership agent_board.board_memberships%rowtype;
25
+ v_actor uuid := agent_board.current_agent_id();
26
+ begin
27
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
28
+ if v_actor is null and not agent_board._is_service_role() then
29
+ raise exception 'current agent principal not resolved' using errcode = '42501';
30
+ end if;
31
+
32
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
33
+ if v_board.id is null then
34
+ raise exception 'board not found' using errcode = 'P0002';
35
+ end if;
36
+ if v_actor is not null then
37
+ if v_board.role_gate_enforced then
38
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
39
+ -- must hold an active owner/admin membership.
40
+ if not exists (
41
+ select 1
42
+ from agent_board.board_memberships bm
43
+ where bm.board_id = p_board_id
44
+ and bm.agent_id = v_actor
45
+ and bm.membership_state in ('joined', 'subscribed')
46
+ and bm.role in ('owner', 'admin')
47
+ ) then
48
+ raise exception 'board invite requires owner or admin role' using errcode = '42501';
49
+ end if;
50
+ elsif v_actor <> v_board.created_by_agent_id then
51
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
52
+ if not exists (
53
+ select 1
54
+ from agent_board.board_memberships bm
55
+ where bm.board_id = p_board_id
56
+ and bm.agent_id = v_actor
57
+ and bm.membership_state in ('joined', 'subscribed')
58
+ ) then
59
+ raise exception 'board invite requires active membership' using errcode = '42501';
60
+ end if;
61
+ end if;
62
+ end if;
63
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
64
+ raise exception 'agent not found' using errcode = 'P0002';
65
+ end if;
66
+
67
+ -- INVITEPOLICY (#90): a target may block a specific inviter on a specific board.
68
+ -- Gate on the SESSION actor (the real inviter), never p_created_by_agent_id (an
69
+ -- assertion). A null actor is the service_role operator carve-out (permissive).
70
+ if v_actor is not null
71
+ and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
72
+ raise exception 'target has blocked invitations from this inviter on this board'
73
+ using errcode = '42501';
74
+ end if;
75
+
76
+ v_membership := agent_board._upsert_board_membership_state(
77
+ p_board_id,
78
+ p_agent_id,
79
+ 'invited',
80
+ v_actor,
81
+ p_metadata
82
+ );
83
+
84
+ return query
85
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
86
+ end;
87
+ $function$;
88
+
89
+ revoke all on function agent_board.invite_board_member(uuid,uuid,uuid,jsonb) from public, anon;
90
+
91
+ grant execute on function agent_board.invite_board_member(uuid,uuid,uuid,jsonb) to authenticated, service_role;
92
+
93
+ comment on function agent_board.invite_board_member(uuid,uuid,uuid,jsonb) is 'Membership attribution is derived from current_agent_id(); p_created_by_agent_id is retained for signature compatibility only. Unresolved service-role writes supply no agent attribution.';
94
+
95
+ create or replace function agent_board.resolve_board_request(
96
+ p_board_id uuid,
97
+ p_agent_id uuid,
98
+ p_membership_state text,
99
+ p_created_by_agent_id uuid,
100
+ p_metadata jsonb default '{}'::jsonb
101
+ )
102
+ 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)
103
+ language plpgsql
104
+ security definer
105
+ set search_path to 'agent_board', 'public'
106
+ as $function$
107
+ declare
108
+ v_effective_state text;
109
+ v_board agent_board.boards%rowtype;
110
+ v_membership_id uuid;
111
+ v_active_id uuid;
112
+ v_membership agent_board.board_memberships%rowtype;
113
+ v_actor uuid := agent_board.current_agent_id();
114
+ begin
115
+ if v_actor is null and not agent_board._is_service_role() then
116
+ raise exception 'current agent principal not resolved' using errcode = '42501';
117
+ end if;
118
+
119
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
120
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
121
+ end if;
122
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
123
+ if v_board.id is null then
124
+ raise exception 'board not found' using errcode = 'P0002';
125
+ end if;
126
+ if v_actor is not null then
127
+ if v_board.role_gate_enforced then
128
+ if not exists (
129
+ select 1
130
+ from agent_board.board_memberships bm
131
+ where bm.board_id = p_board_id
132
+ and bm.agent_id = v_actor
133
+ and bm.membership_state in ('joined', 'subscribed')
134
+ and bm.role in ('owner', 'admin')
135
+ ) then
136
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
137
+ end if;
138
+ elsif v_actor <> v_board.created_by_agent_id then
139
+ if not exists (
140
+ select 1
141
+ from agent_board.board_memberships bm
142
+ where bm.board_id = p_board_id
143
+ and bm.agent_id = v_actor
144
+ and bm.membership_state in ('joined', 'subscribed')
145
+ ) then
146
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
147
+ end if;
148
+ end if;
149
+ end if;
150
+
151
+ -- Lock the WHOLE (board, agent) set first so a concurrent self-withdrawal on ANY physical row
152
+ -- serialises behind this resolution (#109's race is per-(board,agent), not per-row).
153
+ perform 1
154
+ from agent_board.board_memberships bm
155
+ where bm.board_id = p_board_id
156
+ and bm.agent_id = p_agent_id
157
+ order by bm.id
158
+ for update;
159
+
160
+ -- Select the PENDING row explicitly (not the total-order winner). B2 (#116): with TWO pending
161
+ -- rows (a requested AND an invited) for one subject this resolves the NEWEST; the older dangles.
162
+ -- Inherent to the multi-row model — resolution is one act on one row.
163
+ select bm.id
164
+ into v_membership_id
165
+ from agent_board.board_memberships bm
166
+ where bm.board_id = p_board_id
167
+ and bm.agent_id = p_agent_id
168
+ and bm.membership_state in ('requested', 'invited')
169
+ order by bm.created_at desc, bm.id desc
170
+ limit 1;
171
+
172
+ select bm.id
173
+ into v_active_id
174
+ from agent_board.board_memberships bm
175
+ where bm.board_id = p_board_id
176
+ and bm.agent_id = p_agent_id
177
+ and bm.membership_state in ('joined', 'subscribed')
178
+ order by bm.created_at desc, bm.id desc
179
+ limit 1;
180
+
181
+ if v_membership_id is null then
182
+ raise exception 'pending board membership not found' using errcode = 'P0002';
183
+ end if;
184
+
185
+ -- OPTION 1 FALLBACK (#109 A3): approving a subject who is ALREADY active is a no-op success,
186
+ -- not a promotion of the pending row into collision with the active one. Return the active row
187
+ -- untouched. Rejection still falls through to act on the pending row.
188
+ --
189
+ -- B4 (#116): the no-op path intentionally IGNORES the requested target state and p_metadata —
190
+ -- approving with target 'subscribed' a 'joined' active row returns 'joined' (a self-toggle
191
+ -- belongs to update_board_membership, which is where the old path coupled the metadata write to
192
+ -- a demotion). Nothing legitimate is lost.
193
+ if p_membership_state in ('joined', 'subscribed') and v_active_id is not null then
194
+ -- B1 (#116): retire the redundant pending row so it stops showing in pending listings. The
195
+ -- subject is already active via v_active_id (untouched); route the retire through the sink so
196
+ -- the write EMITS (the emission-completeness invariant requires every membership write to hit
197
+ -- the ledger) — a board-authored 'left' event, which is NEUTRAL for consent (not the subject's
198
+ -- own act) and does not affect the subject's consent-present status carried by the active row.
199
+ -- 'left' is recoverable. Previously only a reject cleared this, leaving an owner-visible
200
+ -- dangling request after a no-op approve.
201
+ perform agent_board._upsert_board_membership_state(
202
+ p_board_id, p_agent_id, 'left', v_actor, p_metadata, v_membership_id, 'noop_superseded'
203
+ );
204
+
205
+ return query
206
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
207
+ from agent_board.board_memberships bm
208
+ where bm.id = v_active_id;
209
+ return;
210
+ end if;
211
+
212
+ -- REJECTSEM (#89): a rejection is NOT an eviction — resolve only acts on a PENDING row, so a
213
+ -- refusal lands on 'left' (recoverable), never 'removed' (which bars self-re-entry). Applied to
214
+ -- both requested and invited origins.
215
+ v_effective_state := case when p_membership_state = 'removed' then 'left'
216
+ else p_membership_state end;
217
+
218
+ -- #82: promotion into an active state requires the subject's own consent act (the sole
219
+ -- consent-predicate read site). Authenticated only; service_role keeps the operator carve-out.
220
+ if v_actor is not null
221
+ and v_effective_state in ('joined', 'subscribed')
222
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
223
+ raise exception 'admitting % requires an act by that agent', p_agent_id
224
+ using errcode = '42501';
225
+ end if;
226
+
227
+ v_membership := agent_board._upsert_board_membership_state(
228
+ p_board_id,
229
+ p_agent_id,
230
+ v_effective_state,
231
+ v_actor,
232
+ p_metadata,
233
+ v_membership_id,
234
+ case when p_membership_state = 'removed' then 'reject_withdrawn' else null end
235
+ );
236
+
237
+ return query
238
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
239
+ end;
240
+ $function$;
241
+
242
+ revoke all on function agent_board.resolve_board_request(uuid,uuid,text,uuid,jsonb) from public, anon;
243
+
244
+ grant execute on function agent_board.resolve_board_request(uuid,uuid,text,uuid,jsonb) to authenticated, service_role;
245
+
246
+ comment on function agent_board.resolve_board_request(uuid,uuid,text,uuid,jsonb) is 'Membership attribution is derived from current_agent_id(); p_created_by_agent_id is retained for signature compatibility only. Unresolved service-role writes supply no agent attribution.';
247
+
248
+ create or replace function agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
249
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
250
+ LANGUAGE plpgsql
251
+ SECURITY DEFINER
252
+ SET search_path TO 'agent_board', 'public'
253
+ as $$
254
+ declare
255
+ v_board agent_board.boards%rowtype;
256
+ v_existing_state text;
257
+ v_membership agent_board.board_memberships%rowtype;
258
+ v_actor uuid := agent_board.current_agent_id();
259
+ begin
260
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
261
+ if v_actor is null and not agent_board._is_service_role() then
262
+ raise exception 'current agent principal not resolved' using errcode = '42501';
263
+ end if;
264
+
265
+ if p_membership_state not in ('joined', 'subscribed') then
266
+ raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
267
+ end if;
268
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
269
+ raise exception 'agent not found' using errcode = 'P0002';
270
+ end if;
271
+
272
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
273
+ if v_board.id is null then
274
+ raise exception 'board not found' using errcode = 'P0002';
275
+ end if;
276
+
277
+ -- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
278
+ -- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
279
+ -- locking an active member out of their own private / invite_only board (P0002).
280
+ select bm.membership_state
281
+ into v_existing_state
282
+ from agent_board.board_memberships bm
283
+ where bm.board_id = p_board_id
284
+ and bm.agent_id = p_agent_id
285
+ order by
286
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
287
+ bm.created_at desc,
288
+ bm.id desc
289
+ limit 1;
290
+
291
+ -- #82: the CREATOR EXEMPTION IS REMOVED. Previously a board's creator could join ANY agent
292
+ -- to their board -- `v_actor <> v_board.created_by_agent_id` let them past this guard, and
293
+ -- the joinability check below only runs for self-joins, so nothing else stopped them. That
294
+ -- is the force-join hole. Creating a board does not entitle you to place other people in it.
295
+ --
296
+ -- Only the subject may join the subject. service_role (v_actor null) keeps the operator
297
+ -- carve-out, which is deliberate and maintainer-confirmed.
298
+ if v_actor is not null and v_actor <> p_agent_id then
299
+ raise exception 'board membership actor mismatch' using errcode = '42501';
300
+ end if;
301
+
302
+ -- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
303
+ -- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
304
+ -- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
305
+ -- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
306
+ -- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
307
+ -- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
308
+ --
309
+ -- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
310
+ -- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
311
+ -- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
312
+ -- created_at can surface 'left' as the total-order winner while standing is still removed
313
+ -- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
314
+ if v_actor is not null
315
+ and v_actor = p_agent_id
316
+ and v_board.created_by_agent_id <> v_actor
317
+ and not (
318
+ not (
319
+ exists (
320
+ select 1 from agent_board.board_memberships bm
321
+ where bm.board_id = p_board_id
322
+ and bm.agent_id = p_agent_id
323
+ and bm.membership_state = 'removed'
324
+ ) and not exists (
325
+ select 1 from agent_board.board_memberships bm
326
+ where bm.board_id = p_board_id
327
+ and bm.agent_id = p_agent_id
328
+ and bm.membership_state in ('joined', 'subscribed')
329
+ )
330
+ )
331
+ and (
332
+ v_board.visibility = 'open'
333
+ -- #108: HOLDING AN INVITATION ADMITS THE SUBJECT ON ANY VISIBILITY, not only
334
+ -- invite_only. Previously `private` and `directory_hidden` were excluded outright, so
335
+ -- an invited subject had NO act available -- verified: a first-time invitee with no
336
+ -- prior refusal was blocked identically to a refused one. Gating makes consent
337
+ -- REQUIRED, so a board type where consent cannot be GIVEN is correct-but-unusable.
338
+ --
339
+ -- This is the narrowest widening that makes consent exercisable. It admits nobody new:
340
+ -- this whole guard is reached only when v_actor = p_agent_id (the actor-mismatch raise
341
+ -- above returns otherwise), so it is self-accept only. The board still decides who is
342
+ -- invited; the subject decides whether to accept.
343
+ or coalesce(v_existing_state, '') = 'invited'
344
+ or coalesce(v_existing_state, '') in ('joined', 'subscribed')
345
+ )
346
+ ) then
347
+ raise exception 'board is not joinable by this agent' using errcode = 'P0002';
348
+ end if;
349
+
350
+ v_membership := agent_board._upsert_board_membership_state(
351
+ p_board_id,
352
+ p_agent_id,
353
+ p_membership_state,
354
+ v_actor,
355
+ p_metadata
356
+ );
357
+
358
+ return query
359
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
360
+ end;
361
+ $$;
362
+
363
+ revoke all on function agent_board.join_board(uuid,uuid,text,uuid,jsonb) from public, anon;
364
+
365
+ grant execute on function agent_board.join_board(uuid,uuid,text,uuid,jsonb) to authenticated, service_role;
366
+
367
+ comment on function agent_board.join_board(uuid,uuid,text,uuid,jsonb) is 'Membership attribution is derived from current_agent_id(); p_created_by_agent_id is retained for signature compatibility only. Unresolved service-role writes supply no agent attribution.';