@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,601 @@
1
+ -- MBCONSENT-B(a) pgTAP: EMISSION only. No gating.
2
+ --
3
+ -- The claim under test: every membership transition writes exactly one ledger event whose
4
+ -- provenance is DERIVED FROM THE SESSION and cannot be influenced by any caller argument.
5
+ --
6
+ -- Methodology:
7
+ -- * RPC probes are driven by the JWT claim triplet only. SET LOCAL ROLE is never used here:
8
+ -- it would revoke _is_service_role() and mask the operator branch behind a permission
9
+ -- error on the function itself.
10
+ -- * p_created_by_agent_id is passed DELIBERATELY WRONG in the anti-forge cases. If provenance
11
+ -- were keyed off it rather than the session, those assertions flip.
12
+ create extension if not exists pgtap with schema extensions;
13
+ set search_path = extensions, public, agent_board;
14
+
15
+ begin;
16
+
17
+ select plan(68);
18
+
19
+ create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
20
+ begin
21
+ perform set_config('request.jwt.claims', json_build_object('role','authenticated','sub',p_sub)::text, true);
22
+ end $$;
23
+ create function pg_temp.service() returns void language plpgsql as $$
24
+ begin
25
+ perform set_config('request.jwt.claims', '', true);
26
+ end $$;
27
+ create function pg_temp.latest(p_agent uuid)
28
+ returns table(kind text, prov text, actor uuid, state text) language sql as $$
29
+ select event_kind, provenance, actor_agent_id, result_state
30
+ from agent_board.board_membership_events
31
+ where agent_id = p_agent order by seq desc limit 1;
32
+ $$;
33
+
34
+ insert into auth.users(id, instance_id, aud, role, email)
35
+ select ('f0000000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid,
36
+ '00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated',
37
+ 'emit'||n||'@t.test'
38
+ from generate_series(1,6) n;
39
+
40
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
41
+ select ('f1000000-0000-4000-8000-' || lpad(n::text,12,'0'))::uuid,
42
+ 'agent','agent','emit-agent-'||n,'Emit Agent '||n,'active',
43
+ ('f0000000-0000-0000-0000-' || lpad(n::text,12,'0'))::uuid
44
+ from generate_series(1,6) n;
45
+
46
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
47
+ values ('f2000000-0000-4000-8000-000000000001','emit-b1','Emit B1',
48
+ 'f1000000-0000-4000-8000-000000000001','private','active');
49
+
50
+ -- ===========================================================================
51
+ -- 1. Every transition emits exactly ONE event
52
+ -- ===========================================================================
53
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000002');
54
+ select lives_ok($$
55
+ select agent_board._upsert_board_membership_state(
56
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','joined')
57
+ $$, 'emission: a self-join succeeds');
58
+
59
+ select is(
60
+ (select count(*)::int from agent_board.board_membership_events
61
+ where agent_id = 'f1000000-0000-4000-8000-000000000002'),
62
+ 1,
63
+ 'emission: exactly ONE event per transition, not zero and not two');
64
+
65
+
66
+ -- ===========================================================================
67
+ -- 2. Provenance is derived from the SESSION, not from any argument
68
+ -- ===========================================================================
69
+ select is((select prov from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
70
+ 'target-authored', 'derivation: the subject acting on itself is target-authored');
71
+ select is((select actor from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
72
+ 'f1000000-0000-4000-8000-000000000002'::uuid, 'derivation: ...with the subject as actor');
73
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
74
+ 'consent_self_join', 'derivation: ...and the self-join kind');
75
+
76
+ -- ANTI-FORGE. p_created_by_agent_id is a caller ASSERTION; the session is EVIDENCE. Here the
77
+ -- argument NAMES THE SUBJECT while the session is a third party. If provenance were keyed off
78
+ -- the argument this would read target-authored -- a forged consent event.
79
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000001');
80
+ select lives_ok($$
81
+ select agent_board._upsert_board_membership_state(
82
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000003','invited',
83
+ 'f1000000-0000-4000-8000-000000000003')
84
+ $$, 'anti-forge setup: a third party acts, naming the SUBJECT in p_created_by_agent_id');
85
+
86
+ select is((select prov from pg_temp.latest('f1000000-0000-4000-8000-000000000003')),
87
+ 'board-authored',
88
+ 'ANTI-FORGE: provenance follows the SESSION, not p_created_by_agent_id');
89
+ select is((select actor from pg_temp.latest('f1000000-0000-4000-8000-000000000003')),
90
+ 'f1000000-0000-4000-8000-000000000001'::uuid,
91
+ 'ANTI-FORGE: ...and the actor is the real session, not the named argument');
92
+ select isnt((select actor from pg_temp.latest('f1000000-0000-4000-8000-000000000003')),
93
+ 'f1000000-0000-4000-8000-000000000003'::uuid,
94
+ 'ANTI-FORGE: the subject is NOT recorded as having acted');
95
+
96
+ -- ===========================================================================
97
+ -- 3. service_role is ALWAYS operator-designated -- even with a resolvable subject
98
+ -- ===========================================================================
99
+ -- This is the laundering path a review caught on the abandoned larger change: an operator
100
+ -- acting while some agent id happens to resolve from its token was recorded as the subject's
101
+ -- own act. An operator placement is an operator placement.
102
+ select pg_temp.service();
103
+ select lives_ok($$
104
+ select agent_board._upsert_board_membership_state(
105
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000004','joined',
106
+ 'f1000000-0000-4000-8000-000000000004')
107
+ $$, 'operator setup: service_role places a subject');
108
+
109
+ select is((select prov from pg_temp.latest('f1000000-0000-4000-8000-000000000004')),
110
+ 'operator-designated', 'OPERATOR: service_role records operator-designated');
111
+ select is((select actor from pg_temp.latest('f1000000-0000-4000-8000-000000000004')),
112
+ null::uuid, 'OPERATOR: ...with a NULL actor');
113
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000004')),
114
+ 'operator_designation', 'OPERATOR: ...and the designation kind');
115
+
116
+ -- ...and with a subject RESOLVABLE from the service session, it is STILL operator-designated.
117
+ select set_config('request.jwt.claims',
118
+ '{"role":"service_role","sub":"f0000000-0000-0000-0000-000000000004"}', true);
119
+ select lives_ok($$
120
+ select agent_board._upsert_board_membership_state(
121
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000004','subscribed')
122
+ $$, 'operator setup: service_role acts with a RESOLVABLE subject in its token');
123
+ select is((select prov from pg_temp.latest('f1000000-0000-4000-8000-000000000004')),
124
+ 'operator-designated',
125
+ 'OPERATOR: a resolvable subject does NOT make an operator action target-authored');
126
+ select is((select actor from pg_temp.latest('f1000000-0000-4000-8000-000000000004')),
127
+ null::uuid, 'OPERATOR: ...actor stays NULL');
128
+
129
+ -- ===========================================================================
130
+ -- 4. An unattributable write FAILS CLOSED
131
+ -- ===========================================================================
132
+ -- Neither service_role nor a resolvable actor. An earlier draft of this logic labelled that
133
+ -- case operator-designated, which manufactures evidence of an operator placement that never
134
+ -- happened. It must raise instead.
135
+ select pg_temp.claims('00000000-0000-0000-0000-0000000000ff');
136
+ select throws_ok($$
137
+ select agent_board._upsert_board_membership_state(
138
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','left')
139
+ $$, '42501', 'membership change has no attributable actor',
140
+ 'FAIL CLOSED: an unattributable write raises rather than inventing a provenance');
141
+
142
+ -- STATEMENT-LEVEL ATOMICITY. The refused write above did not just skip the LEDGER (section 6
143
+ -- pins the empty ledger); the membership WRITE that preceded the failing emit rolled back with
144
+ -- it. _upsert writes the row and PERFORMs _emit in ONE function body, so an emit exception
145
+ -- unwinds the row too -- no partial `left` can commit. Agent 2 was `joined` before the refused
146
+ -- `left`; had the write leaked it would read `left` here.
147
+ -- This is NOT redundant with the ledger-empty check: an autonomous/independent membership write
148
+ -- would still raise, still leave the ledger empty, and still commit the `left` -- only reading
149
+ -- the membership row catches that leak.
150
+ select is(
151
+ (select membership_state from agent_board.board_memberships
152
+ where board_id = 'f2000000-0000-4000-8000-000000000001'
153
+ and agent_id = 'f1000000-0000-4000-8000-000000000002'),
154
+ 'joined',
155
+ 'ATOMICITY: the refused write rolled its membership row back too -- state is still joined, no partial left committed');
156
+
157
+ -- ===========================================================================
158
+ -- 5. Event-kind mapping across the state space
159
+ -- ===========================================================================
160
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000002');
161
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
162
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','left') $$,
163
+ 'kinds setup: the subject leaves');
164
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
165
+ 'self_withdraw', 'kinds: target-authored + left is a self-withdraw, not an eviction');
166
+
167
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000001');
168
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
169
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','removed') $$,
170
+ 'kinds setup: a third party removes');
171
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
172
+ 'eviction', 'kinds: board-authored + removed is an eviction, not a self-withdraw');
173
+
174
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000002');
175
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
176
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','requested') $$,
177
+ 'kinds setup: the subject requests');
178
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
179
+ 'consent_join_request', 'kinds: target-authored + requested is a join request');
180
+
181
+ -- Exactly-once ACROSS THE CALL GRAPH, driven through a FACADE rather than the sink. Counting
182
+ -- call sites per function cannot see double-emission: adding a second emit to a wrapper that
183
+ -- already calls the emitting sink leaves BOTH functions with one textual call while a single
184
+ -- transition writes two events. Placed here, after the derivation assertions, because it moves
185
+ -- agent 2's state and would otherwise change what a later assertion reads.
186
+ -- Counted RELATIVE to this call, via the seq high-water mark. An absolute count over the
187
+ -- subject's history is polluted by earlier sections -- it read 2 because a previous test had
188
+ -- already written a `left` for this agent, and that failure looks exactly like a real
189
+ -- double-emission defect.
190
+ create temporary table _seq_mark on commit drop as
191
+ select coalesce(max(seq), 0) as seq from agent_board.board_membership_events;
192
+
193
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000002');
194
+ select lives_ok($$
195
+ select agent_board.update_board_membership(
196
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','left')
197
+ $$, 'call-graph setup: a transition driven through the update_board_membership facade');
198
+
199
+ select is(
200
+ (select count(*)::int from agent_board.board_membership_events e
201
+ where e.seq > (select seq from _seq_mark)),
202
+ 1,
203
+ 'emission: a FACADE transition emits once across the whole call graph, not twice');
204
+
205
+ -- ===========================================================================
206
+ -- 5b. COMPLETENESS -- every writer emits, not just the shared sink
207
+ -- ===========================================================================
208
+ -- The first version of this migration instrumented ONLY _upsert_board_membership_state. Four
209
+ -- functions write board_memberships DIRECTLY and emitted nothing, so a membership created
210
+ -- through them carried a SILENT ledger and would read as non-consenting once gating lands.
211
+ -- This assertion pins the completeness claim itself, derived from the catalog rather than from
212
+ -- a hand-maintained list -- a new writer added later will fail it.
213
+ -- Covers INSERT **and** UPDATE writers, and fails CLOSED: a function that writes
214
+ -- board_memberships must either emit, or appear on the exemption list below with a stated
215
+ -- reason. Keying only on INSERT would miss a future path that flips membership_state via
216
+ -- UPDATE -- and catching FUTURE writers is the entire purpose of this assertion.
217
+ --
218
+ -- The single exemption is grant_board_role: it sets `role` only, with membership_state
219
+ -- appearing exclusively in its WHERE clauses, so it cannot change membership state and a role
220
+ -- change is not a consent event. If that ever stops being true, this assertion fails.
221
+ -- COMMENTS ARE STRIPPED and CALL SITES ARE COUNTED. Checking that the emitter name appears
222
+ -- anywhere in the body is fail-open twice over: the name in a comment or a dead branch would
223
+ -- satisfy it, and calling the emitter TWICE would too -- while this suite claims exactly one
224
+ -- event per transition.
225
+ -- Strips BOTH comment forms. The first version stripped only `--`, so a /* */ commented-out
226
+ -- call still satisfied the completeness assertion.
227
+ create function pg_temp.code(p_oid oid) returns text language sql stable as $$
228
+ select regexp_replace(
229
+ regexp_replace(pg_get_functiondef(p_oid), '/\*.*?\*/', '', 'gs'),
230
+ '--[^\n]*', '', 'g');
231
+ $$;
232
+
233
+ select is(
234
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
235
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
236
+ where n.nspname = 'agent_board' and p.prokind = 'f'
237
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
238
+ and pg_temp.code(p.oid) ~* '(insert into|update)[[:space:]]+agent_board\.board_memberships'
239
+ and pg_temp.code(p.oid) !~ 'perform agent_board\._emit_membership_event'
240
+ and p.proname <> 'grant_board_role'),
241
+ '',
242
+ 'COMPLETENESS: every function writing board_memberships emits, or is a stated exemption');
243
+
244
+ -- ...and none of them emits MORE than once, which would break one-event-per-transition.
245
+ select is(
246
+ (select coalesce(string_agg(p.proname || '=' ||
247
+ (length(pg_temp.code(p.oid)) - length(replace(pg_temp.code(p.oid),
248
+ 'perform agent_board._emit_membership_event', '')))
249
+ / length('perform agent_board._emit_membership_event'), ', ' order by p.proname), '')
250
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
251
+ where n.nspname = 'agent_board' and p.prokind = 'f'
252
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
253
+ and pg_temp.code(p.oid) ~ 'perform agent_board\._emit_membership_event'
254
+ and (length(pg_temp.code(p.oid)) - length(replace(pg_temp.code(p.oid),
255
+ 'perform agent_board._emit_membership_event', '')))
256
+ / length('perform agent_board._emit_membership_event') <> 1),
257
+ '',
258
+ 'COMPLETENESS: no writer emits more than once (exactly one event per transition)');
259
+
260
+ -- ...and the one exemption is genuinely state-blind: membership_state never appears in a SET.
261
+ select ok(
262
+ (select pg_get_functiondef(p.oid) !~* 'set[^;]*membership_state[[:space:]]*='
263
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
264
+ where n.nspname = 'agent_board' and p.proname = 'grant_board_role'),
265
+ 'COMPLETENESS: the exemption holds -- grant_board_role never SETs membership_state');
266
+
267
+ -- ...and the emitter itself is the single implementation, not five copies.
268
+ select is(
269
+ (select count(*)::int
270
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
271
+ where n.nspname = 'agent_board' and p.prokind = 'f'
272
+ and p.proname <> '_emit_membership_event'
273
+ and pg_get_functiondef(p.oid) ~* 'insert into[[:space:]]+agent_board\.board_membership_events'),
274
+ 0,
275
+ 'COMPLETENESS: only _emit_membership_event writes the ledger -- the derivation cannot drift');
276
+
277
+ -- ===========================================================================
278
+ -- 5c. The RAW WRITERS, exercised behaviourally with their FROZEN event kinds
279
+ -- ===========================================================================
280
+ -- Instrumenting them is not enough: the frozen mapping names SPECIFIC kinds that a generic
281
+ -- (provenance, state) derivation cannot produce -- owner_seed, consent_link_redeem,
282
+ -- operator_repair. A structural check that "the emitter is called" would miss a wrong kind.
283
+
284
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000003');
285
+ select lives_ok($$
286
+ select agent_board.create_board('f1000000-0000-4000-8000-000000000003',
287
+ 'emit-owned', 'Emit Owned', null, '{}'::jsonb, 'private')
288
+ $$, 'raw writer setup: an agent creates a board');
289
+
290
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000003')),
291
+ 'owner_seed',
292
+ 'RAW WRITER: create_board emits owner_seed, not the generic consent_self_join');
293
+ select is((select prov from pg_temp.latest('f1000000-0000-4000-8000-000000000003')),
294
+ 'target-authored',
295
+ 'RAW WRITER: ...target-authored, because the creator seeds their OWN seat');
296
+
297
+ -- A board whose owner has NO membership row -- exactly what bootstrap exists to repair.
298
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
299
+ values ('f2000000-0000-4000-8000-000000000009','emit-orphan','Emit Orphan',
300
+ 'f1000000-0000-4000-8000-000000000004','private','active');
301
+
302
+ select pg_temp.service();
303
+ select lives_ok($$ select agent_board.bootstrap_board_owner_memberships() $$,
304
+ 'raw writer setup: operator bootstrap runs');
305
+
306
+ -- Asserting the EVENT, not merely that the call lives. A structural check cannot prove the
307
+ -- emit is REACHABLE -- moving it into `if false then` would satisfy one. Only this can.
308
+ select is(
309
+ (select event_kind from agent_board.board_membership_events
310
+ where board_id = 'f2000000-0000-4000-8000-000000000009'
311
+ and agent_id = 'f1000000-0000-4000-8000-000000000004'
312
+ order by seq desc limit 1),
313
+ 'operator_repair',
314
+ 'RAW WRITER: bootstrap emits operator_repair, not the generic operator_designation');
315
+
316
+ select is(
317
+ (select provenance from agent_board.board_membership_events
318
+ where board_id = 'f2000000-0000-4000-8000-000000000009'
319
+ and agent_id = 'f1000000-0000-4000-8000-000000000004'
320
+ order by seq desc limit 1),
321
+ 'operator-designated',
322
+ 'RAW WRITER: ...operator-designated, never the owner''s own act');
323
+
324
+ select is(
325
+ (select result_state from agent_board.board_membership_events
326
+ where board_id = 'f2000000-0000-4000-8000-000000000009'
327
+ and agent_id = 'f1000000-0000-4000-8000-000000000004'
328
+ order by seq desc limit 1),
329
+ (select membership_state from agent_board.board_memberships
330
+ where board_id = 'f2000000-0000-4000-8000-000000000009'
331
+ and agent_id = 'f1000000-0000-4000-8000-000000000004'),
332
+ 'RAW WRITER: bootstrap records the ACTUAL row state, not a literal');
333
+
334
+ -- REDEEM MUST RECORD THE ACTUAL OUTCOME, NOT THE TOKEN'S PRESET.
335
+ -- The redeem upsert's ON CONFLICT lifts only the ROLE and leaves an existing membership_state
336
+ -- untouched. So an already-SUBSCRIBED member redeeming a JOINED-preset link stays subscribed --
337
+ -- and emitting the preset would write a ledger row that contradicts the table it describes.
338
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
339
+ values ('f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000005',
340
+ 'member','subscribed','f1000000-0000-4000-8000-000000000001');
341
+
342
+ insert into agent_board.board_invite_tokens(code, board_id, preset_role, preset_membership_state,
343
+ max_uses, expires_at, created_by_agent_id)
344
+ values ('emit-token-1','f2000000-0000-4000-8000-000000000001','member','joined',
345
+ 5, now() + interval '7 days','f1000000-0000-4000-8000-000000000001');
346
+
347
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000005');
348
+ select lives_ok($$
349
+ select agent_board._redeem_invite_for_agent('emit-token-1','f1000000-0000-4000-8000-000000000005')
350
+ $$, 'redeem setup: an already-SUBSCRIBED member redeems a JOINED-preset link');
351
+
352
+ select is(
353
+ (select membership_state from agent_board.board_memberships
354
+ where board_id = 'f2000000-0000-4000-8000-000000000001'
355
+ and agent_id = 'f1000000-0000-4000-8000-000000000005'),
356
+ 'subscribed',
357
+ 'redeem: the membership row STAYS subscribed -- ON CONFLICT lifts role only');
358
+
359
+ select is((select state from pg_temp.latest('f1000000-0000-4000-8000-000000000005')),
360
+ 'subscribed',
361
+ 'REDEEM: the event records the ACTUAL resulting state, not the token preset');
362
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000005')),
363
+ 'consent_link_redeem',
364
+ 'REDEEM: ...with the frozen consent_link_redeem kind');
365
+
366
+ -- ===========================================================================
367
+ -- 5d. Self-TOGGLE vs self-JOIN, and the two gaps a structural check cannot close
368
+ -- ===========================================================================
369
+ -- The frozen mapping distinguishes a toggle between two ACTIVE states from a fresh join.
370
+ -- Deriving from (provenance, state) alone cannot tell them apart -- both are target-authored
371
+ -- and both land active -- so the sink names the kind from the PRIOR state.
372
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000002');
373
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
374
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','joined') $$,
375
+ 'toggle setup: the subject joins');
376
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
377
+ 'consent_self_join', 'TOGGLE: a fresh join is consent_self_join');
378
+
379
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
380
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','subscribed') $$,
381
+ 'toggle setup: the subject switches to subscribed');
382
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
383
+ 'consent_self_toggle',
384
+ 'TOGGLE: joined -> subscribed is a TOGGLE, not a second join');
385
+
386
+ -- The two boundaries of the toggle rule, both verified live before being pinned:
387
+ -- * NO prior row -> v_prior_state is NULL, `null in (...)` is NULL, so the CASE falls through
388
+ -- to the generic derivation. A first-ever join straight to `subscribed` is a JOIN.
389
+ -- * a prior row that is NOT active (left/removed) -> re-entry is a fresh act of consent, not
390
+ -- a toggle. Someone returning after leaving is consenting again, and the ledger must say so.
391
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000006');
392
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
393
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000006','subscribed') $$,
394
+ 'toggle boundary setup: a first-ever membership, straight to subscribed');
395
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000006')),
396
+ 'consent_self_join',
397
+ 'TOGGLE boundary: with NO prior row it is a JOIN, not a toggle');
398
+
399
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
400
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000006','left') $$,
401
+ 'toggle boundary setup: they leave');
402
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
403
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000006','joined') $$,
404
+ 'toggle boundary setup: they come back');
405
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000006')),
406
+ 'consent_self_join',
407
+ 'TOGGLE boundary: re-entry from LEFT is a fresh JOIN -- returning is consenting again');
408
+
409
+ -- A third party moving an ACTIVE member between active states is NOT a self-toggle.
410
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000001');
411
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
412
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000002','joined') $$,
413
+ 'toggle setup: a third party moves them back');
414
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000002')),
415
+ 'resolution',
416
+ 'TOGGLE: a third party is board-authored resolution, never a self-toggle');
417
+
418
+ -- FEDSESSION, behaviourally. A textual call-site count cannot tell whether the emit sits
419
+ -- inside `if found` -- moving it out would emit on idempotent no-insert calls, and hiding it
420
+ -- behind `if false` would emit none. Both keep exactly one textual call.
421
+ -- A DEDICATED board so the counts below are isolated. Agent 4 already carries operator events
422
+ -- on board 1 from the operator section, and counting across both made the baseline wrong --
423
+ -- the assertion failed on my own fixture, not on the code.
424
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
425
+ values ('f2000000-0000-4000-8000-00000000000f','emit-fed','Emit Fed',
426
+ 'f1000000-0000-4000-8000-000000000001','private','active');
427
+
428
+ insert into agent_board.identity_providers(id, issuer, audience, default_board_id)
429
+ values ('emit-prov','https://p.test','aud','f2000000-0000-4000-8000-00000000000f');
430
+
431
+ select pg_temp.service();
432
+ select lives_ok($$
433
+ select agent_board._fedsession_autojoin_default_board(
434
+ (select p from agent_board.identity_providers p where p.id = 'emit-prov'),
435
+ 'f1000000-0000-4000-8000-000000000004')
436
+ $$, 'fedsession setup: first auto-join');
437
+
438
+ select is(
439
+ (select count(*)::int from agent_board.board_membership_events
440
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
441
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'),
442
+ 1,
443
+ 'FEDSESSION: the first auto-join emits exactly one event');
444
+
445
+ -- ...and that one event records the ACTUAL inserted state and its frozen kind, not merely SOME
446
+ -- event. Counting alone is mutation-blind: changing the emitted `joined` literal, or the
447
+ -- explicit `operator_designation` kind the writer passes, keeps the count at one. The fedsession
448
+ -- insert has NO on-conflict-do-update path -- it inserts exactly this state or nothing -- so the
449
+ -- emitted literal must equal the row, and a state literal here (unlike the upserting writers) is
450
+ -- correct precisely because there is no divergent existing state to preserve.
451
+ select is(
452
+ (select result_state from agent_board.board_membership_events
453
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
454
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'
455
+ order by seq desc limit 1),
456
+ 'joined',
457
+ 'FEDSESSION: the auto-join event records the frozen joined literal, not merely some state');
458
+
459
+ select is(
460
+ (select result_state from agent_board.board_membership_events
461
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
462
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'
463
+ order by seq desc limit 1),
464
+ (select membership_state from agent_board.board_memberships
465
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
466
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'),
467
+ 'FEDSESSION: literal == actual -- the emitted state matches the row the no-conflict insert placed');
468
+
469
+ select is(
470
+ (select event_kind from agent_board.board_membership_events
471
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
472
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'
473
+ order by seq desc limit 1),
474
+ 'operator_designation',
475
+ 'FEDSESSION: ...with the frozen operator_designation kind the writer passes explicitly');
476
+
477
+ -- ...and a SECOND call is a no-op (the NOT EXISTS suppresses the insert) so it must emit NOTHING.
478
+ select lives_ok($$
479
+ select agent_board._fedsession_autojoin_default_board(
480
+ (select p from agent_board.identity_providers p where p.id = 'emit-prov'),
481
+ 'f1000000-0000-4000-8000-000000000004')
482
+ $$, 'fedsession setup: idempotent second call');
483
+
484
+ select is(
485
+ (select count(*)::int from agent_board.board_membership_events
486
+ where agent_id = 'f1000000-0000-4000-8000-000000000004'
487
+ and board_id = 'f2000000-0000-4000-8000-00000000000f'),
488
+ 1,
489
+ 'FEDSESSION: a no-op second call emits NOTHING -- still exactly one event');
490
+
491
+ -- ===========================================================================
492
+ -- 5e. The FULL reachable (provenance, state) -> kind matrix
493
+ -- ===========================================================================
494
+ -- Sampling three combinations left the mapping mutation-blind: changing any UNASSERTED cell
495
+ -- passed the suite. Asserted here as a single set comparison, so an added or altered cell
496
+ -- fails and NAMES itself rather than silently diverging from the frozen record.
497
+ --
498
+ -- Driven through the emitter directly. Reaching every cell through the RPCs would need a
499
+ -- fixture per cell and still miss the operator+pending rows, which no route produces.
500
+ select pg_temp.service();
501
+
502
+ create function pg_temp.kind_of(p_state text) returns text language plpgsql as $$
503
+ declare v text;
504
+ begin
505
+ perform agent_board._emit_membership_event(
506
+ 'f2000000-0000-4000-8000-000000000001'::uuid,
507
+ 'f1000000-0000-4000-8000-000000000006'::uuid, p_state);
508
+ select event_kind into v from agent_board.board_membership_events
509
+ where agent_id = 'f1000000-0000-4000-8000-000000000006' order by seq desc limit 1;
510
+ return v;
511
+ end $$;
512
+
513
+ select is(
514
+ (select string_agg(s || '=' || pg_temp.kind_of(s), ' ' order by s)
515
+ from unnest(array['joined','subscribed','invited','requested','left','removed']) s),
516
+ 'invited=invite_offer joined=operator_designation left=operator_removal '
517
+ || 'removed=operator_removal requested=request_manufactured subscribed=operator_designation',
518
+ 'MATRIX: the operator row maps exactly -- pending states are NEUTRAL, not operator_repair');
519
+
520
+ -- The TARGET-AUTHORED row. Asserting only the operator row left every self-authored cell
521
+ -- mutation-blind -- and those are the cells that decide whether someone consented.
522
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000006');
523
+ select is(
524
+ (select string_agg(s || '=' || pg_temp.kind_of(s), ' ' order by s)
525
+ from unnest(array['joined','subscribed','invited','requested','left','removed']) s),
526
+ 'invited=consent_self_invite joined=consent_self_join left=self_withdraw '
527
+ || 'removed=self_withdraw requested=consent_join_request subscribed=consent_self_join',
528
+ 'MATRIX: the target-authored row maps exactly');
529
+
530
+ -- The BOARD-AUTHORED row. Reachable through update_board_membership's other-agent path, and
531
+ -- previously unasserted -- changing board-authored `requested` to invite_offer would have passed.
532
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000001');
533
+ select is(
534
+ (select string_agg(s || '=' || pg_temp.kind_of(s), ' ' order by s)
535
+ from unnest(array['joined','subscribed','invited','requested','left','removed']) s),
536
+ 'invited=invite_offer joined=resolution left=eviction '
537
+ || 'removed=eviction requested=request_manufactured subscribed=resolution',
538
+ 'MATRIX: the board-authored row maps exactly');
539
+ select pg_temp.service();
540
+
541
+ -- An unmapped combination must RAISE rather than land unlabelled in an append-only ledger.
542
+ select throws_ok($$
543
+ select agent_board._emit_membership_event(
544
+ 'f2000000-0000-4000-8000-000000000001'::uuid,
545
+ 'f1000000-0000-4000-8000-000000000006'::uuid, 'not-a-state')
546
+ $$, '22023', null,
547
+ 'MATRIX: an unmapped (provenance, state) raises rather than silently mislabelling');
548
+
549
+ -- A no-change active write is NOT a toggle. join_board and update_board_membership both
550
+ -- permit joined->joined, so this is reachable, and it was recorded as a toggle.
551
+ select pg_temp.claims('f0000000-0000-0000-0000-000000000006');
552
+ select lives_ok($$ select agent_board._upsert_board_membership_state(
553
+ 'f2000000-0000-4000-8000-000000000001','f1000000-0000-4000-8000-000000000006','joined') $$,
554
+ 'no-change setup: the subject writes joined while already joined');
555
+ select is((select kind from pg_temp.latest('f1000000-0000-4000-8000-000000000006')),
556
+ 'consent_self_join',
557
+ 'MATRIX: a no-change active write is a JOIN, not a toggle of nothing');
558
+
559
+ -- ===========================================================================
560
+ -- 6. SCOPE: this migration emits but does not GATE
561
+ -- ===========================================================================
562
+ -- SCOPE BOUNDARY, updated when gating landed. This assertion previously read "no route
563
+ -- consults the predicate yet" and FAILED the moment MBCONSENT-B(ii) shipped -- which is what a
564
+ -- scope guard is for. It now names the expected consumer SET, so a route that starts consulting
565
+ -- the predicate without being added here fails and names itself.
566
+ --
567
+ -- resolve_board_request is the sole predicate READER. join_board and update_board_membership
568
+ -- gate STRUCTURALLY (the actor must be the subject; no other-agent promotion), which needs no
569
+ -- ledger read -- a structural refusal is stronger than a predicate check, because it cannot be
570
+ -- satisfied by manufacturing evidence.
571
+ select is(
572
+ (select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
573
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
574
+ where n.nspname = 'agent_board' and p.prokind = 'f'
575
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
576
+ and has_function_privilege('authenticated', p.oid, 'EXECUTE')
577
+ and pg_get_functiondef(p.oid) ~ '_membership_consent_present'),
578
+ 'resolve_board_request, update_board_membership',
579
+ 'scope: exactly TWO authenticated routes read the predicate');
580
+
581
+ -- ...and the ledger it fills is the one MBCONSENT-A froze.
582
+ -- The refused write must leave NO trace. Asserted against the SUBJECT of the refused attempt
583
+ -- rather than a global row count: a hardcoded total breaks every time a test is added, which
584
+ -- makes it noise rather than a signal, and it was already wrong once here.
585
+ select is(
586
+ (select count(*)::int from agent_board.board_membership_events
587
+ where agent_id = 'f1000000-0000-4000-8000-000000000002'
588
+ and result_state = 'left'
589
+ and provenance = 'operator-designated'),
590
+ 0,
591
+ 'scope: the refused unattributable write left NO ledger row, invented or otherwise');
592
+
593
+ -- ...and every event that DID land carries a real kind and provenance.
594
+ select is(
595
+ (select count(*)::int from agent_board.board_membership_events
596
+ where event_kind is null or provenance is null),
597
+ 0,
598
+ 'scope: no event landed with a null kind or provenance');
599
+
600
+ select finish();
601
+ rollback;