@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,619 @@
1
+ -- =============================================================================
2
+ -- MBCONSENT-B(a) — START RECORDING (emission only, no gating)
3
+ -- =============================================================================
4
+ -- message-board#82/#89/#90.
5
+ --
6
+ -- Every membership transition now writes exactly one event to the consent ledger that
7
+ -- MBCONSENT-A froze. NOTHING READS IT YET: no route consults the predicate, so no call that
8
+ -- succeeds today can begin failing. That is the point of landing emission alone -- the ledger
9
+ -- fills, and the gating change that follows reads a populated ledger instead of an empty one.
10
+ -- Gating first would deny everyone.
11
+ --
12
+ -- schema_version stays 8: additive.
13
+ --
14
+ -- The one load-bearing rule: provenance is derived HERE, from the session, and never from
15
+ -- p_created_by_agent_id. A caller-supplied id is an ASSERTION about who acted; the session is
16
+ -- EVIDENCE. And service_role is ALWAYS operator-designated -- even when a subject happens to
17
+ -- resolve from its token -- because an operator acting is an operator placement, not the
18
+ -- subject consenting.
19
+ -- =============================================================================
20
+
21
+
22
+ -- =============================================================================
23
+ -- The emitter. ONE implementation, called from the shared sink AND from every raw writer.
24
+ -- =============================================================================
25
+ -- Five functions write board_memberships. Instrumenting only the shared sink leaves the other
26
+ -- four emitting NOTHING, so a membership created through them would carry a SILENT ledger and
27
+ -- later read as non-consenting once gating lands. Duplicating the derivation five times would
28
+ -- let the copies drift, so there is exactly one.
29
+ create or replace function agent_board._emit_membership_event(
30
+ p_board_id uuid,
31
+ p_agent_id uuid,
32
+ p_membership_state text,
33
+ p_event_kind text default null
34
+ )
35
+ returns void
36
+ language plpgsql
37
+ security definer
38
+ set search_path = agent_board, public
39
+ as $$
40
+ declare
41
+ v_actor_id uuid := agent_board.current_agent_id();
42
+ v_prov text;
43
+ v_event_actor uuid;
44
+ v_event_kind text;
45
+ begin
46
+ -- Provenance is DERIVED HERE from the session and NEVER from a caller-supplied argument.
47
+ -- A caller-supplied id is an ASSERTION about who acted; the session is EVIDENCE. Keying off
48
+ -- the argument would let any caller mint a `target-authored` event -- the one provenance
49
+ -- that means "the subject consented".
50
+ --
51
+ -- service_role is ALWAYS operator-designated, even when a subject resolves from its token.
52
+ -- An operator acting while some agent id happens to be resolvable is still an operator
53
+ -- placement, and recording it as the subject's own act launders exactly what this ledger
54
+ -- exists to distinguish.
55
+ if agent_board._is_service_role() then
56
+ v_prov := 'operator-designated';
57
+ v_event_actor := null;
58
+ elsif v_actor_id is not null and v_actor_id = p_agent_id then
59
+ v_prov := 'target-authored';
60
+ v_event_actor := p_agent_id;
61
+ elsif v_actor_id is not null then
62
+ v_prov := 'board-authored';
63
+ v_event_actor := v_actor_id;
64
+ else
65
+ -- Unresolved, non-service actor. GATENULL should have refused upstream; if it did not,
66
+ -- fail CLOSED rather than invent a provenance for an unattributable write.
67
+ raise exception 'membership change has no attributable actor'
68
+ using errcode = '42501';
69
+ end if;
70
+
71
+ -- The KIND may be supplied by the caller; the PROVENANCE never can. The frozen mapping
72
+ -- names specific kinds for the raw writers -- owner_seed, consent_link_redeem,
73
+ -- operator_repair -- which a generic (provenance, state) derivation cannot produce. Letting
74
+ -- the caller name the kind while the security-critical field stays derived keeps the
75
+ -- vocabulary honest without reopening the forge: a wrong kind mislabels an event, a wrong
76
+ -- provenance would fabricate consent.
77
+ v_event_kind := coalesce(p_event_kind, case
78
+ when v_prov = 'target-authored' and p_membership_state in ('joined', 'subscribed') then 'consent_self_join'
79
+ when v_prov = 'target-authored' and p_membership_state = 'requested' then 'consent_join_request'
80
+ when v_prov = 'target-authored' and p_membership_state = 'invited' then 'consent_self_invite'
81
+ when v_prov = 'target-authored' and p_membership_state in ('left', 'removed') then 'self_withdraw'
82
+ when v_prov = 'board-authored' and p_membership_state = 'invited' then 'invite_offer'
83
+ when v_prov = 'board-authored' and p_membership_state = 'requested' then 'request_manufactured'
84
+ when v_prov = 'board-authored' and p_membership_state in ('joined', 'subscribed') then 'resolution'
85
+ when v_prov = 'board-authored' and p_membership_state in ('left', 'removed') then 'eviction'
86
+ when v_prov = 'operator-designated' and p_membership_state in ('joined', 'subscribed') then 'operator_designation'
87
+ when v_prov = 'operator-designated' and p_membership_state in ('left', 'removed') then 'operator_removal'
88
+ -- Operator writes to a PENDING state are NEUTRAL, and must not borrow a grant kind.
89
+ -- `operator_repair` denotes bootstrap's grant; letting it be the catch-all made an ordinary
90
+ -- service-role write to `invited`/`requested` read as a repair-grant. Bootstrap still gets
91
+ -- operator_repair -- it passes the kind explicitly.
92
+ when v_prov = 'operator-designated' and p_membership_state = 'invited' then 'invite_offer'
93
+ when v_prov = 'operator-designated' and p_membership_state = 'requested' then 'request_manufactured'
94
+ -- No catch-all. The (provenance, state) space is fully enumerated above, so reaching here
95
+ -- means a state was added without deciding what it MEANS. Fail loudly rather than silently
96
+ -- labelling it -- a wrong kind in an append-only ledger cannot be corrected later.
97
+ else null
98
+ end);
99
+
100
+ if v_event_kind is null then
101
+ raise exception 'no event kind for provenance % with resulting state %', v_prov, p_membership_state
102
+ using errcode = '22023';
103
+ end if;
104
+
105
+ insert into agent_board.board_membership_events(
106
+ board_id, agent_id, event_kind, result_state, provenance, actor_agent_id)
107
+ values (p_board_id, p_agent_id, v_event_kind, p_membership_state, v_prov, v_event_actor);
108
+ end;
109
+ $$;
110
+
111
+ revoke all on function agent_board._emit_membership_event(uuid, uuid, text, text) from public;
112
+ grant execute on function agent_board._emit_membership_event(uuid, uuid, text, text) to service_role;
113
+
114
+ create or replace function agent_board._upsert_board_membership_state(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
115
+ returns agent_board.board_memberships
116
+ language plpgsql
117
+ security definer
118
+ set search_path = agent_board, public
119
+ as $$
120
+ declare
121
+ v_membership agent_board.board_memberships%rowtype;
122
+ v_metadata jsonb;
123
+ -- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
124
+ v_currently_removed boolean;
125
+ v_prior_state text;
126
+ begin
127
+ if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
128
+ raise exception 'invalid board membership state' using errcode = '22023';
129
+ end if;
130
+
131
+ -- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
132
+ -- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
133
+ -- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
134
+ -- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
135
+ if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
136
+ raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
137
+ end if;
138
+
139
+ -- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
140
+ -- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
141
+ -- by a caller whose own row they govern. Strip them from caller input; operational writers set
142
+ -- the column directly.
143
+ select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
144
+ into v_metadata
145
+ from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
146
+ where e.key not like 'ops\_%';
147
+
148
+ -- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
149
+ -- (exists removed AND no active), not a single total-ordered row. A higher-id
150
+ -- removed row can coexist with an active row; inferring "removed" from the
151
+ -- total-order winner alone would then try to activate the removed row and
152
+ -- collide with idx_agent_board_board_memberships_active_unique (23505).
153
+ -- Capture standing before the row lock so the predicate is independent of
154
+ -- which physical row the active-preferring SELECT below returns.
155
+ v_currently_removed :=
156
+ exists (
157
+ select 1 from agent_board.board_memberships bm
158
+ where bm.board_id = p_board_id
159
+ and bm.agent_id = p_agent_id
160
+ and bm.membership_state = 'removed'
161
+ ) and not exists (
162
+ select 1 from agent_board.board_memberships bm
163
+ where bm.board_id = p_board_id
164
+ and bm.agent_id = p_agent_id
165
+ and bm.membership_state in ('joined', 'subscribed')
166
+ );
167
+
168
+ -- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
169
+ -- exists, then total-order by created_at desc, id desc. A pure total-order
170
+ -- can select a higher-id REMOVED row while an ACTIVE row still exists.
171
+ -- When v_currently_removed, no active row exists and the total-order pick is
172
+ -- non-active (re-entry clamp applies below).
173
+ select *
174
+ into v_membership
175
+ from agent_board.board_memberships bm
176
+ where bm.board_id = p_board_id
177
+ and bm.agent_id = p_agent_id
178
+ order by
179
+ case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
180
+ bm.created_at desc,
181
+ bm.id desc
182
+ limit 1
183
+ for update;
184
+
185
+ -- The PRIOR state, captured before the update below overwrites v_membership. The emitter
186
+ -- cannot see it, and without it a joined<->subscribed self-toggle is indistinguishable from
187
+ -- a fresh join.
188
+ v_prior_state := v_membership.membership_state;
189
+
190
+ if v_membership.id is null then
191
+ -- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
192
+ -- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
193
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
194
+ values (
195
+ p_board_id,
196
+ p_agent_id,
197
+ p_membership_state,
198
+ p_created_by_agent_id,
199
+ case when p_membership_state in ('left', 'removed') then now() else null end,
200
+ v_metadata
201
+ )
202
+ returning * into v_membership;
203
+ else
204
+ update agent_board.board_memberships bm
205
+ set membership_state = p_membership_state,
206
+ created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
207
+ -- PRESERVE the first departure time. Restamping it would let the departing agent (or a
208
+ -- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
209
+ left_at = case
210
+ when p_membership_state in ('left', 'removed') then
211
+ case
212
+ -- SAME terminal state re-asserted: preserve the FIRST departure time so a
213
+ -- repeat call cannot push the retention cutoff forward.
214
+ when v_membership.membership_state = p_membership_state
215
+ then coalesce(v_membership.left_at, now())
216
+ -- GENUINE transition (notably left -> removed, an owner barring
217
+ -- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
218
+ -- deliberately does not block voluntary 'left' (20260724000100:321-322),
219
+ -- so this is the real kick moment; inheriting the earlier departure would
220
+ -- both lose the audit answer and make the marker row retention-eligible
221
+ -- prematurely.
222
+ else now()
223
+ end
224
+ else null
225
+ end,
226
+ -- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
227
+ -- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
228
+ -- information_schema at runtime. Active -> active preserves bm.role (including owner).
229
+ -- Unconditional — no service_role exemption.
230
+ -- MBROWID: origin is non-active when the selected row is non-active OR when
231
+ -- currently-removed standing holds (R-LINK-7 set form). With active-preferring
232
+ -- selection those agree; the set form is load-bearing against equal-created_at
233
+ -- removed+left duplicates where a single-row read would flip with id order.
234
+ role = case
235
+ when p_membership_state in ('joined', 'subscribed')
236
+ and (
237
+ v_membership.membership_state not in ('joined', 'subscribed')
238
+ or v_currently_removed
239
+ )
240
+ then 'member'
241
+ else bm.role
242
+ end,
243
+ metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
244
+ updated_at = now()
245
+ where bm.id = v_membership.id
246
+ returning * into v_membership;
247
+ end if;
248
+
249
+ -- Emission is delegated so the sink and the four raw writers cannot drift apart.
250
+ -- The frozen mapping distinguishes a self-TOGGLE between two active states from a fresh
251
+ -- self-JOIN. Only the sink knows the prior state, so it names the kind; the emitter derives
252
+ -- everything security-critical regardless.
253
+ perform agent_board._emit_membership_event(
254
+ p_board_id, p_agent_id, p_membership_state,
255
+ case
256
+ when v_prior_state in ('joined', 'subscribed')
257
+ and p_membership_state in ('joined', 'subscribed')
258
+ -- The states must actually DIFFER. join_board and update_board_membership both permit
259
+ -- joined->joined and subscribed->subscribed, and a write that changes nothing is not a
260
+ -- toggle of anything -- recording it as one permanently mislabels an append-only event.
261
+ and v_prior_state <> p_membership_state
262
+ and not agent_board._is_service_role()
263
+ and agent_board.current_agent_id() = p_agent_id
264
+ then 'consent_self_toggle'
265
+ else null
266
+ end);
267
+
268
+ return v_membership;
269
+ end;
270
+ $$;
271
+
272
+ create or replace function agent_board.create_board(p_created_by_agent_id uuid, p_slug text, p_name text, p_description text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb, p_visibility text DEFAULT 'private'::text)
273
+ RETURNS TABLE(board_id uuid, slug text, name text, status text, visibility text, created_at timestamp with time zone, updated_at timestamp with time zone)
274
+ LANGUAGE plpgsql
275
+ SECURITY DEFINER
276
+ SET search_path TO 'agent_board', 'public'
277
+ as $$
278
+ #variable_conflict use_column
279
+ declare
280
+ v_seed_state text;
281
+ v_actor uuid;
282
+ v_existing agent_board.boards%rowtype;
283
+ v_board agent_board.boards%rowtype;
284
+ begin
285
+ if nullif(trim(p_slug), '') is null or nullif(trim(p_name), '') is null then
286
+ raise exception 'board slug and name are required' using errcode = '22023';
287
+ end if;
288
+ if coalesce(p_visibility, 'private') not in ('private', 'invite_only', 'open', 'directory_hidden') then
289
+ raise exception 'invalid board visibility' using errcode = '22023';
290
+ end if;
291
+
292
+ -- Effective actor: service_role -> supplied arg (create-on-behalf-of);
293
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
294
+ v_actor := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
295
+
296
+ if not exists (select 1 from agent_board.agents where id = v_actor) then
297
+ raise exception 'agent not found' using errcode = 'P0002';
298
+ end if;
299
+
300
+ select * into v_existing
301
+ from agent_board.boards b
302
+ where b.slug = trim(p_slug);
303
+
304
+ if found then
305
+ -- Slug-collision path is OWNER-ONLY, keyed on CURRENT authority: the actor must
306
+ -- hold an ACTIVE owner-role membership on the existing board — NOT merely be its
307
+ -- original `created_by_agent_id`. A demoted or departed original creator no longer
308
+ -- owns the board and must NOT be able to re-open/rename/flip-visibility it.
309
+ if not exists (
310
+ select 1 from agent_board.board_memberships m
311
+ where m.board_id = v_existing.id
312
+ and m.agent_id = v_actor
313
+ and m.role = 'owner'
314
+ and m.membership_state in ('joined', 'subscribed')
315
+ ) then
316
+ raise exception 'board slug already in use' using errcode = '42501';
317
+ end if;
318
+
319
+ update agent_board.boards as b
320
+ set name = trim(p_name),
321
+ description = nullif(trim(p_description), ''),
322
+ status = 'active',
323
+ visibility = coalesce(p_visibility, 'private'),
324
+ metadata = coalesce(b.metadata, '{}'::jsonb) || coalesce(p_metadata, '{}'::jsonb),
325
+ updated_at = now()
326
+ where b.id = v_existing.id
327
+ returning * into v_board;
328
+ -- No owner re-seed: the owner was seeded at this board's original creation, and
329
+ -- re-promoting on every create_board would resurrect a deliberately demoted owner.
330
+ else
331
+ insert into agent_board.boards as b(slug, name, description, created_by_agent_id, visibility, metadata)
332
+ values (
333
+ trim(p_slug),
334
+ trim(p_name),
335
+ nullif(trim(p_description), ''),
336
+ v_actor,
337
+ coalesce(p_visibility, 'private'),
338
+ coalesce(p_metadata, '{}'::jsonb)
339
+ )
340
+ returning * into v_board;
341
+
342
+ -- Seed the creator's owner membership, bound to the board's ACTUAL
343
+ -- created_by_agent_id (never the raw param). Idempotent against the active
344
+ -- partial-unique index so at most one active owner row exists.
345
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
346
+ values (v_board.id, v_board.created_by_agent_id, 'joined', 'owner', v_board.created_by_agent_id)
347
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
348
+ do update set role = 'owner', updated_at = now()
349
+ returning bm.membership_state into v_seed_state;
350
+ -- The creator seeds their OWN owner seat, so the session is the subject and this emits
351
+ -- target-authored. Under service_role it emits operator-designated instead -- correct
352
+ -- either way, because the helper reads the session rather than being told.
353
+ --
354
+ -- v_seed_state, NOT a literal 'joined'. The ON CONFLICT above lifts only the ROLE, so a
355
+ -- board whose creator already holds a `subscribed` row stays subscribed while a literal
356
+ -- would record `joined` -- a ledger contradicting the table. Same defect the redemption
357
+ -- path had; fixing one instance without sweeping the class is how it survived here.
358
+ perform agent_board._emit_membership_event(v_board.id, v_board.created_by_agent_id, v_seed_state, 'owner_seed');
359
+ end if;
360
+
361
+ return query
362
+ select v_board.id, v_board.slug, v_board.name, v_board.status, v_board.visibility, v_board.created_at, v_board.updated_at;
363
+ end;
364
+ $$;
365
+
366
+ create or replace function agent_board.bootstrap_board_owner_memberships()
367
+ RETURNS TABLE(board_id uuid, owner_agent_id uuid, membership_id uuid)
368
+ LANGUAGE plpgsql
369
+ SECURITY DEFINER
370
+ SET search_path TO 'agent_board', 'public'
371
+ as $$
372
+ #variable_conflict use_column
373
+ declare
374
+ v_seed_state text;
375
+ v_row record;
376
+ v_membership_id uuid;
377
+ begin
378
+ -- service_role-only: current_agent_id() is null for service_role, non-null for any
379
+ -- authenticated principal (guarded here in addition to the EXECUTE revoke below).
380
+ if not agent_board._is_service_role() then
381
+ raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
382
+ end if;
383
+
384
+ -- R-LINK-7 guard shape (structural proof requires no-active AND no-removed
385
+ -- contiguously): re-seed only when the creator has no active membership AND is
386
+ -- not currently removed. NEVER suppress on 'left' alone — that is the
387
+ -- sole-owner-lockout recovery trigger (agent_board_rolereset.test.sql assertion
388
+ -- 23). requested/invited-only creators still re-seed; a removed+left pair is
389
+ -- suppressed.
390
+ for v_row in
391
+ select b.id as board_id, b.created_by_agent_id as owner_agent_id
392
+ from agent_board.boards b
393
+ where not exists (
394
+ select 1
395
+ from agent_board.board_memberships bm
396
+ where bm.board_id = b.id
397
+ and bm.agent_id = b.created_by_agent_id
398
+ and bm.membership_state in ('joined', 'subscribed')
399
+ )
400
+ and not exists (
401
+ select 1
402
+ from agent_board.board_memberships bm
403
+ where bm.board_id = b.id
404
+ and bm.agent_id = b.created_by_agent_id
405
+ and bm.membership_state = 'removed'
406
+ )
407
+ loop
408
+ insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
409
+ values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
410
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
411
+ do update set role = 'owner', updated_at = now()
412
+ returning bm.id, bm.membership_state into v_membership_id, v_seed_state;
413
+
414
+ -- Operator repair path: always operator-designated, never the owner's own act.
415
+ -- v_seed_state, NOT a literal: the ON CONFLICT lifts only the ROLE, so a concurrently
416
+ -- created `subscribed` membership stays subscribed while a literal would record `joined`.
417
+ perform agent_board._emit_membership_event(v_row.board_id, v_row.owner_agent_id, v_seed_state, 'operator_repair');
418
+
419
+ board_id := v_row.board_id;
420
+ owner_agent_id := v_row.owner_agent_id;
421
+ membership_id := v_membership_id;
422
+ return next;
423
+ end loop;
424
+ end;
425
+ $$;
426
+
427
+ create or replace function agent_board._redeem_invite_for_agent(p_code text, p_agent_id uuid)
428
+ RETURNS TABLE(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamp with time zone)
429
+ LANGUAGE plpgsql
430
+ SECURITY DEFINER
431
+ SET search_path TO 'agent_board', 'public'
432
+ as $$
433
+ -- OUT params board_id/agent_id/role/membership_state/updated_at share names with the
434
+ -- membership columns referenced in the on-conflict target + update set below; prefer
435
+ -- the column in any collision (mirrors ROOMOWN create_board).
436
+ #variable_conflict use_column
437
+ declare
438
+ v_inv agent_board.board_invite_tokens%rowtype;
439
+ v_led agent_board.board_invite_redemptions%rowtype;
440
+ v_m agent_board.board_memberships%rowtype;
441
+ begin
442
+ -- Lock the token row FIRST (concurrency invariant: the exhaustion check and the
443
+ -- same-txn uses_count increment happen under this lock, and concurrent redeemers
444
+ -- of the same token serialize here).
445
+ select * into v_inv
446
+ from agent_board.board_invite_tokens t
447
+ where t.code = p_code
448
+ for update;
449
+ if not found then
450
+ raise exception 'invite is not usable' using errcode = '42501';
451
+ end if;
452
+
453
+ -- M1 idempotency ordering: an existing (invite, agent) ledger row means this is a
454
+ -- replay -> return the existing membership and SKIP every exhaustion/ceiling check
455
+ -- (a maxed link re-redeemed by the same agent is a no-op, NOT 42501).
456
+ select * into v_led
457
+ from agent_board.board_invite_redemptions r
458
+ where r.invite_id = v_inv.id
459
+ and r.agent_id = p_agent_id;
460
+ if found then
461
+ select * into v_m
462
+ from agent_board.board_memberships bm
463
+ where bm.id = v_led.membership_id;
464
+ return query
465
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
466
+ return;
467
+ end if;
468
+
469
+ -- Genuinely new redemption: uniform 42501 for revoked / expired / exhausted
470
+ -- (no P0002-vs-42501 existence oracle).
471
+ if v_inv.revoked_at is not null then
472
+ raise exception 'invite is not usable' using errcode = '42501';
473
+ end if;
474
+ if v_inv.expires_at <= now() then
475
+ raise exception 'invite is not usable' using errcode = '42501';
476
+ end if;
477
+ if v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses then
478
+ raise exception 'invite is not usable' using errcode = '42501';
479
+ end if;
480
+
481
+ -- R-LINK-2 suspension containment: the creator must still be an ACTIVE agent
482
+ -- (revoke_agent suspends but does not drop memberships, so an outstanding link
483
+ -- from a compromised-then-suspended owner must stop minting).
484
+ if not exists (
485
+ select 1 from agent_board.agents a
486
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
487
+ ) then
488
+ -- Uniform message (see other 42501s) so a code-bearer cannot distinguish
489
+ -- "creator suspended" from a dead/expired/exhausted link (no lifecycle oracle).
490
+ raise exception 'invite is not usable' using errcode = '42501';
491
+ end if;
492
+
493
+ -- Re-bind: the creator's CURRENT authority must still cover the preset role
494
+ -- (stale admin-link auto-degrades if the creator was demoted).
495
+ if not agent_board._grant_within_ceiling(v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role) then
496
+ raise exception 'invite is not usable' using errcode = '42501';
497
+ end if;
498
+
499
+ -- R-LINK-7 kicked-agent re-entry: refuse when the agent's CURRENT standing on the
500
+ -- board is 'removed' — i.e. a 'removed' row exists AND there is no active
501
+ -- (joined/subscribed) membership. The active partial-unique index does NOT cover
502
+ -- 'removed' rows, so a fresh 'joined' insert would otherwise bypass revocation +
503
+ -- join-approval. Scoping to "removed and not currently active" avoids a false
504
+ -- negative for an agent who was removed and then legitimately re-added (an active
505
+ -- row present ⇒ the rank-max upsert handles them correctly). Voluntary 'left' is
506
+ -- intentionally NOT blocked (self-departure).
507
+ if exists (
508
+ select 1 from agent_board.board_memberships bm
509
+ where bm.board_id = v_inv.board_id
510
+ and bm.agent_id = p_agent_id
511
+ and bm.membership_state = 'removed'
512
+ ) and not exists (
513
+ select 1 from agent_board.board_memberships bm
514
+ where bm.board_id = v_inv.board_id
515
+ and bm.agent_id = p_agent_id
516
+ and bm.membership_state in ('joined', 'subscribed')
517
+ ) then
518
+ raise exception 'invite is not usable' using errcode = '42501';
519
+ end if;
520
+
521
+ -- Record the ledger row (idempotent under concurrent same-agent redemption).
522
+ insert into agent_board.board_invite_redemptions as r(invite_id, agent_id)
523
+ values (v_inv.id, p_agent_id)
524
+ on conflict (invite_id, agent_id) do nothing;
525
+
526
+ -- B1 atomic rank-max membership upsert: never downgrade a higher existing role,
527
+ -- race-safe across concurrent tokens. On conflict targets the active partial-unique
528
+ -- index; the CASE lifts ONLY when the preset outranks the current role.
529
+ insert into agent_board.board_memberships (
530
+ board_id, agent_id, membership_state, role, created_by_agent_id
531
+ )
532
+ values (
533
+ v_inv.board_id, p_agent_id, v_inv.preset_membership_state, v_inv.preset_role,
534
+ v_inv.created_by_agent_id
535
+ )
536
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
537
+ do update set
538
+ role = case
539
+ when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)
540
+ then excluded.role
541
+ else agent_board.board_memberships.role
542
+ end,
543
+ updated_at = now()
544
+ returning * into v_m;
545
+
546
+ -- The redeemer places THEMSELVES -- the public wrapper passes current_agent_id() and takes no
547
+ -- target parameter -- so this emits target-authored: redeeming a link IS the consenting act.
548
+ --
549
+ -- v_m.membership_state, NOT v_inv.preset_membership_state. The ON CONFLICT above lifts only
550
+ -- the ROLE and leaves an existing membership_state untouched, so an already-`subscribed`
551
+ -- member redeeming a `joined` link stays subscribed. Emitting the token's preset would
552
+ -- record a resulting state the row does not have -- a ledger that contradicts the table it
553
+ -- describes. Recording the redemption as a fresh consent act is right; recording the wrong
554
+ -- outcome is not.
555
+ perform agent_board._emit_membership_event(
556
+ v_inv.board_id, p_agent_id, v_m.membership_state, 'consent_link_redeem');
557
+
558
+ -- Consume one use and bind the ledger row to the resulting membership.
559
+ update agent_board.board_invite_tokens t
560
+ set uses_count = t.uses_count + 1
561
+ where t.id = v_inv.id;
562
+
563
+ update agent_board.board_invite_redemptions r
564
+ set membership_id = v_m.id
565
+ where r.invite_id = v_inv.id
566
+ and r.agent_id = p_agent_id;
567
+
568
+ return query
569
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
570
+ end;
571
+ $$;
572
+
573
+ create or replace function agent_board._fedsession_autojoin_default_board(p_provider agent_board.identity_providers, p_agent_id uuid)
574
+ RETURNS void
575
+ LANGUAGE plpgsql
576
+ SECURITY DEFINER
577
+ SET search_path TO 'agent_board', 'public'
578
+ as $$
579
+ begin
580
+ if p_provider.default_board_id is null then
581
+ return;
582
+ end if;
583
+ -- The NOT EXISTS below is FIRST-TIME-ONLY (any existing membership row, incl. 'left'/
584
+ -- 'removed', suppresses the auto-join so a human who deliberately left is not re-joined).
585
+ -- Under concurrency it is NOT sufficient on its own: two concurrent re-provisions of the
586
+ -- same already-provisioned human can both pass NOT EXISTS, and the second insert then trips
587
+ -- the partial unique index idx_agent_board_board_memberships_active_unique. Catch that
588
+ -- unique_violation to a no-op (mirrors enroll_federated_human's step-8 agent-insert race
589
+ -- handling) so auto-join is NEVER fatal to the session mint and never duplicates.
590
+ begin
591
+ insert into agent_board.board_memberships(
592
+ board_id, agent_id, role, membership_state, created_by_agent_id, metadata)
593
+ select p_provider.default_board_id, p_agent_id, 'member', 'joined', p_agent_id,
594
+ jsonb_build_object('source', 'fedsession-autojoin', 'providerId', p_provider.id)
595
+ where exists (
596
+ select 1 from agent_board.boards b
597
+ where b.id = p_provider.default_board_id
598
+ and b.status = 'active')
599
+ and not exists (
600
+ select 1 from agent_board.board_memberships m
601
+ where m.board_id = p_provider.default_board_id
602
+ and m.agent_id = p_agent_id);
603
+
604
+ -- FOUND is true only if the conditional insert actually placed a row. The NOT EXISTS can
605
+ -- suppress it entirely, and the race branch below is a no-op -- neither must emit an event
606
+ -- for a placement that did not happen.
607
+ if found then
608
+ -- A LITERAL is correct here, and only here. The other writers use ON CONFLICT DO UPDATE,
609
+ -- which can preserve a DIFFERENT existing state -- so a literal there records an outcome
610
+ -- the row does not have. This insert has NO conflict-update path: it either inserts
611
+ -- exactly this state or inserts nothing, and `found` gates the emit. Do not copy the
612
+ -- literal to a writer that upserts.
613
+ perform agent_board._emit_membership_event(p_provider.default_board_id, p_agent_id, 'joined', 'operator_designation');
614
+ end if;
615
+ exception when unique_violation then
616
+ null; -- concurrent auto-join won the race; its membership stands. Non-fatal.
617
+ end;
618
+ end;
619
+ $$;