@consiliency/agent-board-schema 1.10.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +136 -0
- package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
- package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
- package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +309 -3
- package/src/tooling.test.mjs +123 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_gatenull_membstate.test.sql +352 -0
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -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
|
+
$$;
|