@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,485 @@
1
+ -- =============================================================================
2
+ -- MBCONSENT-B(iii) — LOCKING: validate and mutate the SAME row (#109, #110, and the
3
+ -- check-then-write race left by gating)
4
+ -- =============================================================================
5
+ -- Three defects, one structural change, deliberately reviewed together because they are the
6
+ -- same shared-function contract:
7
+ --
8
+ -- #109 resolve_board_request validated one row while the sink reselected with active-row
9
+ -- preference and mutated another. Rejecting a pending request downgraded the subject's
10
+ -- ACTIVE membership. Reproduced: (joined, requested) -> reject -> (left, requested).
11
+ -- #110 a refusal recorded as `eviction`, because the sink derives the kind from
12
+ -- (provenance, state) and cannot tell a refusal from a removal. Patching the row
13
+ -- afterwards required writing to an append-only table through definer privilege, which
14
+ -- is a worse trade than the wrong label -- so the kind is threaded instead.
15
+ -- race gating checked consent BEFORE the row was locked, so a withdrawal committed in
16
+ -- between could still be overwritten.
17
+ --
18
+ -- The sink gains two OPTIONAL parameters. Both are defaulted, so every existing 3..5-argument
19
+ -- caller is unaffected. It is DROPPED and recreated rather than replaced: adding parameters
20
+ -- creates a second overload, and a 5-argument call would then be ambiguous between them.
21
+ --
22
+ -- p_membership_id act on the row the caller validated and locked, instead of reselecting
23
+ -- p_event_kind name the descriptive kind. PROVENANCE IS NEVER DELEGATED -- a wrong kind
24
+ -- mislabels an event, a wrong provenance would fabricate consent.
25
+ --
26
+ -- schema_version stays 8: additive.
27
+ -- =============================================================================
28
+
29
+ drop function if exists agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb);
30
+
31
+ create or replace function agent_board._upsert_board_membership_state(
32
+ p_board_id uuid,
33
+ p_agent_id uuid,
34
+ p_membership_state text,
35
+ p_created_by_agent_id uuid default null,
36
+ p_metadata jsonb default '{}'::jsonb,
37
+ -- The caller may name the EXACT row it validated and locked. Without this the sink reselects
38
+ -- with active-row preference and can mutate a DIFFERENT row than the caller checked -- #109.
39
+ p_membership_id uuid default null,
40
+ -- The caller may name the event kind. The sink derives one from (provenance, state), which
41
+ -- cannot distinguish a refusal from an eviction -- #110. Provenance is NEVER delegated.
42
+ p_event_kind text default null
43
+ )
44
+ RETURNS agent_board.board_memberships
45
+ LANGUAGE plpgsql
46
+ SECURITY DEFINER
47
+ SET search_path TO 'agent_board', 'public'
48
+ as $$
49
+ declare
50
+ v_membership agent_board.board_memberships%rowtype;
51
+ v_metadata jsonb;
52
+ -- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
53
+ v_currently_removed boolean;
54
+ v_prior_state text;
55
+ begin
56
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
57
+ raise exception 'invalid board membership state' using errcode = '22023';
58
+ end if;
59
+
60
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
61
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
62
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
63
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
64
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
65
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
66
+ end if;
67
+
68
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
69
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
70
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
71
+ -- the column directly.
72
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
73
+ into v_metadata
74
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
75
+ where e.key not like 'ops\_%';
76
+
77
+ -- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
78
+ -- (exists removed AND no active), not a single total-ordered row. A higher-id
79
+ -- removed row can coexist with an active row; inferring "removed" from the
80
+ -- total-order winner alone would then try to activate the removed row and
81
+ -- collide with idx_agent_board_board_memberships_active_unique (23505).
82
+ -- Capture standing before the row lock so the predicate is independent of
83
+ -- which physical row the active-preferring SELECT below returns.
84
+ v_currently_removed :=
85
+ exists (
86
+ select 1 from agent_board.board_memberships bm
87
+ where bm.board_id = p_board_id
88
+ and bm.agent_id = p_agent_id
89
+ and bm.membership_state = 'removed'
90
+ ) and not exists (
91
+ select 1 from agent_board.board_memberships bm
92
+ where bm.board_id = p_board_id
93
+ and bm.agent_id = p_agent_id
94
+ and bm.membership_state in ('joined', 'subscribed')
95
+ );
96
+
97
+ -- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
98
+ -- exists, then total-order by created_at desc, id desc. A pure total-order
99
+ -- can select a higher-id REMOVED row while an ACTIVE row still exists.
100
+ -- When v_currently_removed, no active row exists and the total-order pick is
101
+ -- non-active (re-entry clamp applies below).
102
+ -- When the caller names a row it has already validated AND LOCKED, act on THAT row. The
103
+ -- active-preferring pick below is right when the sink chooses for itself, but wrong when a
104
+ -- caller validated a specific one: rejecting a pending request would lock and mutate the
105
+ -- subject's ACTIVE membership instead, downgrading a real member (#109).
106
+ if p_membership_id is not null then
107
+ select *
108
+ into v_membership
109
+ from agent_board.board_memberships bm
110
+ where bm.id = p_membership_id
111
+ and bm.board_id = p_board_id
112
+ and bm.agent_id = p_agent_id
113
+ for update;
114
+
115
+ if v_membership.id is null then
116
+ raise exception 'membership row % not found for this subject', p_membership_id
117
+ using errcode = 'P0002';
118
+ end if;
119
+ else
120
+ select *
121
+ into v_membership
122
+ from agent_board.board_memberships bm
123
+ where bm.board_id = p_board_id
124
+ and bm.agent_id = p_agent_id
125
+ order by
126
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
127
+ bm.created_at desc,
128
+ bm.id desc
129
+ limit 1
130
+ for update;
131
+ end if;
132
+
133
+ -- The PRIOR state, captured before the update below overwrites v_membership. The emitter
134
+ -- cannot see it, and without it a joined<->subscribed self-toggle is indistinguishable from
135
+ -- a fresh join.
136
+ v_prior_state := v_membership.membership_state;
137
+
138
+ if v_membership.id is null then
139
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
140
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
141
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
142
+ values (
143
+ p_board_id,
144
+ p_agent_id,
145
+ p_membership_state,
146
+ p_created_by_agent_id,
147
+ case when p_membership_state in ('left', 'removed') then now() else null end,
148
+ v_metadata
149
+ )
150
+ returning * into v_membership;
151
+ else
152
+ update agent_board.board_memberships bm
153
+ set membership_state = p_membership_state,
154
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
155
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
156
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
157
+ left_at = case
158
+ when p_membership_state in ('left', 'removed') then
159
+ case
160
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
161
+ -- repeat call cannot push the retention cutoff forward.
162
+ when v_membership.membership_state = p_membership_state
163
+ then coalesce(v_membership.left_at, now())
164
+ -- GENUINE transition (notably left -> removed, an owner barring
165
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
166
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
167
+ -- so this is the real kick moment; inheriting the earlier departure would
168
+ -- both lose the audit answer and make the marker row retention-eligible
169
+ -- prematurely.
170
+ else now()
171
+ end
172
+ else null
173
+ end,
174
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
175
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
176
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
177
+ -- Unconditional — no service_role exemption.
178
+ -- MBROWID: origin is non-active when the selected row is non-active OR when
179
+ -- currently-removed standing holds (R-LINK-7 set form). With active-preferring
180
+ -- selection those agree; the set form is load-bearing against equal-created_at
181
+ -- removed+left duplicates where a single-row read would flip with id order.
182
+ role = case
183
+ when p_membership_state in ('joined', 'subscribed')
184
+ and (
185
+ v_membership.membership_state not in ('joined', 'subscribed')
186
+ or v_currently_removed
187
+ )
188
+ then 'member'
189
+ else bm.role
190
+ end,
191
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
192
+ updated_at = now()
193
+ where bm.id = v_membership.id
194
+ returning * into v_membership;
195
+ end if;
196
+
197
+ -- Emission is delegated so the sink and the four raw writers cannot drift apart.
198
+ -- The frozen mapping distinguishes a self-TOGGLE between two active states from a fresh
199
+ -- self-JOIN. Only the sink knows the prior state, so it names the kind; the emitter derives
200
+ -- everything security-critical regardless.
201
+ -- A caller-supplied kind wins; otherwise the sink's own derivation stands. Only the
202
+ -- DESCRIPTIVE field is delegated -- provenance is always derived from the session.
203
+ perform agent_board._emit_membership_event(
204
+ p_board_id, p_agent_id, p_membership_state,
205
+ coalesce(p_event_kind, case
206
+ when v_prior_state in ('joined', 'subscribed')
207
+ and p_membership_state in ('joined', 'subscribed')
208
+ -- The states must actually DIFFER. join_board and update_board_membership both permit
209
+ -- joined->joined and subscribed->subscribed, and a write that changes nothing is not a
210
+ -- toggle of anything -- recording it as one permanently mislabels an append-only event.
211
+ and v_prior_state <> p_membership_state
212
+ and not agent_board._is_service_role()
213
+ and agent_board.current_agent_id() = p_agent_id
214
+ then 'consent_self_toggle'
215
+ else null
216
+ end));
217
+
218
+ return v_membership;
219
+ end;
220
+ $$;
221
+
222
+ -- RESTORE THE PRIVILEGE POSTURE. DROP FUNCTION discards the function's ACL, and CREATE grants
223
+ -- EXECUTE to PUBLIC by default -- so recreating the sink silently handed every authenticated
224
+ -- caller a direct line to it, bypassing every gate in the phase before this one. The gating
225
+ -- suite's catalog-derived class assertion caught it by NAME, which is precisely why that
226
+ -- assertion is derived from the catalog rather than from a list of routes.
227
+ --
228
+ -- Any future drop-and-recreate of a SECURITY DEFINER function must restate its grants in the
229
+ -- same migration. There is no inheritance.
230
+ -- The COMMENT is discarded by DROP as well, and a suite asserts on it. Restated with the two
231
+ -- new parameters described. Grants, comments, and any other object-level attribute must ALL be
232
+ -- restated after a drop-and-recreate -- nothing survives it.
233
+ comment on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb, uuid, text) is
234
+ 'UNAUTHORIZED PRIMITIVE — accepts any membership state; every caller MUST gate. ROLERESET '
235
+ '(#79): on the UPDATE path only, clamps role to the table default (''member'', '
236
+ '20260717000100:13-15) when transitioning from a non-active origin into joined|subscribed; '
237
+ 'INSERT omits role and takes the table default. Active->active preserves role. LEAVEIDEM '
238
+ '(#81): preserves the FIRST left_at across repeated terminal transitions (the retention sweep '
239
+ 'keys on left_at, 20260513000001:121) and strips the reserved `ops_*` metadata namespace from '
240
+ 'caller input (the sweep skips rows carrying ops_retained_at). MBCONSENT-B(iii): p_membership_id '
241
+ 'acts on the row the CALLER validated and locked instead of reselecting with active-row '
242
+ 'preference (#109); p_event_kind names the descriptive kind only (#110) — provenance is always '
243
+ 'derived from the session and is never delegated. Not a chokepoint — raw-INSERT seeders '
244
+ '(create_board owner, bootstrap, invite redeem, fedsession autojoin) bypass this function.';
245
+
246
+ revoke all on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb, uuid, text) from public;
247
+ grant execute on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb, uuid, text) to service_role;
248
+
249
+ create or replace function agent_board.resolve_board_request(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
250
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
251
+ LANGUAGE plpgsql
252
+ SECURITY DEFINER
253
+ SET search_path TO 'agent_board', 'public'
254
+ as $$
255
+ declare
256
+ v_effective_state text;
257
+ v_board agent_board.boards%rowtype;
258
+ v_membership_id uuid;
259
+ v_active_id uuid;
260
+ v_membership agent_board.board_memberships%rowtype;
261
+ v_actor uuid := agent_board.current_agent_id();
262
+ begin
263
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
264
+ if v_actor is null and not agent_board._is_service_role() then
265
+ raise exception 'current agent principal not resolved' using errcode = '42501';
266
+ end if;
267
+
268
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
269
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
270
+ end if;
271
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
272
+ if v_board.id is null then
273
+ raise exception 'board not found' using errcode = 'P0002';
274
+ end if;
275
+ if v_actor is not null then
276
+ if v_board.role_gate_enforced then
277
+ -- Gate ON: NO creator exemption. Every actor (including a demoted creator)
278
+ -- must hold an active owner/admin membership.
279
+ if not exists (
280
+ select 1
281
+ from agent_board.board_memberships bm
282
+ where bm.board_id = p_board_id
283
+ and bm.agent_id = v_actor
284
+ and bm.membership_state in ('joined', 'subscribed')
285
+ and bm.role in ('owner', 'admin')
286
+ ) then
287
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
288
+ end if;
289
+ elsif v_actor <> v_board.created_by_agent_id then
290
+ -- Gate OFF: creator bypasses; any other actor needs an active membership.
291
+ if not exists (
292
+ select 1
293
+ from agent_board.board_memberships bm
294
+ where bm.board_id = p_board_id
295
+ and bm.agent_id = v_actor
296
+ and bm.membership_state in ('joined', 'subscribed')
297
+ ) then
298
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
299
+ end if;
300
+ end if;
301
+ end if;
302
+
303
+ -- LOCK the row this call will act on, and capture its IDENTITY, in the same statement that
304
+ -- reads its state. Three defects share this line:
305
+ --
306
+ -- * the row read here was not the row the sink then mutated. The sink reselects with
307
+ -- ACTIVE-ROW PREFERENCE, so with a legal (joined, requested) duplicate this validated the
308
+ -- pending row and the sink downgraded the ACTIVE one (#109).
309
+ -- * consent was read WITHOUT the row locked, so a withdrawal committed between the read and
310
+ -- the write could be overwritten -- the race gating left open.
311
+ --
312
+ -- `for update` here makes both impossible: a concurrent withdrawal serialises behind this
313
+ -- lock, and passing v_membership_id to the sink stops it choosing a different row.
314
+ -- LOCK THE WHOLE (board, agent) SET FIRST, so a concurrent self-withdrawal on ANY physical
315
+ -- row of this subject serialises behind this resolution -- #109's race is per-(board,agent),
316
+ -- not per-row. A prior revision locked only the total-order winner, so a withdrawal on a
317
+ -- DIFFERENT row (the active one, when a legal active+pending duplicate exists) never
318
+ -- contended, and the withdrawal was overwritten. Four-seat review + a two-session probe
319
+ -- (Fable) established that.
320
+ perform 1
321
+ from agent_board.board_memberships bm
322
+ where bm.board_id = p_board_id
323
+ and bm.agent_id = p_agent_id
324
+ order by bm.id
325
+ for update;
326
+
327
+ -- Select the PENDING row -- the one being resolved -- explicitly, not the total-order winner.
328
+ -- With a legal (active, pending) duplicate the active row can sort first, and locking THAT
329
+ -- made the resolver report "pending not found" (#109) and, on approve, promote the wrong row
330
+ -- into a unique-violation (the A3 regression).
331
+ select bm.id
332
+ into v_membership_id
333
+ from agent_board.board_memberships bm
334
+ where bm.board_id = p_board_id
335
+ and bm.agent_id = p_agent_id
336
+ and bm.membership_state in ('requested', 'invited')
337
+ order by bm.created_at desc, bm.id desc
338
+ limit 1;
339
+
340
+ -- ...and separately, whether an ACTIVE row already exists for this subject.
341
+ select bm.id
342
+ into v_active_id
343
+ from agent_board.board_memberships bm
344
+ where bm.board_id = p_board_id
345
+ and bm.agent_id = p_agent_id
346
+ and bm.membership_state in ('joined', 'subscribed')
347
+ order by bm.created_at desc, bm.id desc
348
+ limit 1;
349
+
350
+ if v_membership_id is null then
351
+ raise exception 'pending board membership not found' using errcode = 'P0002';
352
+ end if;
353
+
354
+ -- OPTION 1 FALLBACK (#109 A3): approving a subject who is ALREADY active is a no-op success,
355
+ -- not a promotion of the pending row into collision with the active one. The real fix is that
356
+ -- this duplicate should not arise (request_join_board no longer demotes/duplicates an active
357
+ -- member), but if it exists via an operator/raw path, admitting them again is meaningless --
358
+ -- return the active row untouched. Rejection still falls through to act on the pending row.
359
+ if p_membership_state in ('joined', 'subscribed') and v_active_id is not null then
360
+ return query
361
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
362
+ from agent_board.board_memberships bm
363
+ where bm.id = v_active_id;
364
+ return;
365
+ end if;
366
+
367
+ -- REJECTSEM (#89). A rejection is NOT an eviction. resolve_board_request only ever acts on a
368
+ -- PENDING row (requested|invited) -- update_board_membership is the schema's only eviction
369
+ -- path -- so writing `removed` here mislabels a refusal as a removal, and `removed` bars
370
+ -- self-re-entry. Reproduced before fixing: request an OPEN board, owner rejects, and the
371
+ -- subject can no longer join a board open to anyone ("board is not joinable by this agent").
372
+ -- One refusal became a permanent, self-unrecoverable bar.
373
+ --
374
+ -- Applied to BOTH pending origins, not just `requested`. Cancelling an INVITATION had the
375
+ -- identical trap, and fixing only the named instance is how several defects in this work
376
+ -- survived a round.
377
+ --
378
+ -- `left` is recoverable: the subject may act again. The refusal is still durable in the
379
+ -- ledger -- this changes the membership STATE, not the record that it happened.
380
+ v_effective_state := case when p_membership_state = 'removed' then 'left'
381
+ else p_membership_state end;
382
+
383
+ -- #82: PROMOTION INTO AN ACTIVE STATE REQUIRES THE SUBJECT'S OWN ACT. This is the sole
384
+ -- consent-predicate read site. An owner resolving a pending row to joined|subscribed is a
385
+ -- BOARD act; it admits the subject only if the subject has already consented -- by requesting
386
+ -- (target-authored) or by an operator placement. A board-authored `invited` or a manufactured
387
+ -- `requested` is NEUTRAL on the ledger and does not admit anyone.
388
+ --
389
+ -- Authenticated only. service_role keeps the operator carve-out.
390
+ if v_actor is not null
391
+ and v_effective_state in ('joined', 'subscribed')
392
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
393
+ raise exception 'admitting % requires an act by that agent', p_agent_id
394
+ using errcode = '42501';
395
+ end if;
396
+
397
+ v_membership := agent_board._upsert_board_membership_state(
398
+ p_board_id,
399
+ p_agent_id,
400
+ v_effective_state,
401
+ p_created_by_agent_id,
402
+ p_metadata,
403
+ -- act on the row LOCKED above, not one the sink reselects with active-row preference (#109)
404
+ v_membership_id,
405
+ -- a refusal is a REFUSAL. The sink derives board-authored `left` as `eviction`, which
406
+ -- conflates it with a removal (#110). Only the DESCRIPTIVE kind is named -- never provenance.
407
+ case when p_membership_state = 'removed' then 'reject_withdrawn' else null end
408
+ );
409
+
410
+ -- The kind IS threaded now, through the sink's p_event_kind. An earlier revision instead
411
+ -- UPDATEd the freshly-emitted ledger row, which worked only by bypassing grants through
412
+ -- SECURITY DEFINER -- board_membership_events is append-only BY GRANT and service_role holds
413
+ -- no UPDATE. That was reverted; this is the shape it should always have had.
414
+
415
+ return query
416
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
417
+ end;
418
+ $$;
419
+
420
+ create or replace function agent_board.request_join_board(p_board_id uuid, p_agent_id uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
421
+ RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, created_at timestamp with time zone, updated_at timestamp with time zone)
422
+ LANGUAGE plpgsql
423
+ SECURITY DEFINER
424
+ SET search_path TO 'agent_board', 'public'
425
+ as $$
426
+ declare
427
+ v_board agent_board.boards%rowtype;
428
+ v_membership agent_board.board_memberships%rowtype;
429
+ v_actor uuid := agent_board.current_agent_id();
430
+ begin
431
+ -- GATENULL (#58): only service_role may proceed with an unresolved actor.
432
+ if v_actor is null and not agent_board._is_service_role() then
433
+ raise exception 'current agent principal not resolved' using errcode = '42501';
434
+ end if;
435
+
436
+ select * into v_board
437
+ from agent_board.boards
438
+ where id = p_board_id
439
+ and status = 'active'
440
+ and visibility in ('open', 'invite_only');
441
+
442
+ if v_board.id is null then
443
+ raise exception 'board not found' using errcode = 'P0002';
444
+ end if;
445
+ if v_actor is not null and v_actor <> p_agent_id then
446
+ raise exception 'board request actor mismatch' using errcode = '42501';
447
+ end if;
448
+ if not exists (select 1 from agent_board.agents where id = p_agent_id) then
449
+ raise exception 'agent not found' using errcode = 'P0002';
450
+ end if;
451
+
452
+ -- OPTION 3 ROOT CAUSE (#109 / Fable B5): an already-ACTIVE member requesting is a NO-OP.
453
+ -- The sink's active-preference UPDATE would otherwise DEMOTE their joined row to `requested`
454
+ -- (B5), or -- via other paths -- leave an (active, pending) duplicate that the resolver then
455
+ -- has to untangle. They are already in; requesting changes nothing. This is what stops the
456
+ -- dangerous duplicate arising from the request path in the first place.
457
+ if exists (
458
+ select 1 from agent_board.board_memberships bm
459
+ where bm.board_id = p_board_id
460
+ and bm.agent_id = p_agent_id
461
+ and bm.membership_state in ('joined', 'subscribed')
462
+ ) then
463
+ return query
464
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.created_at, bm.updated_at
465
+ from agent_board.board_memberships bm
466
+ where bm.board_id = p_board_id
467
+ and bm.agent_id = p_agent_id
468
+ and bm.membership_state in ('joined', 'subscribed')
469
+ order by bm.created_at desc, bm.id desc
470
+ limit 1;
471
+ return;
472
+ end if;
473
+
474
+ v_membership := agent_board._upsert_board_membership_state(
475
+ p_board_id,
476
+ p_agent_id,
477
+ 'requested',
478
+ p_agent_id,
479
+ p_metadata
480
+ );
481
+
482
+ return query
483
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
484
+ end;
485
+ $$;
@@ -0,0 +1,23 @@
1
+ -- MBHARDEN / #97 — revoke the stray PUBLIC EXECUTE on mark_retention_metadata.
2
+ --
3
+ -- agent_board.mark_retention_metadata is SECURITY DEFINER (runs as the schema owner,
4
+ -- postgres) and performs UNSCOPED bulk writes -- it stamps ops_retention_* metadata and
5
+ -- bumps updated_at across eight core tables (messages, task_events, presence,
6
+ -- callback_outbox_events, callback_delivery_attempts, inbox_items, delivery_recipients,
7
+ -- board_memberships) with no board scoping and no session-derived authorization. It is an
8
+ -- operator/ops-only sweep, invoked by the retention runner as service_role.
9
+ --
10
+ -- Its defining migration (20260513000001_agent_board_retention.sql:160) granted EXECUTE to
11
+ -- service_role but never revoked the default PUBLIC EXECUTE that CREATE FUNCTION confers.
12
+ -- Consequently any `authenticated` agent could drive a global metadata/updated_at write
13
+ -- across every board. No client/SDK path calls this function -- revoking PUBLIC breaks
14
+ -- nothing live.
15
+ --
16
+ -- Fix: revoke from public (and name anon/authenticated explicitly per house convention,
17
+ -- 20260801000100:309-317), re-assert the service_role grant so a fresh replay is
18
+ -- order-independent. This is a pure privilege change; the function body is unchanged.
19
+
20
+ revoke all on function agent_board.mark_retention_metadata(text, timestamptz, uuid, text)
21
+ from public, anon, authenticated;
22
+ grant execute on function agent_board.mark_retention_metadata(text, timestamptz, uuid, text)
23
+ to service_role;
@@ -0,0 +1,54 @@
1
+ -- MBHARDEN / #118 — revoke the stray PUBLIC EXECUTE on the remaining SECURITY DEFINER
2
+ -- functions, closing the same class as #97 (mark_retention_metadata, fixed in
3
+ -- 20260811000100).
4
+ --
5
+ -- A catalog sweep found six agent_board SECURITY DEFINER functions where PUBLIC (the `=X`
6
+ -- ACL entry that CREATE FUNCTION confers by default) still held EXECUTE. #97 fixed the
7
+ -- clear unscoped-write hole; this migration closes the other five. Each caller is itself a
8
+ -- SECURITY DEFINER function (verified from pg_proc bodies), so it executes as the schema
9
+ -- owner and never re-checks PUBLIC — revoking PUBLIC breaks no internal path, and none of
10
+ -- the five is referenced by any client/SDK.
11
+ --
12
+ -- Triage (discriminator: called directly by an `authenticated` session with attacker
13
+ -- controlled args, does it mutate state or leak cross-board data WITHOUT re-deriving on the
14
+ -- session?):
15
+ --
16
+ -- * _record_signed_write_evidence — LIVE HOLE. INSERTs a signature_evidence row taking
17
+ -- the actor and verification_status (incl. 'verified') from the caller-supplied
18
+ -- p_gateway_decision jsonb, never the session. Direct authenticated call forges
19
+ -- attestation rows for any agent; those surface as authoritative in v_thread_feed /
20
+ -- v_agent_dashboard / v_task_tree / v_signature_evidence_status /
21
+ -- v_fleet_delivery_posture. Callers: the five signed_* RPCs (all SECURITY DEFINER).
22
+ -- * _resolve_addressing_recipient_rows — LIVE LEAK. Actor-spoofable directory/handle/
23
+ -- group resolver; takes p_actor_agent_id as a raw trusted param and does NOT rebind via
24
+ -- _resolved_actor_agent_id (unlike resolve_recipients, which does), and ignores RLS.
25
+ -- Caller: resolve_recipients (SECURITY DEFINER).
26
+ -- * _addressing_agent_ids_or_raise — benign (its actor param is rebound fail-closed inside
27
+ -- resolve_recipients); revoked for hygiene + the class invariant.
28
+ -- * _signed_write_actor_id — benign (pure; returns only the caller's own args); revoked
29
+ -- for hygiene + the class invariant.
30
+ -- * _enforce_agent_lineage_owner — benign trigger fn (Postgres refuses direct
31
+ -- invocation); revoked so the class invariant holds with no exceptions.
32
+ --
33
+ -- No grants are re-asserted: every caller runs as the owner, so these helpers need no
34
+ -- grantee. The owner (postgres) retains EXECUTE through ownership.
35
+
36
+ revoke all on function
37
+ agent_board._record_signed_write_evidence(text, uuid, text, jsonb, jsonb)
38
+ from public, anon, authenticated;
39
+
40
+ revoke all on function
41
+ agent_board._resolve_addressing_recipient_rows(uuid, jsonb)
42
+ from public, anon, authenticated;
43
+
44
+ revoke all on function
45
+ agent_board._addressing_agent_ids_or_raise(uuid, jsonb)
46
+ from public, anon, authenticated;
47
+
48
+ revoke all on function
49
+ agent_board._signed_write_actor_id(jsonb, jsonb, text)
50
+ from public, anon, authenticated;
51
+
52
+ revoke all on function
53
+ agent_board._enforce_agent_lineage_owner()
54
+ from public, anon, authenticated;