@consiliency/agent-board-schema 1.12.0 → 1.23.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 (156) hide show
  1. package/fixtures/already-applied.json +160 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  34. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  35. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  36. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  37. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  38. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  39. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  40. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  41. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  42. package/package.json +1 -1
  43. package/schema-manifest.json +363 -3
  44. package/src/tooling.test.mjs +113 -2
  45. package/tests/agent_board_actorspoof.test.sql +349 -0
  46. package/tests/agent_board_agentsdir.test.sql +154 -0
  47. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  48. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  49. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  50. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  51. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  52. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  53. package/tests/agent_board_claimkey.test.sql +229 -0
  54. package/tests/agent_board_consent_backfill.test.sql +48 -0
  55. package/tests/agent_board_consent_emission.test.sql +601 -0
  56. package/tests/agent_board_consent_gating.test.sql +489 -0
  57. package/tests/agent_board_consent_substrate.test.sql +720 -0
  58. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  59. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  60. package/tests/agent_board_delegation_policies.test.sql +107 -1
  61. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  62. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  63. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  64. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  65. package/tests/agent_board_fedsession.test.sql +51 -5
  66. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  67. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  68. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  69. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  70. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  71. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  72. package/tests/agent_board_fleet_policy.test.sql +2 -2
  73. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  74. package/tests/agent_board_identity_providers.test.sql +9 -0
  75. package/tests/agent_board_inbox_reads.test.sql +45 -31
  76. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  77. package/tests/agent_board_invitepolicy.test.sql +205 -0
  78. package/tests/agent_board_leaveidem.test.sql +194 -0
  79. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  80. package/tests/agent_board_mint_harden.test.sql +303 -0
  81. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  82. package/tests/agent_board_obsgate.test.sql +178 -0
  83. package/tests/agent_board_obsgate2.test.sql +153 -0
  84. package/tests/agent_board_obsgate3.test.sql +170 -0
  85. package/tests/agent_board_obsgate4.test.sql +196 -0
  86. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  87. package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
  88. package/tests/agent_board_pubexec_class.test.sql +53 -0
  89. package/tests/agent_board_rejectsem.test.sql +271 -0
  90. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  91. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  92. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  93. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  94. package/tests/agent_board_rolereset.test.sql +351 -0
  95. package/tests/agent_board_roomdeleg.test.sql +5 -3
  96. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  97. package/tests/agent_board_roomlink.test.sql +27 -11
  98. package/tests/agent_board_rowid.test.sql +1274 -0
  99. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  100. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  101. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  102. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  103. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  104. package/tests/agent_board_signing_store.test.sql +6 -6
  105. package/tests/agent_board_wake_recovery.test.sql +756 -0
  106. package/tests/fixtures/isolation-substrate.sql +93 -0
  107. package/tests/fixtures/test-prereqs.sql +33 -0
  108. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  109. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  110. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  111. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  112. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  113. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  114. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  115. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  116. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  117. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  118. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  119. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  120. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  121. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  122. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  123. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  124. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  125. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  126. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  127. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  128. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  129. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  130. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  131. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  132. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  133. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  134. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  135. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  136. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  137. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  138. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  139. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  140. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  141. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  142. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  143. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  144. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  145. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  146. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  147. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  148. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  149. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  150. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  151. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  152. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  153. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  154. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  155. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
  156. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -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;