@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.
- package/fixtures/already-applied.json +160 -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/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/package.json +1 -1
- package/schema-manifest.json +363 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -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_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +51 -5
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -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_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +45 -31
- 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_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
- 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 +27 -11
- 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/agent_board_wake_recovery.test.sql +756 -0
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -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/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -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/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -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
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MBCONSENT-B(i) — REJECTSEM: a refusal is not an eviction (message-board#89)
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
-- Reproduced on main before designing the fix, as the phase plan requires:
|
|
5
|
+
-- subject requests to join an OPEN board -> owner rejects with `removed`
|
|
6
|
+
-- -> subject can no longer join it: "board is not joinable by this agent"
|
|
7
|
+
-- One refusal was a permanent, self-unrecoverable bar on a board open to anyone.
|
|
8
|
+
--
|
|
9
|
+
-- resolve_board_request is the REFUSAL path, not an eviction path -- update_board_membership
|
|
10
|
+
-- is the schema's only eviction path. So `removed` from this RPC always mislabelled a refusal
|
|
11
|
+
-- as a removal. It now writes `left`, which is recoverable.
|
|
12
|
+
--
|
|
13
|
+
-- CORRECTED CLAIM, and the reason it matters. An earlier draft of this header asserted that
|
|
14
|
+
-- this RPC "only ever acts on a pending row". That is FALSE, and review caught it: the
|
|
15
|
+
-- resolver reads the pending row, but the shared sink independently reselects with
|
|
16
|
+
-- ACTIVE-ROW PREFERENCE and does not revalidate that its pick is still pending. With a legal
|
|
17
|
+
-- active-plus-pending duplicate -- permitted, because the uniqueness index covers only ACTIVE
|
|
18
|
+
-- rows -- rejecting the pending request downgrades the ACTIVE membership instead.
|
|
19
|
+
--
|
|
20
|
+
-- That defect is PRE-EXISTING and not introduced here: before this change the same sequence
|
|
21
|
+
-- set the active row to `removed`, which is strictly worse because `removed` also bars
|
|
22
|
+
-- re-entry. It is filed separately and belongs with the locking work, which will make the
|
|
23
|
+
-- resolver select and lock its own row rather than delegating the choice to the sink. This
|
|
24
|
+
-- migration does not claim to fix it.
|
|
25
|
+
--
|
|
26
|
+
-- The API is unchanged: callers still pass `removed` to reject. The rewrite is server-side, so
|
|
27
|
+
-- no client needs to know -- and a client that passed `left` instead would be REFUSED, because
|
|
28
|
+
-- this RPC's enum does not accept it.
|
|
29
|
+
--
|
|
30
|
+
-- The refusal remains fully durable in the ledger: this changes the membership STATE, not the
|
|
31
|
+
-- record that a rejection happened. schema_version stays 8.
|
|
32
|
+
-- =============================================================================
|
|
33
|
+
|
|
34
|
+
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)
|
|
35
|
+
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)
|
|
36
|
+
LANGUAGE plpgsql
|
|
37
|
+
SECURITY DEFINER
|
|
38
|
+
SET search_path TO 'agent_board', 'public'
|
|
39
|
+
as $$
|
|
40
|
+
declare
|
|
41
|
+
v_effective_state text;
|
|
42
|
+
v_board agent_board.boards%rowtype;
|
|
43
|
+
v_existing_state text;
|
|
44
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
45
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
46
|
+
begin
|
|
47
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
48
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
49
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
50
|
+
end if;
|
|
51
|
+
|
|
52
|
+
if p_membership_state not in ('joined', 'subscribed', 'removed') then
|
|
53
|
+
raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
|
|
54
|
+
end if;
|
|
55
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
56
|
+
if v_board.id is null then
|
|
57
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
58
|
+
end if;
|
|
59
|
+
if v_actor is not null then
|
|
60
|
+
if v_board.role_gate_enforced then
|
|
61
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
62
|
+
-- must hold an active owner/admin membership.
|
|
63
|
+
if not exists (
|
|
64
|
+
select 1
|
|
65
|
+
from agent_board.board_memberships bm
|
|
66
|
+
where bm.board_id = p_board_id
|
|
67
|
+
and bm.agent_id = v_actor
|
|
68
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
69
|
+
and bm.role in ('owner', 'admin')
|
|
70
|
+
) then
|
|
71
|
+
raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
|
|
72
|
+
end if;
|
|
73
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
74
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
75
|
+
if not exists (
|
|
76
|
+
select 1
|
|
77
|
+
from agent_board.board_memberships bm
|
|
78
|
+
where bm.board_id = p_board_id
|
|
79
|
+
and bm.agent_id = v_actor
|
|
80
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
81
|
+
) then
|
|
82
|
+
raise exception 'board request resolution requires active membership' using errcode = '42501';
|
|
83
|
+
end if;
|
|
84
|
+
end if;
|
|
85
|
+
end if;
|
|
86
|
+
|
|
87
|
+
select bm.membership_state
|
|
88
|
+
into v_existing_state
|
|
89
|
+
from agent_board.board_memberships bm
|
|
90
|
+
where bm.board_id = p_board_id
|
|
91
|
+
and bm.agent_id = p_agent_id
|
|
92
|
+
order by bm.created_at desc, bm.id desc
|
|
93
|
+
limit 1;
|
|
94
|
+
|
|
95
|
+
-- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
|
|
96
|
+
-- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
|
|
97
|
+
-- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
|
|
98
|
+
-- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
|
|
99
|
+
if coalesce(v_existing_state, '') not in ('requested', 'invited') then
|
|
100
|
+
raise exception 'pending board membership not found' using errcode = 'P0002';
|
|
101
|
+
end if;
|
|
102
|
+
|
|
103
|
+
-- REJECTSEM (#89). A rejection is NOT an eviction. resolve_board_request only ever acts on a
|
|
104
|
+
-- PENDING row (requested|invited) -- update_board_membership is the schema's only eviction
|
|
105
|
+
-- path -- so writing `removed` here mislabels a refusal as a removal, and `removed` bars
|
|
106
|
+
-- self-re-entry. Reproduced before fixing: request an OPEN board, owner rejects, and the
|
|
107
|
+
-- subject can no longer join a board open to anyone ("board is not joinable by this agent").
|
|
108
|
+
-- One refusal became a permanent, self-unrecoverable bar.
|
|
109
|
+
--
|
|
110
|
+
-- Applied to BOTH pending origins, not just `requested`. Cancelling an INVITATION had the
|
|
111
|
+
-- identical trap, and fixing only the named instance is how several defects in this work
|
|
112
|
+
-- survived a round.
|
|
113
|
+
--
|
|
114
|
+
-- `left` is recoverable: the subject may act again. The refusal is still durable in the
|
|
115
|
+
-- ledger -- this changes the membership STATE, not the record that it happened.
|
|
116
|
+
v_effective_state := case when p_membership_state = 'removed' then 'left'
|
|
117
|
+
else p_membership_state end;
|
|
118
|
+
|
|
119
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
120
|
+
p_board_id,
|
|
121
|
+
p_agent_id,
|
|
122
|
+
v_effective_state,
|
|
123
|
+
p_created_by_agent_id,
|
|
124
|
+
p_metadata
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
-- NOT relabelled here, deliberately. An earlier revision UPDATEd the freshly-emitted
|
|
128
|
+
-- ledger row to `reject_withdrawn`, so the history would say a REFUSAL rather than an
|
|
129
|
+
-- eviction. It worked -- and that is the problem. board_membership_events is append-only by
|
|
130
|
+
-- GRANT: service_role holds SELECT and INSERT and no UPDATE. The write succeeded only
|
|
131
|
+
-- because this function is SECURITY DEFINER and runs as the owner, bypassing grants
|
|
132
|
+
-- entirely. Fixing a descriptive label by punching through the property that makes the
|
|
133
|
+
-- ledger trustworthy is a worse trade than the label being wrong.
|
|
134
|
+
--
|
|
135
|
+
-- The row must instead be written with the right kind the FIRST time, which means threading
|
|
136
|
+
-- a kind through the shared sink. That changes a signature every membership route calls, so
|
|
137
|
+
-- it belongs in the gating change that is already modifying the sink -- reviewed in context,
|
|
138
|
+
-- not bolted onto this one. Filed; until then a refusal records as `eviction`. The membership
|
|
139
|
+
-- STATE is correct and recoverable, which is what #89 is about; only the label is wrong.
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
return query
|
|
143
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
144
|
+
end;
|
|
145
|
+
$$;
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MBCONSENT-B(ii) — ROUTE GATING (message-board#82) + the paired widening (#108)
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
-- The change that actually closes the hole. Everything before it was preparation: the ledger
|
|
5
|
+
-- exists (#101), every writer records to it (#104), and a refusal no longer bans (#107).
|
|
6
|
+
-- Nothing read the record. This makes it authoritative.
|
|
7
|
+
--
|
|
8
|
+
-- This migration is NOT inert. Calls that succeed today begin failing -- that is the point --
|
|
9
|
+
-- so every gate ships with a paired assertion that the legitimate path still works. A gate
|
|
10
|
+
-- that refuses everything passes every force-join test.
|
|
11
|
+
--
|
|
12
|
+
-- FIVE user-callable routes reach an active membership, not four. Every prior document said
|
|
13
|
+
-- four; redeem_invite_link was missed because it reaches membership through a different
|
|
14
|
+
-- internal function. It needs NO change here -- verified: it takes no target parameter and
|
|
15
|
+
-- passes current_agent_id(), so it is already self-only and cannot name a victim.
|
|
16
|
+
--
|
|
17
|
+
-- join_board creator exemption REMOVED (#82) + invitation admits on any
|
|
18
|
+
-- visibility (#108)
|
|
19
|
+
-- resolve_board_request promotion to active requires consent on the ledger
|
|
20
|
+
-- update_board_membership other-agent promotion into active refused
|
|
21
|
+
-- redeem_invite_link unchanged -- already self-only
|
|
22
|
+
-- create_board unchanged -- the actor IS the subject
|
|
23
|
+
--
|
|
24
|
+
-- NOT gated, verified rather than assumed: invite_board_member writes only `invited`,
|
|
25
|
+
-- request_join_board only `requested`, leave_board only `left`, and grant_board_role requires
|
|
26
|
+
-- an already-active membership and never SETs membership_state.
|
|
27
|
+
--
|
|
28
|
+
-- THE OPERATOR CARVE-OUT IS PRESERVED AND IS DELIBERATE. Every gate is conditioned on
|
|
29
|
+
-- `v_actor is not null` -- authenticated callers. service_role may still place agents, recorded
|
|
30
|
+
-- as operator-designated with a null actor and never laundered as consent. Writing the gates as
|
|
31
|
+
-- "authenticated is blocked" rather than "the actor must be the subject" would mean a caller
|
|
32
|
+
-- misclassified as operator OPENS the hole rather than closing it.
|
|
33
|
+
--
|
|
34
|
+
-- KNOWN LIMITATION, stated so the gate is not mistaken for airtight: both admission paths
|
|
35
|
+
-- CHECK CONSENT BEFORE THE ROW IS LOCKED. The sink locks only when it writes, so a subject who
|
|
36
|
+
-- withdraws between the check and the write can still be admitted -- a check-then-write race.
|
|
37
|
+
--
|
|
38
|
+
-- This narrows an open hole to a race window rather than leaving it open: before this
|
|
39
|
+
-- migration an owner could admit anyone unconditionally, with no race required. It is not
|
|
40
|
+
-- fully closed until the locking phase, which makes the resolver select and lock its own row
|
|
41
|
+
-- before reading consent. That phase also carries #109 (rejecting a pending request can
|
|
42
|
+
-- downgrade an ACTIVE membership, because the sink reselects with active-row preference) and
|
|
43
|
+
-- #110 (a refusal records as an eviction) -- all three are the same shared-function change and
|
|
44
|
+
-- are reviewed together rather than three passes over the same code.
|
|
45
|
+
--
|
|
46
|
+
-- schema_version stays 8: additive.
|
|
47
|
+
-- =============================================================================
|
|
48
|
+
|
|
49
|
+
create or replace function agent_board.join_board(p_board_id uuid, p_agent_id uuid, p_membership_state text DEFAULT 'joined'::text, p_created_by_agent_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
50
|
+
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)
|
|
51
|
+
LANGUAGE plpgsql
|
|
52
|
+
SECURITY DEFINER
|
|
53
|
+
SET search_path TO 'agent_board', 'public'
|
|
54
|
+
as $$
|
|
55
|
+
declare
|
|
56
|
+
v_board agent_board.boards%rowtype;
|
|
57
|
+
v_existing_state text;
|
|
58
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
59
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
60
|
+
begin
|
|
61
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
62
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
63
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
64
|
+
end if;
|
|
65
|
+
|
|
66
|
+
if p_membership_state not in ('joined', 'subscribed') then
|
|
67
|
+
raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
|
|
68
|
+
end if;
|
|
69
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
70
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
71
|
+
end if;
|
|
72
|
+
if p_created_by_agent_id is not null and not exists (select 1 from agent_board.agents where id = p_created_by_agent_id) then
|
|
73
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
74
|
+
end if;
|
|
75
|
+
|
|
76
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
77
|
+
if v_board.id is null then
|
|
78
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
79
|
+
end if;
|
|
80
|
+
|
|
81
|
+
-- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
|
|
82
|
+
-- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
|
|
83
|
+
-- locking an active member out of their own private / invite_only board (P0002).
|
|
84
|
+
select bm.membership_state
|
|
85
|
+
into v_existing_state
|
|
86
|
+
from agent_board.board_memberships bm
|
|
87
|
+
where bm.board_id = p_board_id
|
|
88
|
+
and bm.agent_id = p_agent_id
|
|
89
|
+
order by
|
|
90
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
91
|
+
bm.created_at desc,
|
|
92
|
+
bm.id desc
|
|
93
|
+
limit 1;
|
|
94
|
+
|
|
95
|
+
-- #82: the CREATOR EXEMPTION IS REMOVED. Previously a board's creator could join ANY agent
|
|
96
|
+
-- to their board -- `v_actor <> v_board.created_by_agent_id` let them past this guard, and
|
|
97
|
+
-- the joinability check below only runs for self-joins, so nothing else stopped them. That
|
|
98
|
+
-- is the force-join hole. Creating a board does not entitle you to place other people in it.
|
|
99
|
+
--
|
|
100
|
+
-- Only the subject may join the subject. service_role (v_actor null) keeps the operator
|
|
101
|
+
-- carve-out, which is deliberate and maintainer-confirmed.
|
|
102
|
+
if v_actor is not null and v_actor <> p_agent_id then
|
|
103
|
+
raise exception 'board membership actor mismatch' using errcode = '42501';
|
|
104
|
+
end if;
|
|
105
|
+
|
|
106
|
+
-- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
|
|
107
|
+
-- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
|
|
108
|
+
-- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
|
|
109
|
+
-- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
|
|
110
|
+
-- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
|
|
111
|
+
-- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
|
|
112
|
+
--
|
|
113
|
+
-- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
|
|
114
|
+
-- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
|
|
115
|
+
-- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
|
|
116
|
+
-- created_at can surface 'left' as the total-order winner while standing is still removed
|
|
117
|
+
-- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
|
|
118
|
+
if v_actor is not null
|
|
119
|
+
and v_actor = p_agent_id
|
|
120
|
+
and v_board.created_by_agent_id <> v_actor
|
|
121
|
+
and not (
|
|
122
|
+
not (
|
|
123
|
+
exists (
|
|
124
|
+
select 1 from agent_board.board_memberships bm
|
|
125
|
+
where bm.board_id = p_board_id
|
|
126
|
+
and bm.agent_id = p_agent_id
|
|
127
|
+
and bm.membership_state = 'removed'
|
|
128
|
+
) and not exists (
|
|
129
|
+
select 1 from agent_board.board_memberships bm
|
|
130
|
+
where bm.board_id = p_board_id
|
|
131
|
+
and bm.agent_id = p_agent_id
|
|
132
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
and (
|
|
136
|
+
v_board.visibility = 'open'
|
|
137
|
+
-- #108: HOLDING AN INVITATION ADMITS THE SUBJECT ON ANY VISIBILITY, not only
|
|
138
|
+
-- invite_only. Previously `private` and `directory_hidden` were excluded outright, so
|
|
139
|
+
-- an invited subject had NO act available -- verified: a first-time invitee with no
|
|
140
|
+
-- prior refusal was blocked identically to a refused one. Gating makes consent
|
|
141
|
+
-- REQUIRED, so a board type where consent cannot be GIVEN is correct-but-unusable.
|
|
142
|
+
--
|
|
143
|
+
-- This is the narrowest widening that makes consent exercisable. It admits nobody new:
|
|
144
|
+
-- this whole guard is reached only when v_actor = p_agent_id (the actor-mismatch raise
|
|
145
|
+
-- above returns otherwise), so it is self-accept only. The board still decides who is
|
|
146
|
+
-- invited; the subject decides whether to accept.
|
|
147
|
+
or coalesce(v_existing_state, '') = 'invited'
|
|
148
|
+
or coalesce(v_existing_state, '') in ('joined', 'subscribed')
|
|
149
|
+
)
|
|
150
|
+
) then
|
|
151
|
+
raise exception 'board is not joinable by this agent' using errcode = 'P0002';
|
|
152
|
+
end if;
|
|
153
|
+
|
|
154
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
155
|
+
p_board_id,
|
|
156
|
+
p_agent_id,
|
|
157
|
+
p_membership_state,
|
|
158
|
+
coalesce(p_created_by_agent_id, p_agent_id),
|
|
159
|
+
p_metadata
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return query
|
|
163
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
164
|
+
end;
|
|
165
|
+
$$;
|
|
166
|
+
|
|
167
|
+
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)
|
|
168
|
+
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)
|
|
169
|
+
LANGUAGE plpgsql
|
|
170
|
+
SECURITY DEFINER
|
|
171
|
+
SET search_path TO 'agent_board', 'public'
|
|
172
|
+
as $$
|
|
173
|
+
declare
|
|
174
|
+
v_effective_state text;
|
|
175
|
+
v_board agent_board.boards%rowtype;
|
|
176
|
+
v_existing_state text;
|
|
177
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
178
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
179
|
+
begin
|
|
180
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
181
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
182
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
183
|
+
end if;
|
|
184
|
+
|
|
185
|
+
if p_membership_state not in ('joined', 'subscribed', 'removed') then
|
|
186
|
+
raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
|
|
187
|
+
end if;
|
|
188
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
189
|
+
if v_board.id is null then
|
|
190
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
191
|
+
end if;
|
|
192
|
+
if v_actor is not null then
|
|
193
|
+
if v_board.role_gate_enforced then
|
|
194
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
195
|
+
-- must hold an active owner/admin membership.
|
|
196
|
+
if not exists (
|
|
197
|
+
select 1
|
|
198
|
+
from agent_board.board_memberships bm
|
|
199
|
+
where bm.board_id = p_board_id
|
|
200
|
+
and bm.agent_id = v_actor
|
|
201
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
202
|
+
and bm.role in ('owner', 'admin')
|
|
203
|
+
) then
|
|
204
|
+
raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
|
|
205
|
+
end if;
|
|
206
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
207
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
208
|
+
if not exists (
|
|
209
|
+
select 1
|
|
210
|
+
from agent_board.board_memberships bm
|
|
211
|
+
where bm.board_id = p_board_id
|
|
212
|
+
and bm.agent_id = v_actor
|
|
213
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
214
|
+
) then
|
|
215
|
+
raise exception 'board request resolution requires active membership' using errcode = '42501';
|
|
216
|
+
end if;
|
|
217
|
+
end if;
|
|
218
|
+
end if;
|
|
219
|
+
|
|
220
|
+
select bm.membership_state
|
|
221
|
+
into v_existing_state
|
|
222
|
+
from agent_board.board_memberships bm
|
|
223
|
+
where bm.board_id = p_board_id
|
|
224
|
+
and bm.agent_id = p_agent_id
|
|
225
|
+
order by bm.created_at desc, bm.id desc
|
|
226
|
+
limit 1;
|
|
227
|
+
|
|
228
|
+
-- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
|
|
229
|
+
-- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
|
|
230
|
+
-- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
|
|
231
|
+
-- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
|
|
232
|
+
if coalesce(v_existing_state, '') not in ('requested', 'invited') then
|
|
233
|
+
raise exception 'pending board membership not found' using errcode = 'P0002';
|
|
234
|
+
end if;
|
|
235
|
+
|
|
236
|
+
-- REJECTSEM (#89). A rejection is NOT an eviction. resolve_board_request only ever acts on a
|
|
237
|
+
-- PENDING row (requested|invited) -- update_board_membership is the schema's only eviction
|
|
238
|
+
-- path -- so writing `removed` here mislabels a refusal as a removal, and `removed` bars
|
|
239
|
+
-- self-re-entry. Reproduced before fixing: request an OPEN board, owner rejects, and the
|
|
240
|
+
-- subject can no longer join a board open to anyone ("board is not joinable by this agent").
|
|
241
|
+
-- One refusal became a permanent, self-unrecoverable bar.
|
|
242
|
+
--
|
|
243
|
+
-- Applied to BOTH pending origins, not just `requested`. Cancelling an INVITATION had the
|
|
244
|
+
-- identical trap, and fixing only the named instance is how several defects in this work
|
|
245
|
+
-- survived a round.
|
|
246
|
+
--
|
|
247
|
+
-- `left` is recoverable: the subject may act again. The refusal is still durable in the
|
|
248
|
+
-- ledger -- this changes the membership STATE, not the record that it happened.
|
|
249
|
+
v_effective_state := case when p_membership_state = 'removed' then 'left'
|
|
250
|
+
else p_membership_state end;
|
|
251
|
+
|
|
252
|
+
-- #82: PROMOTION INTO AN ACTIVE STATE REQUIRES THE SUBJECT'S OWN ACT. This is the sole
|
|
253
|
+
-- consent-predicate read site. An owner resolving a pending row to joined|subscribed is a
|
|
254
|
+
-- BOARD act; it admits the subject only if the subject has already consented -- by requesting
|
|
255
|
+
-- (target-authored) or by an operator placement. A board-authored `invited` or a manufactured
|
|
256
|
+
-- `requested` is NEUTRAL on the ledger and does not admit anyone.
|
|
257
|
+
--
|
|
258
|
+
-- Authenticated only. service_role keeps the operator carve-out.
|
|
259
|
+
if v_actor is not null
|
|
260
|
+
and v_effective_state in ('joined', 'subscribed')
|
|
261
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
262
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
263
|
+
using errcode = '42501';
|
|
264
|
+
end if;
|
|
265
|
+
|
|
266
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
267
|
+
p_board_id,
|
|
268
|
+
p_agent_id,
|
|
269
|
+
v_effective_state,
|
|
270
|
+
p_created_by_agent_id,
|
|
271
|
+
p_metadata
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
-- NOT relabelled here, deliberately. An earlier revision UPDATEd the freshly-emitted
|
|
275
|
+
-- ledger row to `reject_withdrawn`, so the history would say a REFUSAL rather than an
|
|
276
|
+
-- eviction. It worked -- and that is the problem. board_membership_events is append-only by
|
|
277
|
+
-- GRANT: service_role holds SELECT and INSERT and no UPDATE. The write succeeded only
|
|
278
|
+
-- because this function is SECURITY DEFINER and runs as the owner, bypassing grants
|
|
279
|
+
-- entirely. Fixing a descriptive label by punching through the property that makes the
|
|
280
|
+
-- ledger trustworthy is a worse trade than the label being wrong.
|
|
281
|
+
--
|
|
282
|
+
-- The row must instead be written with the right kind the FIRST time, which means threading
|
|
283
|
+
-- a kind through the shared sink. That changes a signature every membership route calls, so
|
|
284
|
+
-- it belongs in the gating change that is already modifying the sink -- reviewed in context,
|
|
285
|
+
-- not bolted onto this one. Filed; until then a refusal records as `eviction`. The membership
|
|
286
|
+
-- STATE is correct and recoverable, which is what #89 is about; only the label is wrong.
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
return query
|
|
290
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
291
|
+
end;
|
|
292
|
+
$$;
|
|
293
|
+
|
|
294
|
+
create or replace function agent_board.update_board_membership(p_board_id uuid, p_agent_id uuid, p_membership_state text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
295
|
+
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)
|
|
296
|
+
LANGUAGE plpgsql
|
|
297
|
+
SECURITY DEFINER
|
|
298
|
+
SET search_path TO 'agent_board', 'public'
|
|
299
|
+
as $$
|
|
300
|
+
declare
|
|
301
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
302
|
+
v_board agent_board.boards%rowtype;
|
|
303
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
304
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
305
|
+
v_existing_state text;
|
|
306
|
+
v_target_role text;
|
|
307
|
+
v_actor_role text;
|
|
308
|
+
begin
|
|
309
|
+
-- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
|
|
310
|
+
-- cannot be used as a membership-existence oracle.
|
|
311
|
+
if v_actor is null and not v_is_service then
|
|
312
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
313
|
+
end if;
|
|
314
|
+
|
|
315
|
+
-- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
|
|
316
|
+
-- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
|
|
317
|
+
-- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
|
|
318
|
+
-- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
|
|
319
|
+
-- own membership, and the service path is the operator.)
|
|
320
|
+
if not v_is_service and v_actor <> p_agent_id then
|
|
321
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
322
|
+
if v_board.id is null then
|
|
323
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
324
|
+
end if;
|
|
325
|
+
select bm.role
|
|
326
|
+
into v_actor_role
|
|
327
|
+
from agent_board.board_memberships bm
|
|
328
|
+
where bm.board_id = p_board_id
|
|
329
|
+
and bm.agent_id = v_actor
|
|
330
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
331
|
+
limit 1;
|
|
332
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
333
|
+
raise exception 'board membership change requires owner or admin role'
|
|
334
|
+
using errcode = '42501';
|
|
335
|
+
end if;
|
|
336
|
+
end if;
|
|
337
|
+
|
|
338
|
+
-- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
|
|
339
|
+
-- rank check below.
|
|
340
|
+
-- MBROWID (3a): prefer ACTIVE standing. Bare total-order can surface a high-id REMOVED
|
|
341
|
+
-- row while a low-id JOINED row exists, so self-service would reject a legitimate
|
|
342
|
+
-- joined->joined toggle with 42501 'self membership change not permitted'.
|
|
343
|
+
select bm.membership_state, bm.role
|
|
344
|
+
into v_existing_state, v_target_role
|
|
345
|
+
from agent_board.board_memberships bm
|
|
346
|
+
where bm.board_id = p_board_id
|
|
347
|
+
and bm.agent_id = p_agent_id
|
|
348
|
+
order by
|
|
349
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
350
|
+
bm.created_at desc,
|
|
351
|
+
bm.id desc
|
|
352
|
+
limit 1;
|
|
353
|
+
if v_existing_state is null then
|
|
354
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
355
|
+
end if;
|
|
356
|
+
|
|
357
|
+
-- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
|
|
358
|
+
-- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
|
|
359
|
+
if not v_is_service then
|
|
360
|
+
if v_actor = p_agent_id then
|
|
361
|
+
-- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
|
|
362
|
+
-- for the authority checks below, and requiring an ACTIVE board here would strand an agent
|
|
363
|
+
-- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
|
|
364
|
+
-- delegates straight into this RPC, 20260512000002:154-158).
|
|
365
|
+
--
|
|
366
|
+
-- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
|
|
367
|
+
-- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
|
|
368
|
+
-- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
|
|
369
|
+
-- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
|
|
370
|
+
-- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
|
|
371
|
+
-- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
|
|
372
|
+
--
|
|
373
|
+
-- LEAVEIDEM (#81): a same-state TERMINAL call is additionally permitted. It is a no-op
|
|
374
|
+
-- (returned unchanged at step 5), so it neither erases nor refreshes the marker — it simply
|
|
375
|
+
-- restores idempotence to leave_board without widening the transition set.
|
|
376
|
+
if not (
|
|
377
|
+
(p_membership_state = 'left'
|
|
378
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
379
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
380
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
381
|
+
or (p_membership_state = v_existing_state
|
|
382
|
+
and p_membership_state in ('left', 'removed'))
|
|
383
|
+
) then
|
|
384
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
385
|
+
using errcode = '42501';
|
|
386
|
+
end if;
|
|
387
|
+
else
|
|
388
|
+
-- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
|
|
389
|
+
-- requirement already ran in step (2) (authority before disclosure). This RPC is the
|
|
390
|
+
-- schema's only eviction path — no sibling can move a JOINED member to removed
|
|
391
|
+
-- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
|
|
392
|
+
-- gated more strictly than its siblings, with NO creator exemption: a bare
|
|
393
|
+
-- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
|
|
394
|
+
-- membership at create_board time (20260723000100), so a legitimate creator holds one, while
|
|
395
|
+
-- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
|
|
396
|
+
--
|
|
397
|
+
-- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
|
|
398
|
+
-- could evict the OWNER, or admins could evict each other.
|
|
399
|
+
-- The rank comparison must use the EFFECTIVE POST-TRANSITION role, not the stale retained
|
|
400
|
+
-- one. Roles are preserved on a `left`/`removed` row, and the sink CLAMPS any
|
|
401
|
+
-- non-active -> active admission back to 'member' (MBROLERESET). So a departed OWNER who
|
|
402
|
+
-- genuinely requests re-entry was compared at rank owner <= owner and refused -- while the
|
|
403
|
+
-- role they would actually receive is `member`. That blocked a CONSENTING subject from
|
|
404
|
+
-- being re-admitted, which is this phase's own EC-2, and it is the allow-side failure the
|
|
405
|
+
-- deny side cannot see.
|
|
406
|
+
--
|
|
407
|
+
-- For an admission the effective role is what the clamp will produce; for every other
|
|
408
|
+
-- transition the retained role stands, so eviction and demotion still require strict rank
|
|
409
|
+
-- superiority over the role the target actually holds.
|
|
410
|
+
if agent_board._role_rank(v_actor_role)
|
|
411
|
+
<= agent_board._role_rank(
|
|
412
|
+
case
|
|
413
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
414
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
415
|
+
then 'member'
|
|
416
|
+
else coalesce(v_target_role, '')
|
|
417
|
+
end) then
|
|
418
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
419
|
+
using errcode = '42501';
|
|
420
|
+
end if;
|
|
421
|
+
|
|
422
|
+
-- #82: an owner or admin may EVICT or demote another agent, but may not ADMIT one.
|
|
423
|
+
-- Moving someone into joined|subscribed is exactly the force-join this phase closes, and
|
|
424
|
+
-- no authority short of the subject's own act permits it. Manufacturing a `requested` row
|
|
425
|
+
-- stays allowed -- it is neutral on the ledger and admits nobody, and the promotion that
|
|
426
|
+
-- would follow is refused at resolve_board_request's predicate read.
|
|
427
|
+
--
|
|
428
|
+
-- service_role retains update-other promotion as an operator placement.
|
|
429
|
+
-- The rule is ADMISSION REQUIRES CONSENT, not "no promotion ever". Three cases:
|
|
430
|
+
--
|
|
431
|
+
-- * already ACTIVE -> active (joined <-> subscribed): NOT an admission. The subject is
|
|
432
|
+
-- already a member and consented to be; changing which active state they hold admits
|
|
433
|
+
-- nobody. Refusing it broke a legitimate owner flow.
|
|
434
|
+
-- * NOT active -> active WITH consent on the ledger: allowed. The subject asked, or an
|
|
435
|
+
-- operator placed them. An owner acting on that is an approval, not a force-join.
|
|
436
|
+
-- * NOT active -> active WITHOUT consent: refused. This is #82.
|
|
437
|
+
--
|
|
438
|
+
-- An earlier revision refused ALL other-agent promotion regardless of consent or origin.
|
|
439
|
+
-- That contradicted this phase's own acceptance criterion (an owner may promote a
|
|
440
|
+
-- CONSENTING subject) and over-blocked active->active. Deny-side correctness is worthless
|
|
441
|
+
-- if it takes the allow side with it.
|
|
442
|
+
if p_membership_state in ('joined', 'subscribed')
|
|
443
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
444
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
445
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
446
|
+
using errcode = '42501';
|
|
447
|
+
end if;
|
|
448
|
+
end if;
|
|
449
|
+
end if;
|
|
450
|
+
|
|
451
|
+
-- (5) LEAVEIDEM (#81): a same-state TERMINAL call is a TRUE no-op — return the row untouched
|
|
452
|
+
-- rather than re-entering the mutator, which would restamp left_at and re-merge metadata.
|
|
453
|
+
-- Placed AFTER the authorization block on purpose: returning early before step (2)/(4) would
|
|
454
|
+
-- hand an UNAUTHORIZED caller a successful no-op instead of 42501, turning this RPC into a
|
|
455
|
+
-- membership-state oracle for arbitrary (board, agent) pairs. Authority is still required; the
|
|
456
|
+
-- entitled caller simply gets an unchanged row.
|
|
457
|
+
-- Scoped to the AUTHENTICATED path on purpose: a service_role/operator caller re-asserting a
|
|
458
|
+
-- terminal state may legitimately be attaching an annotation via p_metadata (CLI --metadata), and
|
|
459
|
+
-- silently dropping it would be a lossy success. The operator falls through to the mutator, which
|
|
460
|
+
-- now preserves left_at for an UNCHANGED terminal state anyway — so the operator keeps the
|
|
461
|
+
-- metadata merge without reacquiring the restamp defect.
|
|
462
|
+
if not v_is_service
|
|
463
|
+
and p_membership_state = v_existing_state
|
|
464
|
+
and p_membership_state in ('left', 'removed') then
|
|
465
|
+
-- MBROWID (3a): same active-preferring total-order as the authz pre-read above so
|
|
466
|
+
-- the no-op return row agrees with the state used for authorization.
|
|
467
|
+
return query
|
|
468
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
469
|
+
from agent_board.board_memberships bm
|
|
470
|
+
where bm.board_id = p_board_id
|
|
471
|
+
and bm.agent_id = p_agent_id
|
|
472
|
+
order by
|
|
473
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
474
|
+
bm.created_at desc,
|
|
475
|
+
bm.id desc
|
|
476
|
+
limit 1;
|
|
477
|
+
return;
|
|
478
|
+
end if;
|
|
479
|
+
|
|
480
|
+
select * into v_membership
|
|
481
|
+
from agent_board._upsert_board_membership_state(
|
|
482
|
+
p_board_id,
|
|
483
|
+
p_agent_id,
|
|
484
|
+
p_membership_state,
|
|
485
|
+
null,
|
|
486
|
+
p_metadata
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
return query
|
|
490
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
491
|
+
end;
|
|
492
|
+
$$;
|