@consiliency/agent-board-schema 1.12.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/fixtures/already-applied.json +124 -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/package.json +1 -1
  34. package/schema-manifest.json +282 -3
  35. package/src/tooling.test.mjs +113 -2
  36. package/tests/agent_board_actorspoof.test.sql +349 -0
  37. package/tests/agent_board_agentsdir.test.sql +154 -0
  38. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  39. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  40. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  41. package/tests/agent_board_claimkey.test.sql +229 -0
  42. package/tests/agent_board_consent_backfill.test.sql +48 -0
  43. package/tests/agent_board_consent_emission.test.sql +601 -0
  44. package/tests/agent_board_consent_gating.test.sql +489 -0
  45. package/tests/agent_board_consent_substrate.test.sql +720 -0
  46. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  47. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  48. package/tests/agent_board_delegation_policies.test.sql +107 -1
  49. package/tests/agent_board_fedsession.test.sql +6 -4
  50. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  51. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  52. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  53. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  54. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  55. package/tests/agent_board_fleet_policy.test.sql +2 -2
  56. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  57. package/tests/agent_board_identity_providers.test.sql +9 -0
  58. package/tests/agent_board_inbox_reads.test.sql +29 -12
  59. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  60. package/tests/agent_board_invitepolicy.test.sql +205 -0
  61. package/tests/agent_board_leaveidem.test.sql +194 -0
  62. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  63. package/tests/agent_board_mint_harden.test.sql +303 -0
  64. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  65. package/tests/agent_board_obsgate.test.sql +178 -0
  66. package/tests/agent_board_obsgate2.test.sql +153 -0
  67. package/tests/agent_board_obsgate3.test.sql +170 -0
  68. package/tests/agent_board_obsgate4.test.sql +196 -0
  69. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  70. package/tests/agent_board_pubexec_class.test.sql +53 -0
  71. package/tests/agent_board_rejectsem.test.sql +271 -0
  72. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  73. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  74. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  75. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  76. package/tests/agent_board_rolereset.test.sql +351 -0
  77. package/tests/agent_board_roomdeleg.test.sql +5 -3
  78. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  79. package/tests/agent_board_roomlink.test.sql +25 -9
  80. package/tests/agent_board_rowid.test.sql +1274 -0
  81. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  82. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  83. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  84. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  85. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  86. package/tests/agent_board_signing_store.test.sql +6 -6
  87. package/tests/fixtures/isolation-substrate.sql +93 -0
  88. package/tests/fixtures/test-prereqs.sql +33 -0
  89. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  90. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  91. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  92. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  93. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  94. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  95. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  96. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  97. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  98. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  99. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  100. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  101. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  102. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  103. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  104. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,489 @@
