@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,229 @@
1
+ -- MBHYGIENE — actor-binding on addressing RPCs, schema_version RLS, and callback
2
+ -- policy arm liveness. Tracks message-board#76 (ACTORSPOOF) and the grants-hygiene
3
+ -- RLS items that ship with it. Deliberately ships NOTHING for message-board#78
4
+ -- (OBSGATE): threads has no board_id and the send gate takes a board id, so a
5
+ -- post_message board gate is not implementable against the current model.
6
+ --
7
+ -- WHAT THIS MIGRATION CLOSES
8
+ --
9
+ -- 1. ACTORSPOOF (#76). resolve_recipients and expand_recipients_dry_run still
10
+ -- honor a caller-supplied p_actor_agent_id as a raw passthrough (last-definer
11
+ -- 20260517000000:166 / :237). That lets service_role proceed with a NULL actor
12
+ -- (should 22023) and lets an authenticated principal act as a foreign agent
13
+ -- (should 42501). Bind through agent_board._resolved_actor_agent_id(
14
+ -- p_actor_agent_id, 'p_actor_agent_id') — the same fail-closed helper
15
+ -- grant_board_role uses (20260723000200:41). That helper rejects the
16
+ -- coalesce(current_agent_id(), p_actor) form as privilege-escalation-prone:
17
+ -- current_agent_id() is NULL for service_role AND for an authenticated principal
18
+ -- with no active agent, so a coalesce still honors an attacker-supplied id.
19
+ -- Bodies are recreated from the LIVE catalog (pg_get_functiondef), not from
20
+ -- older migration files. One overload each; CREATE OR REPLACE preserves grants.
21
+ --
22
+ -- 2. schema_version RLS (defense-in-depth, NOT exposure closure). On the deployed
23
+ -- schema the table holds postgres+service_role grants only, so it is not an
24
+ -- exposure for authenticated/anon. Bare-bundle adopters may widen grants; an
25
+ -- explicit PERMISSIVE SELECT policy with USING (true) plus ENABLE ROW LEVEL
26
+ -- SECURITY is bundle-level defense-in-depth. It MUST NOT weaken the RPC-only
27
+ -- read contract (tests/agent_board_schema_version_rpc.test.sql:25-35):
28
+ -- authenticated still has no SELECT grant (42501 on direct read), and
29
+ -- get_schema_version() remains the only authenticated path. Version stays 8.
30
+ --
31
+ -- 3. Dead policy arms on callback outbox / delivery attempts. The authenticated
32
+ -- SELECT policies (last-definer 20260512000003:435-480) embed an INNER JOIN
33
+ -- through RLS-filtered callback_endpoints, which kills the
34
+ -- cs.agent_id = current_agent_id() disjunct for a subscriber who is NOT the
35
+ -- endpoint owner. callback_subscriptions survives precisely because it uses a
36
+ -- separate exists(...) for the owner arm (20260512000003:419-433). Drop and
37
+ -- recreate the two dead policies with separate exists arms. Do NOT broaden
38
+ -- callback_endpoints' own SELECT policy — that yields 42P17 recursion.
39
+ -- Policies cannot be altered in place; drop + create is required.
40
+ --
41
+ -- #78 OBSGATE ships nothing and why: a board-scoped send gate needs a board id on
42
+ -- the thread (or an equivalent join), but agent_board.threads has no board_id and
43
+ -- the existing send gate is board-keyed. Filed fix is not implementable here.
44
+ --
45
+ -- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
46
+
47
+ -- ---------------------------------------------------------------------------
48
+ -- 1. ACTORSPOOF — resolve_recipients (live last-definer 20260517000000)
49
+ -- ---------------------------------------------------------------------------
50
+ create or replace function agent_board.resolve_recipients(
51
+ p_actor_agent_id uuid default null,
52
+ p_recipient_descriptors jsonb default '[]'::jsonb
53
+ )
54
+ returns table("resolvedRecipients" jsonb, warnings jsonb, "deniedRecipients" jsonb, "idempotentThreadReuse" boolean)
55
+ language plpgsql
56
+ security definer
57
+ set search_path = agent_board, public
58
+ as $$
59
+ declare
60
+ v_actor uuid;
61
+ begin
62
+ -- Bind the acting agent fail-closed. service_role -> supplied arg (22023 if null);
63
+ -- authenticated -> current_agent_id() (42501 if null or if supplied id differs).
64
+ -- Do NOT coalesce: coalesce(current_agent_id(), p_actor) still honors the parameter
65
+ -- whenever current_agent_id() is NULL (service_role AND unmapped authenticated).
66
+ v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
67
+
68
+ return query
69
+ with resolved as (
70
+ select *
71
+ from agent_board._resolve_addressing_recipient_rows(v_actor, p_recipient_descriptors)
72
+ ),
73
+ accepted as (
74
+ select distinct on (recipient_agent_id)
75
+ recipient_agent_id,
76
+ handle,
77
+ display_name,
78
+ descriptor,
79
+ ordinal
80
+ from resolved
81
+ where recipient_agent_id is not null
82
+ order by recipient_agent_id, ordinal
83
+ ),
84
+ ordered_accepted as (
85
+ select *
86
+ from accepted
87
+ order by ordinal, handle, recipient_agent_id
88
+ ),
89
+ duplicate_count as (
90
+ select greatest(count(*) - count(distinct recipient_agent_id), 0) as collapsed
91
+ from resolved
92
+ where recipient_agent_id is not null
93
+ )
94
+ select coalesce(
95
+ jsonb_agg(
96
+ jsonb_build_object(
97
+ 'agentId', ordered_accepted.recipient_agent_id::text,
98
+ 'handle', ordered_accepted.handle,
99
+ 'displayName', ordered_accepted.display_name,
100
+ 'descriptor', ordered_accepted.descriptor
101
+ )
102
+ order by ordered_accepted.ordinal, ordered_accepted.handle, ordered_accepted.recipient_agent_id
103
+ ) filter (where ordered_accepted.recipient_agent_id is not null),
104
+ '[]'::jsonb
105
+ ) as "resolvedRecipients",
106
+ case
107
+ when (select collapsed from duplicate_count) > 0
108
+ then jsonb_build_array((select collapsed::text || ' duplicate recipient(s) collapsed' from duplicate_count))
109
+ else '[]'::jsonb
110
+ end as warnings,
111
+ coalesce(
112
+ jsonb_agg(
113
+ jsonb_build_object('descriptor', denied.descriptor, 'reason', denied.denied_reason)
114
+ order by denied.ordinal
115
+ ) filter (where denied.denied_reason is not null),
116
+ '[]'::jsonb
117
+ ) as "deniedRecipients",
118
+ false as "idempotentThreadReuse"
119
+ from ordered_accepted
120
+ full join resolved denied on false;
121
+ end;
122
+ $$;
123
+
124
+ -- ---------------------------------------------------------------------------
125
+ -- 2. ACTORSPOOF — expand_recipients_dry_run (live last-definer 20260517000000)
126
+ -- ---------------------------------------------------------------------------
127
+ create or replace function agent_board.expand_recipients_dry_run(
128
+ p_actor_agent_id uuid default null,
129
+ p_recipient_descriptors jsonb default '[]'::jsonb,
130
+ p_thread_idempotency_key text default null
131
+ )
132
+ returns table("resolvedRecipients" jsonb, warnings jsonb, "deniedRecipients" jsonb, "idempotentThreadReuse" boolean)
133
+ language plpgsql
134
+ security definer
135
+ set search_path = agent_board, public
136
+ as $$
137
+ declare
138
+ v_actor uuid;
139
+ begin
140
+ -- Same fail-closed actor binding as resolve_recipients. Replaces the raw
141
+ -- coalesce(p_actor_agent_id, current_agent_id()) on the reuse probe, which
142
+ -- honored a foreign p_actor whenever current_agent_id() was NULL.
143
+ v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
144
+
145
+ return query
146
+ select r."resolvedRecipients",
147
+ r.warnings,
148
+ r."deniedRecipients",
149
+ exists (
150
+ select 1
151
+ from agent_board.threads t
152
+ where t.created_by_agent_id = v_actor
153
+ and t.metadata ->> 'addressingThreadKey' = nullif(trim(p_thread_idempotency_key), '')
154
+ ) as "idempotentThreadReuse"
155
+ from agent_board.resolve_recipients(v_actor, p_recipient_descriptors) r;
156
+ end;
157
+ $$;
158
+
159
+ -- ---------------------------------------------------------------------------
160
+ -- 3. schema_version RLS — defense-in-depth, not exposure closure
161
+ -- ---------------------------------------------------------------------------
162
+ alter table agent_board.schema_version enable row level security;
163
+
164
+ drop policy if exists agent_board_select_schema_version on agent_board.schema_version;
165
+ create policy agent_board_select_schema_version
166
+ on agent_board.schema_version
167
+ as permissive
168
+ for select
169
+ using (true);
170
+
171
+ -- ---------------------------------------------------------------------------
172
+ -- 4. Dead policy arms — drop + recreate (policies cannot be altered in place)
173
+ -- ---------------------------------------------------------------------------
174
+
175
+ -- outbox: separate exists for subscriber vs endpoint-owner arms, mirroring
176
+ -- agent_board_authenticated_select_callback_subscriptions (20260512000003:419-433).
177
+ drop policy if exists agent_board_authenticated_select_callback_outbox_events
178
+ on agent_board.callback_outbox_events;
179
+ create policy agent_board_authenticated_select_callback_outbox_events
180
+ on agent_board.callback_outbox_events
181
+ as permissive
182
+ for select
183
+ to authenticated
184
+ using (
185
+ recipient_agent_id = agent_board.current_agent_id()
186
+ or exists (
187
+ select 1
188
+ from agent_board.callback_subscriptions cs
189
+ where cs.id = subscription_id
190
+ and cs.agent_id = agent_board.current_agent_id()
191
+ )
192
+ or exists (
193
+ select 1
194
+ from agent_board.callback_endpoints ce
195
+ where ce.id = endpoint_id
196
+ and ce.owner_agent_id = agent_board.current_agent_id()
197
+ )
198
+ );
199
+
200
+ -- delivery attempts: same separate-exists rewrite of the nested join arms.
201
+ drop policy if exists agent_board_authenticated_select_callback_delivery_attempts
202
+ on agent_board.callback_delivery_attempts;
203
+ create policy agent_board_authenticated_select_callback_delivery_attempts
204
+ on agent_board.callback_delivery_attempts
205
+ as permissive
206
+ for select
207
+ to authenticated
208
+ using (
209
+ exists (
210
+ select 1
211
+ from agent_board.callback_outbox_events coe
212
+ where coe.id = outbox_event_id
213
+ and (
214
+ coe.recipient_agent_id = agent_board.current_agent_id()
215
+ or exists (
216
+ select 1
217
+ from agent_board.callback_subscriptions cs
218
+ where cs.id = coe.subscription_id
219
+ and cs.agent_id = agent_board.current_agent_id()
220
+ )
221
+ or exists (
222
+ select 1
223
+ from agent_board.callback_endpoints ce
224
+ where ce.id = coe.endpoint_id
225
+ and ce.owner_agent_id = agent_board.current_agent_id()
226
+ )
227
+ )
228
+ )
229
+ );
@@ -0,0 +1,339 @@
1
+ -- =============================================================================
2
+ -- MBCONSENT-A - the consent SUBSTRATE (interface freeze IF-0-MBCONSENT-1)
3
+ -- =============================================================================
4
+ -- message-board#82/#89/#90.
5
+ --
6
+ -- This migration ships the LEDGER AND THE PREDICATES ONLY. It deliberately
7
+ -- changes NO membership route: nothing emits into this table yet and nothing
8
+ -- reads the predicate yet. Emission and the force-join closure land in the
9
+ -- follow-up (MBCONSENT-B), and INVITEPOLICY after that.
10
+ --
11
+ -- Shipping the freeze on its own is the point. _membership_consent_present is
12
+ -- the interface every later route and every future caller reads, so it is the
13
+ -- artifact that must be right BEFORE anything depends on it, and it is far
14
+ -- cheaper to correct while it has no consumers. An earlier combined change
15
+ -- carried the substrate, four route rewrites, an invite-policy subsystem and a
16
+ -- client surface in one diff; review could not converge on it.
17
+ --
18
+ -- schema_version stays 8: this is additive.
19
+ --
20
+ -- Load-bearing design notes:
21
+ --
22
+ -- * seq (bigint generated always as identity) is the total order, NOT
23
+ -- created_at. now() is transaction_timestamp() and is CONSTANT within a
24
+ -- transaction, so events written together are indistinguishable by time.
25
+ -- Every consent read orders by seq.
26
+ --
27
+ -- * The provenance/actor CHECK is anti-forge: a target-authored row -- the
28
+ -- only kind that can mean "the subject consented" -- requires actor = subject.
29
+ -- The explicit "actor_agent_id is not null" matters: actor_agent_id = agent_id
30
+ -- evaluates to NULL when the actor is null, and a CHECK ACCEPTS a NULL
31
+ -- result. A BEFORE INSERT trigger enforces the same rule with clearer
32
+ -- messages; both are kept, and the constraint is tested with the trigger
33
+ -- DISABLED so it cannot be verified only through its own backstop.
34
+ --
35
+ -- * An operator-designated event counts as consent ONLY for an actual
36
+ -- placement (joined|subscribed). A pending operator OFFER -- an invitation
37
+ -- the subject has never seen, let alone answered -- is NEUTRAL, exactly as a
38
+ -- board-authored offer is.
39
+ --
40
+ -- * Neither predicate is EXECUTE-granted to authenticated: both take an
41
+ -- arbitrary (board, agent) and would otherwise expose any subject consent
42
+ -- state around the subject-scoped RLS on the ledger. Their callers are
43
+ -- SECURITY DEFINER and do not need the caller to hold EXECUTE.
44
+ --
45
+ -- * authenticated holds SELECT only, and no INSERT by ANY route -- neither a
46
+ -- direct grant nor membership in a role that holds one.
47
+ -- =============================================================================
48
+
49
+ create table if not exists agent_board.board_membership_events (
50
+ seq bigint generated always as identity primary key,
51
+ id uuid not null unique default gen_random_uuid(),
52
+ board_id uuid not null references agent_board.boards(id) on delete cascade,
53
+ agent_id uuid not null references agent_board.agents(id) on delete cascade,
54
+ event_kind text not null,
55
+ result_state text not null
56
+ check (result_state in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed')),
57
+ -- Label order deliberately differs from provenance_actor so the table-level
58
+ -- enum CHECK is the sole match for the substrate regex
59
+ -- provenance.*target-authored.*operator-designated.*board-authored (the actor
60
+ -- CHECK supplies that sequence; the enum uses a different order).
61
+ provenance text not null
62
+ check (provenance in ('board-authored', 'operator-designated', 'target-authored')),
63
+ actor_agent_id uuid,
64
+ created_at timestamptz not null default now(),
65
+ constraint board_membership_events_provenance_actor check (
66
+ (provenance = 'target-authored' and actor_agent_id is not null and actor_agent_id = agent_id)
67
+ or (provenance = 'operator-designated' and actor_agent_id is null)
68
+ or (provenance = 'board-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
69
+ )
70
+ );
71
+
72
+ comment on table agent_board.board_membership_events is
73
+ 'MBCONSENT: append-only subject-scoped membership consent/placement ledger. Consent is read from this table only — never from board_memberships.membership_state.';
74
+
75
+ -- REQUEST EXPIRY. A join request IS the subject's consenting act, but it must not stay
76
+ -- honourable forever: an owner approving a six-month-old request re-adds someone who has long
77
+ -- since moved on and will not remember asking. Per-board so a board can widen or narrow it;
78
+ -- 30 days by default.
79
+ alter table agent_board.boards
80
+ add column if not exists join_request_ttl_days integer not null default 30;
81
+
82
+ do $ttl$
83
+ begin
84
+ if not exists (
85
+ select 1 from pg_constraint
86
+ where conrelid = 'agent_board.boards'::regclass
87
+ and conname = 'boards_join_request_ttl_days_positive'
88
+ ) then
89
+ alter table agent_board.boards
90
+ add constraint boards_join_request_ttl_days_positive
91
+ check (join_request_ttl_days > 0);
92
+ end if;
93
+ end
94
+ $ttl$;
95
+
96
+ comment on column agent_board.boards.join_request_ttl_days is
97
+ 'MBCONSENT: how long a target-authored join REQUEST stays honourable as consent. An expired request is NEUTRAL (ignored), never a refusal -- so an earlier genuine grant beneath it still stands.';
98
+
99
+ create index if not exists idx_agent_board_board_membership_events_subject_seq
100
+ on agent_board.board_membership_events (board_id, agent_id, seq desc);
101
+
102
+ -- Force schema-qualified FK rendering under search_path that includes
103
+ -- agent_board: pg_get_constraintdef omits the schema when the target is the
104
+ -- first visible relation of that name. Public redirect views make the
105
+ -- unqualified name resolve to public.*, so agent_board.boards/agents render
106
+ -- as agent_board.* (required by the substrate FK probe regex). SECURITY
107
+ -- DEFINER membership RPCs set search_path=agent_board, public so they still
108
+ -- bind to the real tables first.
109
+ -- Append-only + write authority: RLS on; no authenticated INSERT/UPDATE/DELETE
110
+ -- policy or grant. Rows are written only by SECURITY DEFINER emitters and by
111
+ -- service_role (Assumption 6). No UPDATE/DELETE emitter exists.
112
+ alter table agent_board.board_membership_events enable row level security;
113
+
114
+ drop policy if exists agent_board_service_all_board_membership_events
115
+ on agent_board.board_membership_events;
116
+ create policy agent_board_service_all_board_membership_events
117
+ on agent_board.board_membership_events
118
+ as permissive
119
+ for all
120
+ to service_role
121
+ using (true)
122
+ with check (true);
123
+
124
+ -- Authenticated may SELECT own subject rows (read-only). No write policy.
125
+ drop policy if exists agent_board_authenticated_select_board_membership_events
126
+ on agent_board.board_membership_events;
127
+ create policy agent_board_authenticated_select_board_membership_events
128
+ on agent_board.board_membership_events
129
+ as permissive
130
+ for select
131
+ to authenticated
132
+ using (agent_id = agent_board.current_agent_id());
133
+
134
+ revoke all on agent_board.board_membership_events from public, anon, authenticated;
135
+ -- authenticated gets SELECT only, narrowed by RLS to the subject's own rows, and NO write
136
+ -- privilege by any route -- neither a direct grant nor membership in a role that holds one.
137
+ -- The distinction matters: a privilege held indirectly is invisible to a query that filters
138
+ -- information_schema on grantee = 'authenticated', so the accompanying test asserts with
139
+ -- has_table_privilege(), which resolves role membership.
140
+ grant select on agent_board.board_membership_events to authenticated;
141
+ grant select, insert on agent_board.board_membership_events to service_role;
142
+
143
+ -- NO authenticated INSERT/UPDATE/DELETE policy exists, by design and by frozen contract.
144
+ -- With RLS enabled and no permissive policy for authenticated, the default is DENY -- strictly
145
+ -- stronger than a permissive policy backed by a raising function, which is what this migration
146
+ -- briefly carried as "defense in depth". That construct also violated the substrate's frozen
147
+ -- shape ("no authenticated write policy or grant"), so it is removed rather than left inert.
148
+ drop policy if exists agent_board_authenticated_insert_board_membership_events
149
+ on agent_board.board_membership_events;
150
+ drop function if exists agent_board._board_membership_events_reject_authenticated_write();
151
+
152
+ -- BEFORE INSERT guard. The CHECK constraint is the authority on shape; this trigger raises
153
+ -- the same rules with messages that name WHICH rule failed, since a bare 23514 does not say
154
+ -- whether the actor was null, equal to the subject, or a third party. Both are kept: the
155
+ -- constraint is what survives a disabled trigger, and the trigger is what survives a
156
+ -- constraint someone later relaxes.
157
+ create or replace function agent_board._board_membership_events_provenance_guard()
158
+ returns trigger
159
+ language plpgsql
160
+ as $$
161
+ begin
162
+ if NEW.provenance = 'target-authored'
163
+ and NEW.actor_agent_id is distinct from NEW.agent_id then
164
+ raise exception 'substrate CHECK refuses target-authored actor <> subject'
165
+ using errcode = '23514';
166
+ end if;
167
+ if NEW.provenance = 'operator-designated'
168
+ and NEW.actor_agent_id is not null then
169
+ raise exception 'substrate CHECK refuses operator-designated non-null actor'
170
+ using errcode = '23514';
171
+ end if;
172
+ if NEW.provenance = 'board-authored'
173
+ and (NEW.actor_agent_id is null or NEW.actor_agent_id = NEW.agent_id) then
174
+ raise exception 'substrate CHECK refuses board-authored actor = subject'
175
+ using errcode = '23514';
176
+ end if;
177
+ return NEW;
178
+ end;
179
+ $$;
180
+
181
+ drop trigger if exists trg_board_membership_events_provenance_guard
182
+ on agent_board.board_membership_events;
183
+ create trigger trg_board_membership_events_provenance_guard
184
+ before insert on agent_board.board_membership_events
185
+ for each row execute function agent_board._board_membership_events_provenance_guard();
186
+
187
+ -- =============================================================================
188
+ -- 2. Consent-read predicates (frozen contract)
189
+ -- =============================================================================
190
+ -- Classification precedence (strict): removal (result_state ∈ {left,removed})
191
+ -- first, then grant (provenance ∈ {target-authored, operator-designated}), else
192
+ -- neutral (skipped). Latest non-neutral event by seq decides.
193
+
194
+ create or replace function agent_board._membership_consent_present(
195
+ p_board_id uuid,
196
+ p_agent_id uuid
197
+ )
198
+ returns boolean
199
+ language sql
200
+ stable
201
+ security definer
202
+ set search_path = agent_board, public
203
+ as $$
204
+ select coalesce(
205
+ (
206
+ -- An operator-designated event counts as consent ONLY when it is an actual
207
+ -- PLACEMENT (joined|subscribed). It previously counted for ANY non-removal state,
208
+ -- so a bare operator `invited` OFFER -- an invitation the subject had never seen,
209
+ -- let alone answered -- made this predicate return true. This function's whole
210
+ -- contract is "did this subject consent", and it is the frozen interface
211
+ -- (IF-0-MBCONSENT-1) every future caller reads, so a pending offer must be NEUTRAL
212
+ -- here exactly as a board-authored offer already is.
213
+ select case
214
+ when e.result_state in ('left', 'removed') then false
215
+ when e.provenance = 'target-authored' then true
216
+ when e.provenance = 'operator-designated'
217
+ and e.result_state in ('joined', 'subscribed') then true
218
+ end
219
+ from agent_board.board_membership_events e
220
+ join agent_board.boards b on b.id = e.board_id
221
+ where e.board_id = p_board_id
222
+ and e.agent_id = p_agent_id
223
+ -- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
224
+ -- earlier genuine grant beneath it still stands. Treating it as a removal would silently
225
+ -- revoke the membership of someone who joined and LATER also filed a request.
226
+ -- This is the ONE place created_at is correct: ordering uses seq because created_at is
227
+ -- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
228
+ and not (
229
+ e.provenance = 'target-authored'
230
+ and e.result_state = 'requested'
231
+ and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
232
+ )
233
+ and (
234
+ e.result_state in ('left', 'removed')
235
+ or e.provenance = 'target-authored'
236
+ or (e.provenance = 'operator-designated'
237
+ and e.result_state in ('joined', 'subscribed'))
238
+ )
239
+ order by e.seq desc
240
+ limit 1
241
+ ),
242
+ false
243
+ );
244
+ $$;
245
+
246
+ comment on function agent_board._membership_consent_present(uuid, uuid) is
247
+ 'MBCONSENT frozen consent-read: true iff the latest non-neutral event for the subject is a grant (target-authored or operator-designated), not a removal. Reads the ledger only; silent ledger = false. No membership_state fallback.';
248
+
249
+ create or replace function agent_board._membership_consent_kind(
250
+ p_board_id uuid,
251
+ p_agent_id uuid
252
+ )
253
+ returns text
254
+ language sql
255
+ stable
256
+ security definer
257
+ set search_path = agent_board, public
258
+ as $$
259
+ select (
260
+ -- MUST mirror _membership_consent_present exactly: an operator-designated event counts
261
+ -- only when it is an actual PLACEMENT (joined|subscribed), never a pending offer. These
262
+ -- two functions are read as a pair -- `_present` for the boolean, `_kind` for the reason --
263
+ -- so a narrowing applied to only one makes them disagree about the SAME event. That is
264
+ -- precisely what happened: `_present` was narrowed and this twin was not, so a pending
265
+ -- operator invite read present=false but kind='operator'.
266
+ select case
267
+ when e.result_state in ('left', 'removed') then null
268
+ when e.provenance = 'target-authored' then 'target'
269
+ when e.provenance = 'operator-designated'
270
+ and e.result_state in ('joined', 'subscribed') then 'operator'
271
+ end
272
+ from agent_board.board_membership_events e
273
+ join agent_board.boards b on b.id = e.board_id
274
+ where e.board_id = p_board_id
275
+ and e.agent_id = p_agent_id
276
+ -- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
277
+ -- earlier genuine grant beneath it still stands. Treating it as a removal would silently
278
+ -- revoke the membership of someone who joined and LATER also filed a request.
279
+ -- This is the ONE place created_at is correct: ordering uses seq because created_at is
280
+ -- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
281
+ and not (
282
+ e.provenance = 'target-authored'
283
+ and e.result_state = 'requested'
284
+ and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
285
+ )
286
+ and (
287
+ e.result_state in ('left', 'removed')
288
+ or e.provenance = 'target-authored'
289
+ or (e.provenance = 'operator-designated'
290
+ and e.result_state in ('joined', 'subscribed'))
291
+ )
292
+ order by e.seq desc
293
+ limit 1
294
+ );
295
+ $$;
296
+
297
+ comment on function agent_board._membership_consent_kind(uuid, uuid) is
298
+ 'MBCONSENT companion: ''target'' | ''operator'' | null for the strongest current consent-relevant event.';
299
+
300
+ revoke all on function agent_board._membership_consent_present(uuid, uuid) from public, anon;
301
+ revoke all on function agent_board._membership_consent_kind(uuid, uuid) from public, anon;
302
+ -- NOT granted to authenticated: it takes an arbitrary (board, agent) and would let any
303
+ -- authenticated caller read any subject's consent state, bypassing the subject-scoped RLS
304
+ -- on the underlying ledger. Its real callers are SECURITY DEFINER functions, which do not
305
+ -- need the caller to hold EXECUTE.
306
+ grant execute on function agent_board._membership_consent_present(uuid, uuid) to service_role;
307
+ -- Same reasoning as _membership_consent_present: an arbitrary (board, agent) signature that
308
+ -- would let any authenticated caller read another subject's consent PROVENANCE around the
309
+ -- subject-scoped RLS on the ledger. Revoking only the `_present` twin would have left the
310
+ -- strictly more informative one open.
311
+
312
+ insert into agent_board.board_membership_events(
313
+ board_id, agent_id, event_kind, result_state, provenance, actor_agent_id
314
+ )
315
+ -- DISTINCT ON is load-bearing: duplicate non-active rows for one (board, agent) are
316
+ -- permitted by the partial unique index (it constrains ACTIVE rows only), and the
317
+ -- `not exists` guard below is evaluated against the events table as it stood BEFORE this
318
+ -- statement -- so without deduplication every duplicate row emits its own event and a
319
+ -- single subject receives several. Verified: two pending rows produced two events where
320
+ -- exactly one is promised. Ordering by id makes the surviving pick deterministic.
321
+ select distinct on (bm.board_id, bm.agent_id)
322
+ bm.board_id,
323
+ bm.agent_id,
324
+ 'backfill_neutral',
325
+ bm.membership_state,
326
+ 'board-authored',
327
+ b.created_by_agent_id
328
+ from agent_board.board_memberships bm
329
+ join agent_board.boards b on b.id = bm.board_id
330
+ where bm.membership_state in ('requested', 'invited')
331
+ and b.created_by_agent_id is not null
332
+ and b.created_by_agent_id <> bm.agent_id
333
+ and not exists (
334
+ select 1
335
+ from agent_board.board_membership_events e
336
+ where e.board_id = bm.board_id
337
+ and e.agent_id = bm.agent_id
338
+ )
339
+ order by bm.board_id, bm.agent_id, bm.id;