1
+ -- MBCONSENT-B(ii) pgTAP: ROUTE GATING (#82) + the paired widening (#108).
2
+ --
3
+ -- The acceptance suite for plan #111. Unlike the three inert predecessors, this change makes
4
+ -- calls that succeed today begin failing -- so EVERY deny assertion has a paired allow, and the
5
+ -- allow half is not optional. A gate that refuses everything passes every force-join test; that
6
+ -- exact defect shipped in the first revision of this migration and was caught in review.
7
+ --
8
+ -- Methodology:
9
+ -- * RPC probes are driven by the JWT claim triplet only. SET LOCAL ROLE is never used: it
10
+ -- revokes _is_service_role() and would mask the operator carve-out behind a permission error
11
+ -- on the function itself.
12
+ -- * The class assertion (EC-4) is derived from the CATALOG, not a hand-maintained list, so a
13
+ -- route added later fails it AND NAMES ITSELF. Every prior statement of the route set said
14
+ -- four; there are five.
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public, agent_board;
17
+
18
+ begin;
19
+
20
+ select plan(53);
21
+
22
+ create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
23
+ begin
24
+ perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',p_sub)::text, true);
25
+ end $$;
26
+ create function pg_temp.service() returns void language plpgsql as $$
27
+ begin perform set_config('request.jwt.claims', '', true); end $$;
28
+ create function pg_temp.state(b uuid, a uuid) returns text language sql as $$
29
+ select membership_state from agent_board.board_memberships
30
+ where board_id = b and agent_id = a
31
+ order by case when membership_state in ('joined','subscribed') then 0 else 1 end,
32
+ created_at desc, id desc
33
+ limit 1;
34
+ $$;
35
+
36
+ insert into auth.users(id, instance_id, aud, role, email)
37
+ select ('c0000000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
38
+ '00000000-0000-0000-0000-000000000000','authenticated','authenticated','gate'||n||'@t.test'
39
+ from generate_series(1,10) n;
40
+
41
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
42
+ select ('c1000000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid,
43
+ 'agent','agent','gate-agent-'||n,'Gate Agent '||n,'active',
44
+ ('c0000000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid
45
+ from generate_series(1,10) n;
46
+
47
+ -- Agent 1 owns every board here.
48
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
49
+ values ('c2000000-0000-4000-8000-000000000001','gate-open','Gate Open',
50
+ 'c1000000-0000-4000-8000-000000000001','open','active'),
51
+ ('c2000000-0000-4000-8000-000000000002','gate-priv','Gate Private',
52
+ 'c1000000-0000-4000-8000-000000000001','private','active'),
53
+ ('c2000000-0000-4000-8000-000000000003','gate-hidden','Gate Hidden',
54
+ 'c1000000-0000-4000-8000-000000000001','directory_hidden','active'),
55
+ ('c2000000-0000-4000-8000-000000000004','gate-inv','Gate InviteOnly',
56
+ 'c1000000-0000-4000-8000-000000000001','invite_only','active');
57
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
58
+ select b, 'c1000000-0000-4000-8000-000000000001'::uuid, 'owner', 'joined',
59
+ 'c1000000-0000-4000-8000-000000000001'::uuid
60
+ from unnest(array['c2000000-0000-4000-8000-000000000001','c2000000-0000-4000-8000-000000000002',
61
+ 'c2000000-0000-4000-8000-000000000003','c2000000-0000-4000-8000-000000000004']::uuid[]) b;
62
+
63
+ -- ===========================================================================
64
+ -- EC-1 DENY -- no route admits a non-consenting subject
65
+ -- ===========================================================================
66
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
67
+
68
+ -- join_board: the CREATOR path. This is the #82 hole: creating a board did not entitle the
69
+ -- creator to place other people in it, but the actor-mismatch guard explicitly let them past.
70
+ select throws_ok($$
71
+ select agent_board.join_board(
72
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000002'::uuid,
73
+ 'joined'::text, null::uuid, '{}'::jsonb)
74
+ $$, '42501', 'board membership actor mismatch',
75
+ 'EC-1 join_board: a board CREATOR cannot join a stranger to their own board');
76
+
77
+ -- update_board_membership: an owner cannot ADMIT a non-consenting subject.
78
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id, left_at)
79
+ values ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003',
80
+ 'member','left','c1000000-0000-4000-8000-000000000001', now());
81
+ select throws_ok($$
82
+ select agent_board.update_board_membership(
83
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003','joined')
84
+ $$, '42501', null,
85
+ 'EC-1 update: an owner cannot admit a non-consenting subject');
86
+
87
+ -- resolve_board_request: a MANUFACTURED request is board-authored, therefore NEUTRAL on the
88
+ -- ledger, and admits nobody. This is the laundering route.
89
+ select lives_ok($$
90
+ select agent_board.update_board_membership(
91
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003','requested')
92
+ $$, 'EC-1 setup: an owner MAY manufacture a `requested` row -- it is neutral');
93
+ select throws_ok($$
94
+ select agent_board.resolve_board_request(
95
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000003'::uuid,
96
+ 'joined'::text, null::uuid, '{}'::jsonb)
97
+ $$, '42501', null,
98
+ 'EC-1 resolve: approving a MANUFACTURED request admits nobody');
99
+
100
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003'),
101
+ 'requested',
102
+ 'EC-1: after all three refusals the subject is still NOT active');
103
+
104
+ -- ===========================================================================
105
+ -- EC-2 ALLOW -- every legitimate path still works
106
+ -- ===========================================================================
107
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000002');
108
+ select lives_ok($$
109
+ select agent_board.join_board(
110
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000002'::uuid,
111
+ 'joined'::text, null::uuid, '{}'::jsonb) $$,
112
+ 'EC-2 join_board: the subject self-joins an open board [call succeeds]');
113
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000002'),
114
+ 'joined', 'EC-2 join_board: the subject self-joins an open board');
115
+
116
+ -- a GENUINE request, approved
117
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000004');
118
+ select lives_ok($$
119
+ select agent_board.request_join_board(
120
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000004')
121
+ $$, 'EC-2 setup: the subject requests');
122
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
123
+ select lives_ok($$
124
+ select agent_board.resolve_board_request(
125
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000004'::uuid,
126
+ 'joined'::text, null::uuid, '{}'::jsonb) $$,
127
+ 'EC-2 resolve: an owner approves a GENUINE request [call succeeds]');
128
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000004'),
129
+ 'joined', 'EC-2 resolve: an owner approves a GENUINE request');
130
+
131
+ -- promoting a CONSENTING subject via update_board_membership
132
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000005');
133
+ select lives_ok($$
134
+ select agent_board.request_join_board(
135
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005')
136
+ $$, 'EC-2 setup: another subject requests');
137
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
138
+ select lives_ok($$
139
+ select agent_board.update_board_membership(
140
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005','joined') $$,
141
+ 'EC-2 update: an owner MAY promote a CONSENTING subject [call succeeds]');
142
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005'),
143
+ 'joined', 'EC-2 update: an owner MAY promote a CONSENTING subject');
144
+
145
+ -- A CONSENTING subject with a STALE HIGHER ROLE must still be admissible. Roles are retained
146
+ -- on a departed row and the sink clamps an admission back to `member`, so comparing the actor
147
+ -- against the retained role refused a departed OWNER re-entry that they had genuinely
148
+ -- requested. Deny-side correctness cannot see this; only an allow assertion can.
149
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
150
+ values ('c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000006',
151
+ 'owner','joined','c1000000-0000-4000-8000-000000000001');
152
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000006');
153
+ select lives_ok($$
154
+ select agent_board.leave_board(
155
+ 'c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000006')
156
+ $$, 'EC-2 setup: that owner leaves, RETAINING the owner role on the departed row');
157
+ select lives_ok($$
158
+ select agent_board.request_join_board(
159
+ 'c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000006')
160
+ $$, 'EC-2 setup: and genuinely requests re-entry');
161
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
162
+ -- lives_ok FIRST. Written as a bare select this ABORTS the file when the rank fix is reverted:
163
+ -- 12 of 34 assertions ran and no `not ok` was emitted, so the mutation read as "not caught".
164
+ -- I had just fixed this exact shape in EC-6 and reproduced it in the very next assertion --
165
+ -- which is why the note below matters more than this one fix.
166
+ select lives_ok($$
167
+ select agent_board.update_board_membership(
168
+ 'c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000006','joined')
169
+ $$, 'EC-2: a CONSENTING departed OWNER can be re-admitted -- rank uses the EFFECTIVE role');
170
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000006'),
171
+ 'joined',
172
+ 'EC-2: ...and they are ACTIVE afterwards');
173
+
174
+ -- active -> active is not an admission
175
+ select lives_ok($$
176
+ select agent_board.update_board_membership(
177
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005','subscribed') $$,
178
+ 'EC-2 update: active -> active admits nobody and stays allowed [call succeeds]');
179
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005'),
180
+ 'subscribed', 'EC-2 update: active -> active admits nobody and stays allowed');
181
+
182
+ -- eviction and demotion untouched
183
+ select lives_ok($$
184
+ select agent_board.update_board_membership(
185
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005','removed') $$,
186
+ 'EC-2 update: eviction is untouched [call succeeds]');
187
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000005'),
188
+ 'removed', 'EC-2 update: eviction is untouched');
189
+
190
+ -- re-entry after leaving
191
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000002');
192
+ select lives_ok($$
193
+ select agent_board.leave_board(
194
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000002')
195
+ $$, 'EC-2 setup: the subject leaves');
196
+ select lives_ok($$
197
+ select agent_board.join_board(
198
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000002'::uuid,
199
+ 'joined'::text, null::uuid, '{}'::jsonb) $$,
200
+ 'EC-2 join_board: re-entry after leaving still works [call succeeds]');
201
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000002'),
202
+ 'joined', 'EC-2 join_board: re-entry after leaving still works');
203
+
204
+ -- ===========================================================================
205
+ -- EC-3 OPERATOR -- the carve-out is preserved on every route
206
+ -- ===========================================================================
207
+ select pg_temp.service();
208
+ select lives_ok($$
209
+ select agent_board._upsert_board_membership_state(
210
+ 'c2000000-0000-4000-8000-000000000002','c1000000-0000-4000-8000-000000000006','joined') $$,
211
+ 'EC-3: service_role places a subject directly [call succeeds]');
212
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000002','c1000000-0000-4000-8000-000000000006'),
213
+ 'joined', 'EC-3: service_role places a subject directly');
214
+ select is(
215
+ (select provenance from agent_board.board_membership_events
216
+ where agent_id = 'c1000000-0000-4000-8000-000000000006' order by seq desc limit 1),
217
+ 'operator-designated', 'EC-3: ...recorded as an operator placement, never as consent');
218
+ select lives_ok($$
219
+ select agent_board.resolve_board_request(
220
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000003'::uuid,
221
+ 'joined'::text, null::uuid, '{}'::jsonb) $$,
222
+ 'EC-3: service_role may resolve a pending row the gate refuses to an owner [call succeeds]');
223
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000003'),
224
+ 'joined', 'EC-3: service_role may resolve a pending row the gate refuses to an owner');
225
+
226
+ -- ===========================================================================
227
+ -- EC-6 / EC-7 -- consent is EXERCISABLE (#108), and the widening admits nobody else
228
+ -- ===========================================================================
229
+ -- EC-6: an invitee can accept on EVERY visibility, including the two that previously excluded
230
+ -- self-entry outright and therefore made consent impossible to give.
231
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
232
+ select lives_ok($$
233
+ select agent_board.invite_board_member(
234
+ 'c2000000-0000-4000-8000-000000000002'::uuid,'c1000000-0000-4000-8000-000000000007'::uuid,
235
+ 'c1000000-0000-4000-8000-000000000001'::uuid)
236
+ $$, 'EC-6 setup: the owner invites to a PRIVATE board');
237
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000007');
238
+ -- lives_ok FIRST, then the state. A bare select from join_board RAISES when the widening is
239
+ -- absent, which ABORTS the transaction and kills the rest of the file -- 18 of 28 assertions
240
+ -- ran and no `not ok` was ever emitted. A test that crashes is indistinguishable from a broken
241
+ -- test file, and that ambiguity has misled this work twice. It must FAIL, not abort.
242
+ select lives_ok($$
243
+ select agent_board.join_board(
244
+ 'c2000000-0000-4000-8000-000000000002'::uuid,'c1000000-0000-4000-8000-000000000007'::uuid,
245
+ 'joined'::text, null::uuid, '{}'::jsonb)
246
+ $$, 'EC-6: an invitee can ACCEPT on a private board -- consent is exercisable');
247
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000002','c1000000-0000-4000-8000-000000000007'),
248
+ 'joined', 'EC-6: ...and the invitee is now ACTIVE on 002');
249
+
250
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
251
+ select lives_ok($$
252
+ select agent_board.invite_board_member(
253
+ 'c2000000-0000-4000-8000-000000000003'::uuid,'c1000000-0000-4000-8000-000000000007'::uuid,
254
+ 'c1000000-0000-4000-8000-000000000001'::uuid)
255
+ $$, 'EC-6 setup: the owner invites to a directory_hidden board');
256
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000007');
257
+ -- lives_ok FIRST, then the state. A bare select from join_board RAISES when the widening is
258
+ -- absent, which ABORTS the transaction and kills the rest of the file -- 18 of 28 assertions
259
+ -- ran and no `not ok` was ever emitted. A test that crashes is indistinguishable from a broken
260
+ -- test file, and that ambiguity has misled this work twice. It must FAIL, not abort.
261
+ select lives_ok($$
262
+ select agent_board.join_board(
263
+ 'c2000000-0000-4000-8000-000000000003'::uuid,'c1000000-0000-4000-8000-000000000007'::uuid,
264
+ 'joined'::text, null::uuid, '{}'::jsonb)
265
+ $$, 'EC-6: ...and on directory_hidden');
266
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000003','c1000000-0000-4000-8000-000000000007'),
267
+ 'joined', 'EC-6: ...and the invitee is now ACTIVE on 003');
268
+
269
+ -- EC-7: THE PAIRED ASSERTION. The widening must admit NOBODY ELSE. Dropping the
270
+ -- actor = subject condition would fail THIS while EC-6 still passes -- a re-opened #82 looks
271
+ -- like a WORKING feature with a hole in it, not like a broken one.
272
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
273
+ select lives_ok($$
274
+ select agent_board.invite_board_member(
275
+ 'c2000000-0000-4000-8000-000000000004'::uuid,'c1000000-0000-4000-8000-000000000008'::uuid,
276
+ 'c1000000-0000-4000-8000-000000000001'::uuid)
277
+ $$, 'EC-7 setup: the owner invites agent 8');
278
+ select throws_ok($$
279
+ select agent_board.join_board(
280
+ 'c2000000-0000-4000-8000-000000000004'::uuid,'c1000000-0000-4000-8000-000000000008'::uuid,
281
+ 'joined'::text, null::uuid, '{}'::jsonb)
282
+ $$, '42501', 'board membership actor mismatch',
283
+ 'EC-7: a THIRD PARTY cannot use the widened invitation path for someone else');
284
+ select is(pg_temp.state('c2000000-0000-4000-8000-000000000004','c1000000-0000-4000-8000-000000000008'),
285
+ 'invited',
286
+ 'EC-7: ...and the invitee is still merely invited, not admitted');
287
+
288
+ -- ===========================================================================
289
+ -- EC-4 CLASS -- catalog-derived, so a future ungated route fails and NAMES ITSELF
290
+ -- ===========================================================================
291
+ -- The assertion that would have caught the fifth route. It does not enumerate expected routes
292
+ -- and check them off; it asks the catalog which authenticated functions can reach an active
293
+ -- membership, and requires each to be gated or explicitly exempt WITH A REASON.
294
+ select is(
295
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
296
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
297
+ where n.nspname = 'agent_board' and p.prokind = 'f'
298
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
299
+ and has_function_privilege('authenticated', p.oid, 'EXECUTE')
300
+ -- reaches an active membership, directly or through the shared sink
301
+ and regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
302
+ -- Includes update_board_membership: a one-hop WRAPPER calling it with 'joined'
303
+ -- reaches an active membership without ever naming the sink, and would otherwise
304
+ -- evade this class assertion entirely.
305
+ ~ '(_upsert_board_membership_state|_redeem_invite_for_agent|update_board_membership|insert into agent_board\.board_memberships)'
306
+ -- gated: consults the predicate, or refuses a non-subject actor structurally
307
+ and regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
308
+ !~ '(_membership_consent_present|actor mismatch|requires an act by that agent)'
309
+ -- stated exemptions, each with a reason recorded in the migration header
310
+ and p.proname not in (
311
+ 'create_board', -- the actor IS the subject: it seeds the creator's own seat
312
+ 'redeem_invite_link', -- self-only: no target parameter, passes current_agent_id()
313
+ 'invite_board_member', -- writes `invited` only -- an offer, not a membership
314
+ 'request_join_board', -- writes `requested` only
315
+ 'leave_board', -- writes `left` only
316
+ 'grant_board_role' -- requires an already-active membership; never SETs the state
317
+ )),
318
+ '',
319
+ 'EC-4 CLASS: every authenticated route reaching an active membership is gated or exempt');
320
+
321
+ -- ...and the exemptions are not assertions of convenience: the three "writes only X" claims are
322
+ -- checked, because an exemption that stops being true is how the fifth route hid.
323
+ -- Matches the literal passed to EITHER the shared sink OR update_board_membership. The first
324
+ -- version looked only at the sink, so leave_board -- which calls update_board_membership --
325
+ -- matched nothing and the assertion passed by finding NOTHING rather than by verifying
326
+ -- anything. Right answer, broken check: exactly the fail-open shape this suite exists to stop.
327
+ select is(
328
+ (select coalesce(string_agg(p.proname || '=' || m[1], ', ' order by p.proname), '')
329
+ from pg_proc p
330
+ join pg_namespace n on n.oid = p.pronamespace
331
+ cross join lateral regexp_matches(
332
+ regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g'),
333
+ '(?:_upsert_board_membership_state|update_board_membership)\s*\([^;]*?''(joined|subscribed)''',
334
+ 'g') m
335
+ where n.nspname = 'agent_board'
336
+ and p.proname in ('invite_board_member','request_join_board','leave_board')),
337
+ '',
338
+ 'EC-4: the "writes only a pending state" exemptions still hold -- none reaches an active state');
339
+
340
+ -- ...and each writes the state its exemption claims, verified positively rather than by absence.
341
+ select is(
342
+ (select string_agg(p.proname || '=' || m[1], ' ' order by p.proname)
343
+ from pg_proc p
344
+ join pg_namespace n on n.oid = p.pronamespace
345
+ cross join lateral regexp_matches(
346
+ regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g'),
347
+ '(?:_upsert_board_membership_state|update_board_membership)\s*\([^;]*?''(invited|requested|left)''',
348
+ 'g') m
349
+ where n.nspname = 'agent_board'
350
+ and p.proname in ('invite_board_member','request_join_board','leave_board')),
351
+ 'invite_board_member=invited leave_board=left request_join_board=requested',
352
+ 'EC-4: ...and each writes exactly the pending state its exemption names');
353
+
354
+ -- ===========================================================================
355
+ -- EC-5 NO REGRESSION -- gating did not change what is RECORDED
356
+ -- ===========================================================================
357
+ select is(
358
+ (select provenance from agent_board.board_membership_events
359
+ where agent_id = 'c1000000-0000-4000-8000-000000000002'
360
+ and board_id = 'c2000000-0000-4000-8000-000000000001'
361
+ order by seq asc limit 1),
362
+ 'target-authored',
363
+ 'EC-5: a self-join is still recorded as the subject''s own act');
364
+
365
+ -- ===========================================================================
366
+ -- The consent read happens UNDER a row lock (MBCONSENT-B(iii))
367
+ -- ===========================================================================
368
+ -- Structural, and deliberately labelled as such. The real proof is a TWO-SESSION probe -- a
369
+ -- withdrawal committed between the consent read and the write must not be overwritten -- and
370
+ -- that cannot run inside this single-transaction harness (see #103). Verified live:
371
+ --
372
+ -- A approves, holding the transaction open -> returns joined
373
+ -- B withdraws mid-approval -> BLOCKS 3015ms on A's lock, then applies
374
+ -- final state -> left (the withdrawal is not lost)
375
+ --
376
+ -- What this assertion adds is regression cover: if the `for update` is dropped, the two-session
377
+ -- outcome silently changes and nothing else here would notice.
378
+ -- The lock must span the WHOLE (board_id, agent_id) SET -- both predicates, ORDER BY bm.id,
379
+ -- FOR UPDATE, and NO state filter and NO limit. That breadth is what serializes a concurrent
380
+ -- self-withdrawal on a DIFFERENT physical row (the active one) than the pending row being
381
+ -- resolved. A lock scoped to the pending row alone -- the earlier wrong implementation --
382
+ -- would satisfy a bare "any FOR UPDATE" check while leaving the duplicate-shape race open
383
+ -- (Fable A2). The single-session tests below pin row IDENTITY, not contention, so this
384
+ -- structural assertion is the only durable regression guard until #115's deterministic
385
+ -- two-session probe lands. It is written as one regex so a pending-only, state-filtered, or
386
+ -- LIMITed lock all fail it. The regex anchors on `for update;` (the statement
387
+ -- terminator), NOT a bare `for update`: `for update skip locked` would otherwise pass
388
+ -- while SKIP LOCKED turns the guard into a no-op for exactly the contended row,
389
+ -- silently reopening the race -- the classic wrong "fix" for lock-wait reports. `nowait`
390
+ -- is excluded too (it at least fails loudly).
391
+ select matches(
392
+ (select regexp_replace(regexp_replace(pg_get_functiondef(p.oid), '/\*.*?\*/', '', 'gs'), '--[^\n]*', '', 'g')
393
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
394
+ where n.nspname = 'agent_board' and p.proname = 'resolve_board_request'),
395
+ 'from agent_board\.board_memberships[[:space:]]+bm[[:space:]]+where bm\.board_id = p_board_id[[:space:]]+and bm\.agent_id = p_agent_id[[:space:]]+order by bm\.id[[:space:]]+for update;',
396
+ 'LOCK: resolve_board_request locks the WHOLE (board, agent) set -- unfiltered, id-ordered, for update');
397
+
398
+ -- ...and the consent read comes AFTER that lock, not before it. Order is the whole point:
399
+ -- a lock taken after the check protects nothing.
400
+ select ok(
401
+ (select position('for update' in body) < position('_membership_consent_present' in body)
402
+ from (select regexp_replace(regexp_replace(pg_get_functiondef(p.oid), '/\*.*?\*/', '', 'gs'), '--[^\n]*', '', 'g') as body
403
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
404
+ where n.nspname = 'agent_board' and p.proname = 'resolve_board_request') s),
405
+ 'LOCK: ...and reads consent AFTER taking it, not before');
406
+
407
+ -- The sink acts on the row the CALLER named, rather than reselecting (#109).
408
+ select matches(
409
+ (select regexp_replace(pg_get_functiondef(p.oid), '--[^\n]*', '', 'g')
410
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
411
+ where n.nspname = 'agent_board' and p.proname = '_upsert_board_membership_state'),
412
+ 'if p_membership_id is not null then',
413
+ 'LOCK: the sink honours a caller-supplied row identity instead of reselecting');
414
+
415
+ -- ===========================================================================
416
+ -- MBCONSENT-B(iii): the (active, pending) DUPLICATE shape -- behavioural, not structural
417
+ -- ===========================================================================
418
+ -- The four-seat review (a native Fable seat, executing) found the structural lock assertions
419
+ -- passed a plausible wrong implementation: #109 was unpinned, and approving a consenting
420
+ -- subject on this shape regressed to a raw 23505. These pin the EFFECT, not the presence of a
421
+ -- lock. Agents 9 and 10 are fresh; the duplicate is seeded directly because request_join_board
422
+ -- now refuses to create it (that refusal is asserted separately below).
423
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id, created_at)
424
+ values ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000009',
425
+ 'member','joined','c1000000-0000-4000-8000-000000000001', now() - interval '2 days'),
426
+ ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000009',
427
+ 'member','requested','c1000000-0000-4000-8000-000000000001', now());
428
+ insert into agent_board.board_membership_events(board_id, agent_id, event_kind, result_state, provenance, actor_agent_id)
429
+ values ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000009',
430
+ 'consent_join_request','requested','target-authored','c1000000-0000-4000-8000-000000000009');
431
+
432
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000001');
433
+
434
+ -- APPROVE on the duplicate: no-op success returning the ACTIVE row, never a 23505 (A3).
435
+ select lives_ok($$
436
+ select agent_board.resolve_board_request(
437
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000009'::uuid,'joined'::text,null::uuid,'{}'::jsonb)
438
+ $$, 'DUP: approving an already-active consenting subject succeeds (no 23505)');
439
+ select is(
440
+ (select count(*)::int from agent_board.board_memberships
441
+ where agent_id = 'c1000000-0000-4000-8000-000000000009'
442
+ and membership_state in ('joined','subscribed')),
443
+ 1,
444
+ 'DUP: ...and the active membership is intact, not collided');
445
+
446
+ -- REJECT on the duplicate: the PENDING row goes left, the ACTIVE row is UNTOUCHED (#109 core).
447
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id, created_at)
448
+ -- ACTIVE row NEWER so it wins the created_at DESC sort. A resolver that selects the
449
+ -- total-order winner instead of the pending row (the #109 bug) would then lock and
450
+ -- downgrade THIS active row on reject -- which is exactly what must fail.
451
+ values ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000010',
452
+ 'member','requested','c1000000-0000-4000-8000-000000000001', now() - interval '2 days'),
453
+ ('c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000010',
454
+ 'member','joined','c1000000-0000-4000-8000-000000000001', now());
455
+ select lives_ok($$
456
+ select agent_board.resolve_board_request(
457
+ 'c2000000-0000-4000-8000-000000000001'::uuid,'c1000000-0000-4000-8000-000000000010'::uuid,'removed'::text,null::uuid,'{}'::jsonb)
458
+ $$, 'DUP: rejecting the pending request succeeds');
459
+ select is(
460
+ (select membership_state from agent_board.board_memberships
461
+ where agent_id = 'c1000000-0000-4000-8000-000000000010'
462
+ and membership_state in ('joined','subscribed')),
463
+ 'joined',
464
+ 'DUP: #109 -- rejecting the pending request leaves the ACTIVE membership untouched');
465
+ select is(
466
+ (select count(*)::int from agent_board.board_memberships
467
+ where agent_id = 'c1000000-0000-4000-8000-000000000010' and membership_state = 'left'),
468
+ 1,
469
+ 'DUP: ...and the pending row became left');
470
+
471
+ -- OPTION 3 root cause: an ALREADY-ACTIVE member requesting is a no-op, not a demotion (B5).
472
+ select pg_temp.claims('c0000000-0000-0000-0000-000000000002');
473
+ select lives_ok($$
474
+ select agent_board.request_join_board(
475
+ 'c2000000-0000-4000-8000-000000000001','c1000000-0000-4000-8000-000000000002')
476
+ $$, 'DUP setup: an active member requests');
477
+ select is(
478
+ (select string_agg(membership_state, ',' order by created_at)
479
+ from agent_board.board_memberships
480
+ where board_id = 'c2000000-0000-4000-8000-000000000001'
481
+ and agent_id = 'c1000000-0000-4000-8000-000000000002'),
482
+ 'joined',
483
+ 'DUP: an active member requesting stays a single joined row -- no demotion, no duplicate (B5)');
484
+
485
+ select is((select version from agent_board.schema_version), 8,
486
+ 'schema_version stays 8 -- additive');
487
+
488
+ select finish();
489
+ rollback;