@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,1201 @@
|
|
|
1
|
+
-- MBROWID — deterministic board-membership row identity (total order + R-LINK-7 bootstrap).
|
|
2
|
+
-- Closes the remaining third of message-board#79 (bootstrap resurrection of a removed
|
|
3
|
+
-- creator: "2 rows, 1 active") and freezes every latest-row membership reader on a total
|
|
4
|
+
-- order so equal-created_at duplicates cannot be read differently by different defenses.
|
|
5
|
+
--
|
|
6
|
+
-- THEME: board_memberships has no single authoritative row per (board_id, agent_id). The
|
|
7
|
+
-- active-membership unique index is PARTIAL —
|
|
8
|
+
-- idx_agent_board_board_memberships_active_unique on (board_id, agent_id) WHERE
|
|
9
|
+
-- membership_state in ('joined','subscribed') (20260505000000:34-36) — so a raw INSERT
|
|
10
|
+
-- onto a NON-ACTIVE row creates a SECOND row instead of conflicting. Latest-row readers
|
|
11
|
+
-- previously used `order by created_at desc limit 1`; created_at defaults to
|
|
12
|
+
-- transaction_timestamp() and is equal for same-txn writes, so the tie broke
|
|
13
|
+
-- non-deterministically. Two defenses reading the same pair could disagree.
|
|
14
|
+
--
|
|
15
|
+
-- WHAT THIS MIGRATION DOES
|
|
16
|
+
-- 1. Total-order every live latest-row reader: `order by created_at desc, id desc`
|
|
17
|
+
-- (per-agent reads: `agent_id, created_at desc, id desc`). Catalog-derived inventory
|
|
18
|
+
-- (8 functions / 10 sites + v_board_directory):
|
|
19
|
+
-- _active_board_membership_id (20260506000000 last-definer)
|
|
20
|
+
-- _upsert_board_membership_state (20260802000100 last-definer) — 1 site
|
|
21
|
+
-- join_board (20260730000100 last-definer)
|
|
22
|
+
-- resolve_board_request (20260730000100 last-definer)
|
|
23
|
+
-- update_board_membership (20260801000100 last-definer) — 2 sites
|
|
24
|
+
-- list_board_pending_memberships (20260727000100 last-definer) — 2 sites
|
|
25
|
+
-- create_tasking_delivery (20260718000200 last-definer; tasks idempotency
|
|
26
|
+
-- site counted by COUNT-EQUALITY structural proof)
|
|
27
|
+
-- post_board_message (20260718000200 last-definer; messages idempotency
|
|
28
|
+
-- site counted by COUNT-EQUALITY structural proof)
|
|
29
|
+
-- v_board_directory (DROP+CREATE last definer 20260515000000:397-444)
|
|
30
|
+
-- Bodies are copied forward from the LIVE catalog (pg_get_functiondef /
|
|
31
|
+
-- pg_get_viewdef), not from older migration files. create or replace preserves
|
|
32
|
+
-- grants; the view is drop + create so SELECT is re-granted.
|
|
33
|
+
--
|
|
34
|
+
-- 2. bootstrap_board_owner_memberships R-LINK-7 guard (20260723000100 last-definer):
|
|
35
|
+
-- re-seed only when there is NO active row AND NO removed row. NEVER suppress on
|
|
36
|
+
-- 'left' alone — that is the sole-owner-lockout recovery trigger pinned by assertion
|
|
37
|
+
-- 23 of tests/agent_board_rolereset.test.sql. requested-only and invited-only
|
|
38
|
+
-- creators ARE still re-seeded; a removed+left pair is suppressed.
|
|
39
|
+
--
|
|
40
|
+
-- 3a. Duplicate-aware ACTIVE-row selection at EVERY acting read site (not just the
|
|
41
|
+
-- mutator). A pure `order by created_at desc, id desc` can select a higher-id
|
|
42
|
+
-- REMOVED (or REQUESTED) row while an ACTIVE row still exists. Every reader that
|
|
43
|
+
-- then ACTS on the resulting state must prefer active first, then total-order:
|
|
44
|
+
-- order by case when membership_state in ('joined','subscribed') then 0 else 1 end,
|
|
45
|
+
-- created_at desc, id desc
|
|
46
|
+
-- Sites that now prefer active (when one exists), then total-order:
|
|
47
|
+
-- _upsert_board_membership_state — FOR UPDATE pre-read (avoids 23505)
|
|
48
|
+
-- join_board — joinability / idempotent-join state read
|
|
49
|
+
-- update_board_membership — both sites (authz pre-read + LEAVEIDEM return)
|
|
50
|
+
-- list_board_pending_memberships — distinct-on-per-agent selection
|
|
51
|
+
-- v_board_directory — current_membership lateral
|
|
52
|
+
-- Currently-removed standing (mutator + join_board self-join guard) uses the
|
|
53
|
+
-- R-LINK-7 SET predicate (exists removed AND not exists active), not a single row.
|
|
54
|
+
--
|
|
55
|
+
-- 3b. join_board joinability agrees with mutator + grant_board_role on removed+left
|
|
56
|
+
-- histories: the self-join guard uses the R-LINK-7 set predicate for "removed
|
|
57
|
+
-- standing", not just the total-ordered latest row's membership_state. Otherwise a
|
|
58
|
+
-- higher-UUID left row would admit open-board self-join while standing is removed.
|
|
59
|
+
-- Combined with 3a, a low-id ACTIVE + high-id REMOVED pair is treated as active
|
|
60
|
+
-- for idempotent join on private / invite_only boards (the active member is not
|
|
61
|
+
-- locked out of their own board).
|
|
62
|
+
--
|
|
63
|
+
-- 3c. Bare total-order retained (deliberately) where the reader is NOT acting on
|
|
64
|
+
-- "current standing" but on newest row regardless of state, or where active is
|
|
65
|
+
-- already filtered by the WHERE clause:
|
|
66
|
+
-- resolve_board_request — pending-only gate: must see the newest
|
|
67
|
+
-- requested/invited marker, not prefer a
|
|
68
|
+
-- coexisting active (approval path is for
|
|
69
|
+
-- non-active pending rows; active standing
|
|
70
|
+
-- is already joinable via join_board)
|
|
71
|
+
-- _active_board_membership_id — WHERE already restricts to joined|subscribed
|
|
72
|
+
-- create_tasking_delivery / post_board_message — task/message idempotency keys,
|
|
73
|
+
-- not membership standing
|
|
74
|
+
--
|
|
75
|
+
-- FOUR RAW INSERT WRITERS — per-writer ruling (IF-0-MBROLERESET-1(c) / EC-MBROWID-4):
|
|
76
|
+
-- 1. create_board owner seed (20260723000100) — KEEP RAW.
|
|
77
|
+
-- Runs only on a genuine board insert (if v_did_insert); no prior membership can
|
|
78
|
+
-- exist. on conflict (active) do update covers the slug-collision active case.
|
|
79
|
+
-- 2. bootstrap_board_owner_memberships (this migration) — KEEP RAW; FIX THE GUARD
|
|
80
|
+
-- to the R-LINK-7 shape above. The only raw writer that manufactured the harmful
|
|
81
|
+
-- "2 rows, 1 active" resurrection.
|
|
82
|
+
-- 3. _redeem_invite_for_agent (20260724000100) — KEEP RAW.
|
|
83
|
+
-- R-LINK-7 guard already refuses removed; on conflict (active) rank-max; left gets
|
|
84
|
+
-- the normal left+joined pair, never a second active row.
|
|
85
|
+
-- 4. _fedsession_autojoin_default_board (20260729000100) — KEEP RAW (benign).
|
|
86
|
+
-- not exists (any row) guard + exception when unique_violation then null.
|
|
87
|
+
-- None of the four route through _upsert_board_membership_state: the mutator clamps
|
|
88
|
+
-- role to 'member' on non-active→active re-entry (ROLERESET), which would erase
|
|
89
|
+
-- writer 1's owner seed and writer 3's admin preset.
|
|
90
|
+
--
|
|
91
|
+
-- WHY A TRIGGER IS FORBIDDEN: a BEFORE INSERT/UPDATE trigger on board_memberships
|
|
92
|
+
-- would also fire for the intentional non-default seeders (create_board owner,
|
|
93
|
+
-- bootstrap owner, invite preset_role, fedsession member) and erase their intentional
|
|
94
|
+
-- roles. The clamp and total-order live in the mutator / readers only.
|
|
95
|
+
--
|
|
96
|
+
-- Non-goals: no drop/widen of the partial unique index; no delete/merge/renumber of
|
|
97
|
+
-- existing rows (delivery_recipients, callback_subscriptions, board_invite_redemptions
|
|
98
|
+
-- pin membership row identity via FK); schema_version stays 8.
|
|
99
|
+
--
|
|
100
|
+
-- Additive, forward-only. Last-definer rule: only this file is live for the recreated
|
|
101
|
+
-- objects after apply.
|
|
102
|
+
|
|
103
|
+
-- 1. _active_board_membership_id ---------------------------------------------------------
|
|
104
|
+
CREATE OR REPLACE FUNCTION agent_board._active_board_membership_id(p_board_id uuid, p_agent_id uuid)
|
|
105
|
+
RETURNS uuid
|
|
106
|
+
LANGUAGE sql
|
|
107
|
+
STABLE
|
|
108
|
+
AS $$
|
|
109
|
+
select bm.id
|
|
110
|
+
from agent_board.board_memberships bm
|
|
111
|
+
where bm.board_id = p_board_id
|
|
112
|
+
and bm.agent_id = p_agent_id
|
|
113
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
114
|
+
order by bm.created_at desc, bm.id desc
|
|
115
|
+
limit 1
|
|
116
|
+
$$;
|
|
117
|
+
-- 2. _upsert_board_membership_state (total order + active-prefer + R-LINK-7 set) ---------
|
|
118
|
+
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)
|
|
119
|
+
RETURNS agent_board.board_memberships
|
|
120
|
+
LANGUAGE plpgsql
|
|
121
|
+
SECURITY DEFINER
|
|
122
|
+
SET search_path TO 'agent_board', 'public'
|
|
123
|
+
AS $$
|
|
124
|
+
declare
|
|
125
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
126
|
+
v_metadata jsonb;
|
|
127
|
+
-- EC-MBROWID-3: currently-removed standing (R-LINK-7 set form), not a single row.
|
|
128
|
+
v_currently_removed boolean;
|
|
129
|
+
begin
|
|
130
|
+
if p_membership_state not in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed') then
|
|
131
|
+
raise exception 'invalid board membership state' using errcode = '22023';
|
|
132
|
+
end if;
|
|
133
|
+
|
|
134
|
+
-- Metadata must be a JSON OBJECT. Previously a scalar/array was stored verbatim on insert and
|
|
135
|
+
-- concatenated on update; the reserved-key strip below iterates keys, so a non-object would raise
|
|
136
|
+
-- an opaque internal error. Fail explicitly instead. Reachable from the CLI `--metadata`, which
|
|
137
|
+
-- parses arbitrary JSON — this is a deliberate, declared behavior change for ALL mutator callers.
|
|
138
|
+
if jsonb_typeof(coalesce(p_metadata, '{}'::jsonb)) <> 'object' then
|
|
139
|
+
raise exception 'board membership metadata must be a JSON object' using errcode = '22023';
|
|
140
|
+
end if;
|
|
141
|
+
|
|
142
|
+
-- Reserved namespace: `ops_*` keys are operational state (the retention sweep's skip marker
|
|
143
|
+
-- `ops_retained_at`, read for board_memberships at 20260513000001:122) and must never be writable
|
|
144
|
+
-- by a caller whose own row they govern. Strip them from caller input; operational writers set
|
|
145
|
+
-- the column directly.
|
|
146
|
+
select coalesce(jsonb_object_agg(e.key, e.value), '{}'::jsonb)
|
|
147
|
+
into v_metadata
|
|
148
|
+
from jsonb_each(coalesce(p_metadata, '{}'::jsonb)) as e(key, value)
|
|
149
|
+
where e.key not like 'ops\_%';
|
|
150
|
+
|
|
151
|
+
-- EC-MBROWID-3 / R-LINK-7: currently-removed standing is the SET predicate
|
|
152
|
+
-- (exists removed AND no active), not a single total-ordered row. A higher-id
|
|
153
|
+
-- removed row can coexist with an active row; inferring "removed" from the
|
|
154
|
+
-- total-order winner alone would then try to activate the removed row and
|
|
155
|
+
-- collide with idx_agent_board_board_memberships_active_unique (23505).
|
|
156
|
+
-- Capture standing before the row lock so the predicate is independent of
|
|
157
|
+
-- which physical row the active-preferring SELECT below returns.
|
|
158
|
+
v_currently_removed :=
|
|
159
|
+
exists (
|
|
160
|
+
select 1 from agent_board.board_memberships bm
|
|
161
|
+
where bm.board_id = p_board_id
|
|
162
|
+
and bm.agent_id = p_agent_id
|
|
163
|
+
and bm.membership_state = 'removed'
|
|
164
|
+
) and not exists (
|
|
165
|
+
select 1 from agent_board.board_memberships bm
|
|
166
|
+
where bm.board_id = p_board_id
|
|
167
|
+
and bm.agent_id = p_agent_id
|
|
168
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
-- Duplicate-aware ACTIVE-row selection (3a): prefer an active row when one
|
|
172
|
+
-- exists, then total-order by created_at desc, id desc. A pure total-order
|
|
173
|
+
-- can select a higher-id REMOVED row while an ACTIVE row still exists.
|
|
174
|
+
-- When v_currently_removed, no active row exists and the total-order pick is
|
|
175
|
+
-- non-active (re-entry clamp applies below).
|
|
176
|
+
select *
|
|
177
|
+
into v_membership
|
|
178
|
+
from agent_board.board_memberships bm
|
|
179
|
+
where bm.board_id = p_board_id
|
|
180
|
+
and bm.agent_id = p_agent_id
|
|
181
|
+
order by
|
|
182
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
183
|
+
bm.created_at desc,
|
|
184
|
+
bm.id desc
|
|
185
|
+
limit 1
|
|
186
|
+
for update;
|
|
187
|
+
|
|
188
|
+
if v_membership.id is null then
|
|
189
|
+
-- INSERT branch intentionally omits `role` so a no-prior-row entry takes the table default
|
|
190
|
+
-- (20260717000100:13-15). Do not write role here — an explicit write is wrong.
|
|
191
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, left_at, metadata)
|
|
192
|
+
values (
|
|
193
|
+
p_board_id,
|
|
194
|
+
p_agent_id,
|
|
195
|
+
p_membership_state,
|
|
196
|
+
p_created_by_agent_id,
|
|
197
|
+
case when p_membership_state in ('left', 'removed') then now() else null end,
|
|
198
|
+
v_metadata
|
|
199
|
+
)
|
|
200
|
+
returning * into v_membership;
|
|
201
|
+
else
|
|
202
|
+
update agent_board.board_memberships bm
|
|
203
|
+
set membership_state = p_membership_state,
|
|
204
|
+
created_by_agent_id = coalesce(p_created_by_agent_id, bm.created_by_agent_id),
|
|
205
|
+
-- PRESERVE the first departure time. Restamping it would let the departing agent (or a
|
|
206
|
+
-- re-kicking owner) push its own row past the retention cutoff (20260513000001:121).
|
|
207
|
+
left_at = case
|
|
208
|
+
when p_membership_state in ('left', 'removed') then
|
|
209
|
+
case
|
|
210
|
+
-- SAME terminal state re-asserted: preserve the FIRST departure time so a
|
|
211
|
+
-- repeat call cannot push the retention cutoff forward.
|
|
212
|
+
when v_membership.membership_state = p_membership_state
|
|
213
|
+
then coalesce(v_membership.left_at, now())
|
|
214
|
+
-- GENUINE transition (notably left -> removed, an owner barring
|
|
215
|
+
-- invite-link re-entry for an already-departed agent) stamps NOW. R-LINK-7
|
|
216
|
+
-- deliberately does not block voluntary 'left' (20260724000100:321-322),
|
|
217
|
+
-- so this is the real kick moment; inheriting the earlier departure would
|
|
218
|
+
-- both lose the audit answer and make the marker row retention-eligible
|
|
219
|
+
-- prematurely.
|
|
220
|
+
else now()
|
|
221
|
+
end
|
|
222
|
+
else null
|
|
223
|
+
end,
|
|
224
|
+
-- ROLERESET (#79): clamp role to the table default on non-active -> active re-entry.
|
|
225
|
+
-- Hardcoded 'member' (column default at 20260717000100:13-15); do not read
|
|
226
|
+
-- information_schema at runtime. Active -> active preserves bm.role (including owner).
|
|
227
|
+
-- Unconditional — no service_role exemption.
|
|
228
|
+
-- MBROWID: origin is non-active when the selected row is non-active OR when
|
|
229
|
+
-- currently-removed standing holds (R-LINK-7 set form). With active-preferring
|
|
230
|
+
-- selection those agree; the set form is load-bearing against equal-created_at
|
|
231
|
+
-- removed+left duplicates where a single-row read would flip with id order.
|
|
232
|
+
role = case
|
|
233
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
234
|
+
and (
|
|
235
|
+
v_membership.membership_state not in ('joined', 'subscribed')
|
|
236
|
+
or v_currently_removed
|
|
237
|
+
)
|
|
238
|
+
then 'member'
|
|
239
|
+
else bm.role
|
|
240
|
+
end,
|
|
241
|
+
metadata = coalesce(bm.metadata, '{}'::jsonb) || v_metadata,
|
|
242
|
+
updated_at = now()
|
|
243
|
+
where bm.id = v_membership.id
|
|
244
|
+
returning * into v_membership;
|
|
245
|
+
end if;
|
|
246
|
+
|
|
247
|
+
return v_membership;
|
|
248
|
+
end;
|
|
249
|
+
$$;
|
|
250
|
+
-- 3. bootstrap_board_owner_memberships (R-LINK-7 guard) ---------------------------------
|
|
251
|
+
CREATE OR REPLACE FUNCTION agent_board.bootstrap_board_owner_memberships()
|
|
252
|
+
RETURNS TABLE(board_id uuid, owner_agent_id uuid, membership_id uuid)
|
|
253
|
+
LANGUAGE plpgsql
|
|
254
|
+
SECURITY DEFINER
|
|
255
|
+
SET search_path TO 'agent_board', 'public'
|
|
256
|
+
AS $$
|
|
257
|
+
#variable_conflict use_column
|
|
258
|
+
declare
|
|
259
|
+
v_row record;
|
|
260
|
+
v_membership_id uuid;
|
|
261
|
+
begin
|
|
262
|
+
-- service_role-only: current_agent_id() is null for service_role, non-null for any
|
|
263
|
+
-- authenticated principal (guarded here in addition to the EXECUTE revoke below).
|
|
264
|
+
if not agent_board._is_service_role() then
|
|
265
|
+
raise exception 'bootstrap_board_owner_memberships is service_role-only' using errcode = '42501';
|
|
266
|
+
end if;
|
|
267
|
+
|
|
268
|
+
-- R-LINK-7 guard shape (structural proof requires no-active AND no-removed
|
|
269
|
+
-- contiguously): re-seed only when the creator has no active membership AND is
|
|
270
|
+
-- not currently removed. NEVER suppress on 'left' alone — that is the
|
|
271
|
+
-- sole-owner-lockout recovery trigger (agent_board_rolereset.test.sql assertion
|
|
272
|
+
-- 23). requested/invited-only creators still re-seed; a removed+left pair is
|
|
273
|
+
-- suppressed.
|
|
274
|
+
for v_row in
|
|
275
|
+
select b.id as board_id, b.created_by_agent_id as owner_agent_id
|
|
276
|
+
from agent_board.boards b
|
|
277
|
+
where not exists (
|
|
278
|
+
select 1
|
|
279
|
+
from agent_board.board_memberships bm
|
|
280
|
+
where bm.board_id = b.id
|
|
281
|
+
and bm.agent_id = b.created_by_agent_id
|
|
282
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
283
|
+
)
|
|
284
|
+
and not exists (
|
|
285
|
+
select 1
|
|
286
|
+
from agent_board.board_memberships bm
|
|
287
|
+
where bm.board_id = b.id
|
|
288
|
+
and bm.agent_id = b.created_by_agent_id
|
|
289
|
+
and bm.membership_state = 'removed'
|
|
290
|
+
)
|
|
291
|
+
loop
|
|
292
|
+
insert into agent_board.board_memberships as bm(board_id, agent_id, membership_state, role, created_by_agent_id)
|
|
293
|
+
values (v_row.board_id, v_row.owner_agent_id, 'joined', 'owner', v_row.owner_agent_id)
|
|
294
|
+
on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
|
|
295
|
+
do update set role = 'owner', updated_at = now()
|
|
296
|
+
returning bm.id into v_membership_id;
|
|
297
|
+
|
|
298
|
+
board_id := v_row.board_id;
|
|
299
|
+
owner_agent_id := v_row.owner_agent_id;
|
|
300
|
+
membership_id := v_membership_id;
|
|
301
|
+
return next;
|
|
302
|
+
end loop;
|
|
303
|
+
end;
|
|
304
|
+
$$;
|
|
305
|
+
-- 4. join_board (active-prefer + total order + R-LINK-7 joinability) --------------------
|
|
306
|
+
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)
|
|
307
|
+
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)
|
|
308
|
+
LANGUAGE plpgsql
|
|
309
|
+
SECURITY DEFINER
|
|
310
|
+
SET search_path TO 'agent_board', 'public'
|
|
311
|
+
AS $$
|
|
312
|
+
declare
|
|
313
|
+
v_board agent_board.boards%rowtype;
|
|
314
|
+
v_existing_state text;
|
|
315
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
316
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
317
|
+
begin
|
|
318
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
319
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
320
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
321
|
+
end if;
|
|
322
|
+
|
|
323
|
+
if p_membership_state not in ('joined', 'subscribed') then
|
|
324
|
+
raise exception 'join_board only accepts joined or subscribed' using errcode = '22023';
|
|
325
|
+
end if;
|
|
326
|
+
if not exists (select 1 from agent_board.agents where id = p_agent_id) then
|
|
327
|
+
raise exception 'agent not found' using errcode = 'P0002';
|
|
328
|
+
end if;
|
|
329
|
+
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
|
|
330
|
+
raise exception 'creator agent not found' using errcode = 'P0002';
|
|
331
|
+
end if;
|
|
332
|
+
|
|
333
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
334
|
+
if v_board.id is null then
|
|
335
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
336
|
+
end if;
|
|
337
|
+
|
|
338
|
+
-- MBROWID (3a): prefer ACTIVE standing for joinability / idempotent-join. A bare
|
|
339
|
+
-- total-order can surface a high-id REMOVED row while a low-id JOINED row exists,
|
|
340
|
+
-- locking an active member out of their own private / invite_only board (P0002).
|
|
341
|
+
select bm.membership_state
|
|
342
|
+
into v_existing_state
|
|
343
|
+
from agent_board.board_memberships bm
|
|
344
|
+
where bm.board_id = p_board_id
|
|
345
|
+
and bm.agent_id = p_agent_id
|
|
346
|
+
order by
|
|
347
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
348
|
+
bm.created_at desc,
|
|
349
|
+
bm.id desc
|
|
350
|
+
limit 1;
|
|
351
|
+
|
|
352
|
+
if v_actor is not null and v_actor <> p_agent_id and v_actor <> v_board.created_by_agent_id then
|
|
353
|
+
raise exception 'board membership actor mismatch' using errcode = '42501';
|
|
354
|
+
end if;
|
|
355
|
+
|
|
356
|
+
-- NULLFIX (CR): v_existing_state is NULL when the caller has NO membership row, and the original
|
|
357
|
+
-- guard then evaluated to NULL — `not (false or false or NULL)` is NULL, not TRUE — so `if NULL
|
|
358
|
+
-- then` never fired and ANY resolvable actor self-joined ANY board, private ones included. That
|
|
359
|
+
-- (not the null-actor bypass) was the true root cause of the live "joined a private invite_only
|
|
360
|
+
-- board with no invite" finding, and it is reachable by an ACTIVE agent, which the GATENULL guard
|
|
361
|
+
-- above cannot touch. coalesce() makes the absent-row case FALSE so the raise fires.
|
|
362
|
+
--
|
|
363
|
+
-- MBROWID (3b): a 'removed' agent is excluded from self-joining even an OPEN board via the
|
|
364
|
+
-- R-LINK-7 SET predicate (exists removed AND no active), not a single latest-row state.
|
|
365
|
+
-- Total-ordering the latest-row read alone is insufficient: a removed+left pair with equal
|
|
366
|
+
-- created_at can surface 'left' as the total-order winner while standing is still removed
|
|
367
|
+
-- (mutator + grant_board_role already use set-based rules). The three defenses must agree.
|
|
368
|
+
if v_actor is not null
|
|
369
|
+
and v_actor = p_agent_id
|
|
370
|
+
and v_board.created_by_agent_id <> v_actor
|
|
371
|
+
and not (
|
|
372
|
+
not (
|
|
373
|
+
exists (
|
|
374
|
+
select 1 from agent_board.board_memberships bm
|
|
375
|
+
where bm.board_id = p_board_id
|
|
376
|
+
and bm.agent_id = p_agent_id
|
|
377
|
+
and bm.membership_state = 'removed'
|
|
378
|
+
) and not exists (
|
|
379
|
+
select 1 from agent_board.board_memberships bm
|
|
380
|
+
where bm.board_id = p_board_id
|
|
381
|
+
and bm.agent_id = p_agent_id
|
|
382
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
and (
|
|
386
|
+
v_board.visibility = 'open'
|
|
387
|
+
or (v_board.visibility = 'invite_only' and coalesce(v_existing_state, '') = 'invited')
|
|
388
|
+
or coalesce(v_existing_state, '') in ('joined', 'subscribed')
|
|
389
|
+
)
|
|
390
|
+
) then
|
|
391
|
+
raise exception 'board is not joinable by this agent' using errcode = 'P0002';
|
|
392
|
+
end if;
|
|
393
|
+
|
|
394
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
395
|
+
p_board_id,
|
|
396
|
+
p_agent_id,
|
|
397
|
+
p_membership_state,
|
|
398
|
+
coalesce(p_created_by_agent_id, p_agent_id),
|
|
399
|
+
p_metadata
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
return query
|
|
403
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.created_at, v_membership.updated_at;
|
|
404
|
+
end;
|
|
405
|
+
$$;
|
|
406
|
+
-- 5. resolve_board_request --------------------------------------------------------------
|
|
407
|
+
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)
|
|
408
|
+
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)
|
|
409
|
+
LANGUAGE plpgsql
|
|
410
|
+
SECURITY DEFINER
|
|
411
|
+
SET search_path TO 'agent_board', 'public'
|
|
412
|
+
AS $$
|
|
413
|
+
declare
|
|
414
|
+
v_board agent_board.boards%rowtype;
|
|
415
|
+
v_existing_state text;
|
|
416
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
417
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
418
|
+
begin
|
|
419
|
+
-- GATENULL (#58): only service_role may proceed with an unresolved actor.
|
|
420
|
+
if v_actor is null and not agent_board._is_service_role() then
|
|
421
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
422
|
+
end if;
|
|
423
|
+
|
|
424
|
+
if p_membership_state not in ('joined', 'subscribed', 'removed') then
|
|
425
|
+
raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
|
|
426
|
+
end if;
|
|
427
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
428
|
+
if v_board.id is null then
|
|
429
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
430
|
+
end if;
|
|
431
|
+
if v_actor is not null then
|
|
432
|
+
if v_board.role_gate_enforced then
|
|
433
|
+
-- Gate ON: NO creator exemption. Every actor (including a demoted creator)
|
|
434
|
+
-- must hold an active owner/admin membership.
|
|
435
|
+
if not exists (
|
|
436
|
+
select 1
|
|
437
|
+
from agent_board.board_memberships bm
|
|
438
|
+
where bm.board_id = p_board_id
|
|
439
|
+
and bm.agent_id = v_actor
|
|
440
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
441
|
+
and bm.role in ('owner', 'admin')
|
|
442
|
+
) then
|
|
443
|
+
raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
|
|
444
|
+
end if;
|
|
445
|
+
elsif v_actor <> v_board.created_by_agent_id then
|
|
446
|
+
-- Gate OFF: creator bypasses; any other actor needs an active membership.
|
|
447
|
+
if not exists (
|
|
448
|
+
select 1
|
|
449
|
+
from agent_board.board_memberships bm
|
|
450
|
+
where bm.board_id = p_board_id
|
|
451
|
+
and bm.agent_id = v_actor
|
|
452
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
453
|
+
) then
|
|
454
|
+
raise exception 'board request resolution requires active membership' using errcode = '42501';
|
|
455
|
+
end if;
|
|
456
|
+
end if;
|
|
457
|
+
end if;
|
|
458
|
+
|
|
459
|
+
select bm.membership_state
|
|
460
|
+
into v_existing_state
|
|
461
|
+
from agent_board.board_memberships bm
|
|
462
|
+
where bm.board_id = p_board_id
|
|
463
|
+
and bm.agent_id = p_agent_id
|
|
464
|
+
order by bm.created_at desc, bm.id desc
|
|
465
|
+
limit 1;
|
|
466
|
+
|
|
467
|
+
-- NULLFIX (CR): same NULL-falsy hazard — with NO membership row v_existing_state is NULL and
|
|
468
|
+
-- `NULL not in (...)` is NULL, so the raise never fired and a caller could "resolve" an agent
|
|
469
|
+
-- that had never requested or been invited STRAIGHT to joined, bypassing the whole request path
|
|
470
|
+
-- (reachable by any active member on a gate-OFF board, incl. via `rooms approve --agent-id`).
|
|
471
|
+
if coalesce(v_existing_state, '') not in ('requested', 'invited') then
|
|
472
|
+
raise exception 'pending board membership not found' using errcode = 'P0002';
|
|
473
|
+
end if;
|
|
474
|
+
|
|
475
|
+
v_membership := agent_board._upsert_board_membership_state(
|
|
476
|
+
p_board_id,
|
|
477
|
+
p_agent_id,
|
|
478
|
+
p_membership_state,
|
|
479
|
+
p_created_by_agent_id,
|
|
480
|
+
p_metadata
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
return query
|
|
484
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
485
|
+
end;
|
|
486
|
+
$$;
|
|
487
|
+
-- 6. update_board_membership (both sites: active-prefer + total order) ------------------
|
|
488
|
+
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)
|
|
489
|
+
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)
|
|
490
|
+
LANGUAGE plpgsql
|
|
491
|
+
SECURITY DEFINER
|
|
492
|
+
SET search_path TO 'agent_board', 'public'
|
|
493
|
+
AS $$
|
|
494
|
+
declare
|
|
495
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
496
|
+
v_board agent_board.boards%rowtype;
|
|
497
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
498
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
499
|
+
v_existing_state text;
|
|
500
|
+
v_target_role text;
|
|
501
|
+
v_actor_role text;
|
|
502
|
+
begin
|
|
503
|
+
-- (1) Unresolved principal fails closed (GATENULL #58 posture). Raised before any lookup so this
|
|
504
|
+
-- cannot be used as a membership-existence oracle.
|
|
505
|
+
if v_actor is null and not v_is_service then
|
|
506
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
507
|
+
end if;
|
|
508
|
+
|
|
509
|
+
-- (2) AUTHORITY BEFORE DISCLOSURE. For a caller acting on ANOTHER agent's row the owner/admin
|
|
510
|
+
-- check runs BEFORE the membership lookup, so an unauthorized caller cannot distinguish
|
|
511
|
+
-- P0002 'board membership not found' from 42501 and thus cannot probe whether an arbitrary
|
|
512
|
+
-- (board_id, agent_id) pair has a membership row. (The self branch legitimately learns about its
|
|
513
|
+
-- own membership, and the service path is the operator.)
|
|
514
|
+
if not v_is_service and v_actor <> p_agent_id then
|
|
515
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
516
|
+
if v_board.id is null then
|
|
517
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
518
|
+
end if;
|
|
519
|
+
select bm.role
|
|
520
|
+
into v_actor_role
|
|
521
|
+
from agent_board.board_memberships bm
|
|
522
|
+
where bm.board_id = p_board_id
|
|
523
|
+
and bm.agent_id = v_actor
|
|
524
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
525
|
+
limit 1;
|
|
526
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
527
|
+
raise exception 'board membership change requires owner or admin role'
|
|
528
|
+
using errcode = '42501';
|
|
529
|
+
end if;
|
|
530
|
+
end if;
|
|
531
|
+
|
|
532
|
+
-- (3) Pre-existing precondition, preserved. Also captures the target's CURRENT role for the
|
|
533
|
+
-- rank check below.
|
|
534
|
+
-- MBROWID (3a): prefer ACTIVE standing. Bare total-order can surface a high-id REMOVED
|
|
535
|
+
-- row while a low-id JOINED row exists, so self-service would reject a legitimate
|
|
536
|
+
-- joined->joined toggle with 42501 'self membership change not permitted'.
|
|
537
|
+
select bm.membership_state, bm.role
|
|
538
|
+
into v_existing_state, v_target_role
|
|
539
|
+
from agent_board.board_memberships bm
|
|
540
|
+
where bm.board_id = p_board_id
|
|
541
|
+
and bm.agent_id = p_agent_id
|
|
542
|
+
order by
|
|
543
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
544
|
+
bm.created_at desc,
|
|
545
|
+
bm.id desc
|
|
546
|
+
limit 1;
|
|
547
|
+
if v_existing_state is null then
|
|
548
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
549
|
+
end if;
|
|
550
|
+
|
|
551
|
+
-- (4) MEMBSTATE authorization. Skipped ONLY for a genuine service_role/operator caller; every
|
|
552
|
+
-- authenticated caller (whose actor is guaranteed non-null by step 1) is gated.
|
|
553
|
+
if not v_is_service then
|
|
554
|
+
if v_actor = p_agent_id then
|
|
555
|
+
-- SELF-SERVICE. Deliberately NO board lookup on this branch: the board row is only needed
|
|
556
|
+
-- for the authority checks below, and requiring an ACTIVE board here would strand an agent
|
|
557
|
+
-- holding a live membership on an ARCHIVED board with no way to leave it (leave_board
|
|
558
|
+
-- delegates straight into this RPC, 20260512000002:154-158).
|
|
559
|
+
--
|
|
560
|
+
-- Leaving is permitted only FROM a live/pending state, and joined<->subscribed may be
|
|
561
|
+
-- toggled. Promotion INTO the board is NOT permitted — that belongs to the approval path.
|
|
562
|
+
-- The 'removed' exclusion is load-bearing: allowing removed -> left would let a kicked agent
|
|
563
|
+
-- erase the 'removed' marker (memberships are updated IN PLACE, so the row IS the record),
|
|
564
|
+
-- which is exactly the marker _redeem_invite_for_agent's R-LINK-7 defense reads
|
|
565
|
+
-- (20260724000100:323-335) — i.e. it would launder a kick into invite-link re-entry.
|
|
566
|
+
--
|
|
567
|
+
-- LEAVEIDEM (#81): a same-state TERMINAL call is additionally permitted. It is a no-op
|
|
568
|
+
-- (returned unchanged at step 5), so it neither erases nor refreshes the marker — it simply
|
|
569
|
+
-- restores idempotence to leave_board without widening the transition set.
|
|
570
|
+
if not (
|
|
571
|
+
(p_membership_state = 'left'
|
|
572
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
573
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
574
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
575
|
+
or (p_membership_state = v_existing_state
|
|
576
|
+
and p_membership_state in ('left', 'removed'))
|
|
577
|
+
) then
|
|
578
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
579
|
+
using errcode = '42501';
|
|
580
|
+
end if;
|
|
581
|
+
else
|
|
582
|
+
-- ACTING ON ANOTHER AGENT'S MEMBERSHIP. The board-active check and the ACTIVE owner/admin
|
|
583
|
+
-- requirement already ran in step (2) (authority before disclosure). This RPC is the
|
|
584
|
+
-- schema's only eviction path — no sibling can move a JOINED member to removed
|
|
585
|
+
-- (resolve_board_request only resolves requested/invited, 20260725000100:212) — so it is
|
|
586
|
+
-- gated more strictly than its siblings, with NO creator exemption: a bare
|
|
587
|
+
-- created_by_agent_id is deliberately NOT sufficient. ROOMOWN seeds the creator's owner
|
|
588
|
+
-- membership at create_board time (20260723000100), so a legitimate creator holds one, while
|
|
589
|
+
-- a DEMOTED or REMOVED creator must not retain eviction power (the R-DELEG-1 lesson).
|
|
590
|
+
--
|
|
591
|
+
-- STRICT RANK SUPERIORITY (owner=4 > admin=3 > member=2 > observer=1). Without it an admin
|
|
592
|
+
-- could evict the OWNER, or admins could evict each other.
|
|
593
|
+
if agent_board._role_rank(v_actor_role)
|
|
594
|
+
<= agent_board._role_rank(coalesce(v_target_role, '')) then
|
|
595
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
596
|
+
using errcode = '42501';
|
|
597
|
+
end if;
|
|
598
|
+
end if;
|
|
599
|
+
end if;
|
|
600
|
+
|
|
601
|
+
-- (5) LEAVEIDEM (#81): a same-state TERMINAL call is a TRUE no-op — return the row untouched
|
|
602
|
+
-- rather than re-entering the mutator, which would restamp left_at and re-merge metadata.
|
|
603
|
+
-- Placed AFTER the authorization block on purpose: returning early before step (2)/(4) would
|
|
604
|
+
-- hand an UNAUTHORIZED caller a successful no-op instead of 42501, turning this RPC into a
|
|
605
|
+
-- membership-state oracle for arbitrary (board, agent) pairs. Authority is still required; the
|
|
606
|
+
-- entitled caller simply gets an unchanged row.
|
|
607
|
+
-- Scoped to the AUTHENTICATED path on purpose: a service_role/operator caller re-asserting a
|
|
608
|
+
-- terminal state may legitimately be attaching an annotation via p_metadata (CLI --metadata), and
|
|
609
|
+
-- silently dropping it would be a lossy success. The operator falls through to the mutator, which
|
|
610
|
+
-- now preserves left_at for an UNCHANGED terminal state anyway — so the operator keeps the
|
|
611
|
+
-- metadata merge without reacquiring the restamp defect.
|
|
612
|
+
if not v_is_service
|
|
613
|
+
and p_membership_state = v_existing_state
|
|
614
|
+
and p_membership_state in ('left', 'removed') then
|
|
615
|
+
-- MBROWID (3a): same active-preferring total-order as the authz pre-read above so
|
|
616
|
+
-- the no-op return row agrees with the state used for authorization.
|
|
617
|
+
return query
|
|
618
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
619
|
+
from agent_board.board_memberships bm
|
|
620
|
+
where bm.board_id = p_board_id
|
|
621
|
+
and bm.agent_id = p_agent_id
|
|
622
|
+
order by
|
|
623
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
624
|
+
bm.created_at desc,
|
|
625
|
+
bm.id desc
|
|
626
|
+
limit 1;
|
|
627
|
+
return;
|
|
628
|
+
end if;
|
|
629
|
+
|
|
630
|
+
select * into v_membership
|
|
631
|
+
from agent_board._upsert_board_membership_state(
|
|
632
|
+
p_board_id,
|
|
633
|
+
p_agent_id,
|
|
634
|
+
p_membership_state,
|
|
635
|
+
null,
|
|
636
|
+
p_metadata
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
return query
|
|
640
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
641
|
+
end;
|
|
642
|
+
$$;
|
|
643
|
+
-- 7. list_board_pending_memberships (both sites: active-prefer + total order) -----------
|
|
644
|
+
CREATE OR REPLACE FUNCTION agent_board.list_board_pending_memberships(p_board_id uuid)
|
|
645
|
+
RETURNS TABLE(membership_id uuid, board_id uuid, agent_id uuid, handle text, display_name text, agent_type text, membership_state text, role text, created_by_agent_id uuid, requested_at timestamp with time zone, updated_at timestamp with time zone)
|
|
646
|
+
LANGUAGE plpgsql
|
|
647
|
+
STABLE SECURITY DEFINER
|
|
648
|
+
SET search_path TO 'agent_board', 'public'
|
|
649
|
+
AS $$
|
|
650
|
+
declare
|
|
651
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
652
|
+
begin
|
|
653
|
+
-- FLAT owner/admin gate, ALWAYS. Fail closed on a null actor (suspended/unmapped
|
|
654
|
+
-- principal, or service_role) — mirrors set_board_role_gate, NOT the write-RPC
|
|
655
|
+
-- `if v_actor is not null` bypass. Authz precedes disclosure.
|
|
656
|
+
if v_actor is null
|
|
657
|
+
or not exists (
|
|
658
|
+
select 1
|
|
659
|
+
from agent_board.board_memberships bm
|
|
660
|
+
where bm.board_id = p_board_id
|
|
661
|
+
and bm.agent_id = v_actor
|
|
662
|
+
and bm.role in ('owner', 'admin')
|
|
663
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
664
|
+
) then
|
|
665
|
+
raise exception 'only a board owner or admin may list pending memberships' using errcode = '42501';
|
|
666
|
+
end if;
|
|
667
|
+
|
|
668
|
+
-- Current standing per agent, scoped to this board, filtered to the pending set.
|
|
669
|
+
-- MBROWID (3a): prefer ACTIVE over bare total-order so a stale high-id REQUESTED
|
|
670
|
+
-- (or REMOVED) row cannot surface an already-active agent as pending. The dedup
|
|
671
|
+
-- subquery is ALSO scoped to p_board_id so no other board's rows can leak in.
|
|
672
|
+
-- Outer ORDER BY remains bare total-order (presentation only; no state acted on).
|
|
673
|
+
return query
|
|
674
|
+
select
|
|
675
|
+
latest.id,
|
|
676
|
+
latest.board_id,
|
|
677
|
+
latest.agent_id,
|
|
678
|
+
a.handle,
|
|
679
|
+
a.display_name,
|
|
680
|
+
a.agent_type,
|
|
681
|
+
latest.membership_state,
|
|
682
|
+
latest.role,
|
|
683
|
+
latest.created_by_agent_id,
|
|
684
|
+
latest.created_at as requested_at,
|
|
685
|
+
latest.updated_at
|
|
686
|
+
from (
|
|
687
|
+
select distinct on (bm.agent_id) bm.*
|
|
688
|
+
from agent_board.board_memberships bm
|
|
689
|
+
where bm.board_id = p_board_id
|
|
690
|
+
order by
|
|
691
|
+
bm.agent_id,
|
|
692
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
693
|
+
bm.created_at desc,
|
|
694
|
+
bm.id desc
|
|
695
|
+
) latest
|
|
696
|
+
join agent_board.agents a on a.id = latest.agent_id
|
|
697
|
+
where latest.membership_state in ('requested', 'invited')
|
|
698
|
+
order by latest.created_at desc, latest.id desc;
|
|
699
|
+
end;
|
|
700
|
+
$$;
|
|
701
|
+
-- 8. create_tasking_delivery (idempotency total-order for COUNT-EQUALITY) ---------------
|
|
702
|
+
CREATE OR REPLACE FUNCTION agent_board.create_tasking_delivery(p_thread_id uuid, p_created_by_agent_id uuid, p_title text, p_details text DEFAULT NULL::text, p_recipient_agent_ids uuid[] DEFAULT NULL::uuid[], p_board_id uuid DEFAULT NULL::uuid, p_priority text DEFAULT 'medium'::text, p_orchestration_mode text DEFAULT 'distributed'::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
703
|
+
RETURNS TABLE(task_id uuid, created_event_id uuid, delivered_recipient_count integer, inbox_item_count integer, callback_outbox_count integer, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
704
|
+
LANGUAGE plpgsql
|
|
705
|
+
SECURITY DEFINER
|
|
706
|
+
SET search_path TO 'agent_board', 'public'
|
|
707
|
+
AS $$
|
|
708
|
+
declare
|
|
709
|
+
v_task agent_board.tasks%rowtype;
|
|
710
|
+
v_created_event_id uuid;
|
|
711
|
+
v_created_task record;
|
|
712
|
+
v_recipient_ids uuid[];
|
|
713
|
+
v_recipient_id uuid;
|
|
714
|
+
v_membership record;
|
|
715
|
+
v_recipient agent_board.delivery_recipients%rowtype;
|
|
716
|
+
v_inbox agent_board.inbox_items%rowtype;
|
|
717
|
+
v_delivered_count integer := 0;
|
|
718
|
+
v_inbox_count integer := 0;
|
|
719
|
+
v_callback_count integer := 0;
|
|
720
|
+
v_metadata jsonb;
|
|
721
|
+
v_preview text;
|
|
722
|
+
v_fetch_descriptor jsonb;
|
|
723
|
+
v_idempotency_key text := nullif(trim(p_idempotency_key), '');
|
|
724
|
+
v_actor_agent_id uuid;
|
|
725
|
+
begin
|
|
726
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
|
|
727
|
+
|
|
728
|
+
if nullif(trim(p_title), '') is null then
|
|
729
|
+
raise exception 'task title is required' using errcode = '22023';
|
|
730
|
+
end if;
|
|
731
|
+
if p_priority not in ('low', 'medium', 'high', 'urgent') then
|
|
732
|
+
raise exception 'invalid priority' using errcode = '22023';
|
|
733
|
+
end if;
|
|
734
|
+
if p_orchestration_mode not in ('distributed', 'hierarchical', 'hybrid') then
|
|
735
|
+
raise exception 'invalid orchestration mode' using errcode = '22023';
|
|
736
|
+
end if;
|
|
737
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
738
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
739
|
+
end if;
|
|
740
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
741
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
742
|
+
end if;
|
|
743
|
+
if (p_recipient_agent_ids is null or cardinality(p_recipient_agent_ids) = 0) and p_board_id is null then
|
|
744
|
+
raise exception 'tasking delivery requires explicit recipients or a board target' using errcode = '22023';
|
|
745
|
+
end if;
|
|
746
|
+
if p_recipient_agent_ids is not null and array_position(p_recipient_agent_ids, null) is not null then
|
|
747
|
+
raise exception 'recipient_agent_ids must not contain nulls' using errcode = '22023';
|
|
748
|
+
end if;
|
|
749
|
+
if p_board_id is not null then
|
|
750
|
+
if not exists (select 1 from agent_board.boards where id = p_board_id) then
|
|
751
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
752
|
+
end if;
|
|
753
|
+
if not agent_board._current_agent_has_board_access(p_board_id) then
|
|
754
|
+
raise exception 'board not accessible to current agent' using errcode = '42501';
|
|
755
|
+
end if;
|
|
756
|
+
if not agent_board._current_agent_can_send_to_board(p_board_id) then
|
|
757
|
+
raise exception 'board send requires a non-observer board role' using errcode = '42501';
|
|
758
|
+
end if;
|
|
759
|
+
end if;
|
|
760
|
+
|
|
761
|
+
select array_agg(distinct recipient_id)
|
|
762
|
+
into v_recipient_ids
|
|
763
|
+
from unnest(coalesce(p_recipient_agent_ids, '{}'::uuid[])) as recipient_id;
|
|
764
|
+
|
|
765
|
+
if v_recipient_ids is not null and (
|
|
766
|
+
select count(*)
|
|
767
|
+
from agent_board.agents
|
|
768
|
+
where id = any(v_recipient_ids)
|
|
769
|
+
) <> cardinality(v_recipient_ids) then
|
|
770
|
+
raise exception 'recipient agent not found' using errcode = 'P0002';
|
|
771
|
+
end if;
|
|
772
|
+
|
|
773
|
+
if agent_board._is_service_role() then
|
|
774
|
+
perform agent_board._record_send_authz_telemetry(
|
|
775
|
+
'service_role_bypass',
|
|
776
|
+
coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
|
|
777
|
+
v_actor_agent_id,
|
|
778
|
+
p_board_id,
|
|
779
|
+
'create_tasking_delivery',
|
|
780
|
+
agent_board._resolve_send_fleet_scope(v_actor_agent_id),
|
|
781
|
+
null,
|
|
782
|
+
jsonb_build_object('redacted', true)
|
|
783
|
+
);
|
|
784
|
+
end if;
|
|
785
|
+
|
|
786
|
+
perform agent_board._lock_idempotency_key(
|
|
787
|
+
'create_tasking_delivery:' || p_thread_id::text || ':' || v_actor_agent_id::text,
|
|
788
|
+
v_idempotency_key
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
if v_idempotency_key is not null then
|
|
792
|
+
select *
|
|
793
|
+
into v_task
|
|
794
|
+
from agent_board.tasks
|
|
795
|
+
where thread_id = p_thread_id
|
|
796
|
+
and created_by_agent_id = v_actor_agent_id
|
|
797
|
+
and metadata ->> 'rpcName' = 'create_tasking_delivery'
|
|
798
|
+
and metadata ->> 'idempotencyKey' = v_idempotency_key
|
|
799
|
+
order by created_at desc, id desc
|
|
800
|
+
limit 1;
|
|
801
|
+
end if;
|
|
802
|
+
|
|
803
|
+
if v_task.id is null then
|
|
804
|
+
v_metadata := coalesce(p_metadata, '{}'::jsonb)
|
|
805
|
+
|| jsonb_build_object(
|
|
806
|
+
'rpcName', 'create_tasking_delivery',
|
|
807
|
+
'priority', p_priority
|
|
808
|
+
);
|
|
809
|
+
if p_board_id is not null then
|
|
810
|
+
v_metadata := v_metadata || jsonb_build_object('boardId', p_board_id::text);
|
|
811
|
+
end if;
|
|
812
|
+
if v_idempotency_key is not null then
|
|
813
|
+
v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
|
|
814
|
+
end if;
|
|
815
|
+
|
|
816
|
+
select *
|
|
817
|
+
into v_created_task
|
|
818
|
+
from agent_board.create_task(
|
|
819
|
+
p_thread_id,
|
|
820
|
+
v_actor_agent_id,
|
|
821
|
+
trim(p_title),
|
|
822
|
+
p_details,
|
|
823
|
+
null,
|
|
824
|
+
null,
|
|
825
|
+
p_priority,
|
|
826
|
+
p_orchestration_mode,
|
|
827
|
+
v_metadata
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
v_created_event_id := v_created_task.created_event_id;
|
|
831
|
+
|
|
832
|
+
select *
|
|
833
|
+
into v_task
|
|
834
|
+
from agent_board.tasks
|
|
835
|
+
where id = v_created_task.task_id;
|
|
836
|
+
else
|
|
837
|
+
select te.id
|
|
838
|
+
into v_created_event_id
|
|
839
|
+
from agent_board.task_events te
|
|
840
|
+
where te.task_id = v_task.id
|
|
841
|
+
and te.event_sequence = 1;
|
|
842
|
+
end if;
|
|
843
|
+
|
|
844
|
+
v_preview := agent_board._trim_inbox_text(coalesce(p_details, p_title), 280);
|
|
845
|
+
v_fetch_descriptor := agent_board._build_fetch_descriptor(
|
|
846
|
+
'task',
|
|
847
|
+
v_task.id,
|
|
848
|
+
'agent_board.v_task_tree',
|
|
849
|
+
'tasking delivery',
|
|
850
|
+
jsonb_build_object('threadId', p_thread_id::text)
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
if v_recipient_ids is not null then
|
|
854
|
+
foreach v_recipient_id in array v_recipient_ids loop
|
|
855
|
+
v_recipient := agent_board._upsert_delivery_recipient(
|
|
856
|
+
'tasking',
|
|
857
|
+
v_recipient_id,
|
|
858
|
+
'task',
|
|
859
|
+
null,
|
|
860
|
+
v_task.id,
|
|
861
|
+
null,
|
|
862
|
+
null,
|
|
863
|
+
null,
|
|
864
|
+
jsonb_build_object('priority', p_priority)
|
|
865
|
+
);
|
|
866
|
+
|
|
867
|
+
v_inbox := agent_board._upsert_inbox_item(
|
|
868
|
+
v_recipient.id,
|
|
869
|
+
v_recipient_id,
|
|
870
|
+
null,
|
|
871
|
+
v_recipient.delivery_mode,
|
|
872
|
+
'task',
|
|
873
|
+
null,
|
|
874
|
+
v_task.id,
|
|
875
|
+
null,
|
|
876
|
+
trim(p_title),
|
|
877
|
+
v_preview,
|
|
878
|
+
p_priority,
|
|
879
|
+
true,
|
|
880
|
+
false,
|
|
881
|
+
v_fetch_descriptor,
|
|
882
|
+
jsonb_build_object('threadId', p_thread_id::text)
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
v_delivered_count := v_delivered_count + 1;
|
|
886
|
+
v_inbox_count := v_inbox_count + 1;
|
|
887
|
+
v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
|
|
888
|
+
end loop;
|
|
889
|
+
end if;
|
|
890
|
+
|
|
891
|
+
if p_board_id is not null then
|
|
892
|
+
for v_membership in
|
|
893
|
+
select bm.id, bm.agent_id
|
|
894
|
+
from agent_board.board_memberships bm
|
|
895
|
+
where bm.board_id = p_board_id
|
|
896
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
897
|
+
order by bm.created_at
|
|
898
|
+
loop
|
|
899
|
+
v_recipient := agent_board._upsert_delivery_recipient(
|
|
900
|
+
'tasking',
|
|
901
|
+
v_membership.agent_id,
|
|
902
|
+
'task',
|
|
903
|
+
null,
|
|
904
|
+
v_task.id,
|
|
905
|
+
null,
|
|
906
|
+
p_board_id,
|
|
907
|
+
v_membership.id,
|
|
908
|
+
jsonb_build_object('priority', p_priority)
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
v_inbox := agent_board._upsert_inbox_item(
|
|
912
|
+
v_recipient.id,
|
|
913
|
+
v_membership.agent_id,
|
|
914
|
+
v_recipient.board_id,
|
|
915
|
+
v_recipient.delivery_mode,
|
|
916
|
+
'task',
|
|
917
|
+
null,
|
|
918
|
+
v_task.id,
|
|
919
|
+
null,
|
|
920
|
+
trim(p_title),
|
|
921
|
+
v_preview,
|
|
922
|
+
p_priority,
|
|
923
|
+
true,
|
|
924
|
+
v_recipient.board_id is not null,
|
|
925
|
+
v_fetch_descriptor,
|
|
926
|
+
jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
v_delivered_count := v_delivered_count + 1;
|
|
930
|
+
v_inbox_count := v_inbox_count + 1;
|
|
931
|
+
v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
|
|
932
|
+
end loop;
|
|
933
|
+
end if;
|
|
934
|
+
|
|
935
|
+
return query
|
|
936
|
+
select v_task.id, v_created_event_id, v_delivered_count, v_inbox_count, v_callback_count, v_task.created_at, v_task.updated_at;
|
|
937
|
+
end;
|
|
938
|
+
$$;
|
|
939
|
+
-- 9. post_board_message (idempotency total-order for COUNT-EQUALITY) --------------------
|
|
940
|
+
CREATE OR REPLACE FUNCTION agent_board.post_board_message(p_thread_id uuid, p_author_agent_id uuid, p_board_id uuid, p_subject text, p_body text, p_message_type text DEFAULT 'comment'::text, p_priority text DEFAULT 'medium'::text, p_idempotency_key text DEFAULT NULL::text, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
941
|
+
RETURNS TABLE(message_id uuid, board_id uuid, delivered_recipient_count integer, callback_outbox_count integer, created_at timestamp with time zone)
|
|
942
|
+
LANGUAGE plpgsql
|
|
943
|
+
SECURITY DEFINER
|
|
944
|
+
SET search_path TO 'agent_board', 'public'
|
|
945
|
+
AS $$
|
|
946
|
+
declare
|
|
947
|
+
v_message agent_board.messages%rowtype;
|
|
948
|
+
v_created_message record;
|
|
949
|
+
v_membership record;
|
|
950
|
+
v_recipient agent_board.delivery_recipients%rowtype;
|
|
951
|
+
v_inbox agent_board.inbox_items%rowtype;
|
|
952
|
+
v_delivered_count integer := 0;
|
|
953
|
+
v_callback_count integer := 0;
|
|
954
|
+
v_metadata jsonb;
|
|
955
|
+
v_preview text;
|
|
956
|
+
v_fetch_descriptor jsonb;
|
|
957
|
+
v_idempotency_key text := nullif(trim(p_idempotency_key), '');
|
|
958
|
+
v_actor_agent_id uuid;
|
|
959
|
+
begin
|
|
960
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_author_agent_id, 'p_author_agent_id');
|
|
961
|
+
|
|
962
|
+
if nullif(trim(p_subject), '') is null or nullif(trim(p_body), '') is null then
|
|
963
|
+
raise exception 'subject and body are required' using errcode = '22023';
|
|
964
|
+
end if;
|
|
965
|
+
if p_priority not in ('low', 'medium', 'high', 'urgent') then
|
|
966
|
+
raise exception 'invalid priority' using errcode = '22023';
|
|
967
|
+
end if;
|
|
968
|
+
if not exists (select 1 from agent_board.threads where id = p_thread_id) then
|
|
969
|
+
raise exception 'thread not found' using errcode = 'P0002';
|
|
970
|
+
end if;
|
|
971
|
+
if not exists (select 1 from agent_board.boards where id = p_board_id) then
|
|
972
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
973
|
+
end if;
|
|
974
|
+
if not agent_board._current_agent_can_access_thread(p_thread_id) then
|
|
975
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
976
|
+
end if;
|
|
977
|
+
if not agent_board._current_agent_has_board_access(p_board_id) then
|
|
978
|
+
raise exception 'board not accessible to current agent' using errcode = '42501';
|
|
979
|
+
end if;
|
|
980
|
+
if not agent_board._current_agent_can_send_to_board(p_board_id) then
|
|
981
|
+
raise exception 'board send requires a non-observer board role' using errcode = '42501';
|
|
982
|
+
end if;
|
|
983
|
+
|
|
984
|
+
if agent_board._is_service_role() then
|
|
985
|
+
perform agent_board._record_send_authz_telemetry(
|
|
986
|
+
'service_role_bypass',
|
|
987
|
+
coalesce(nullif(p_metadata -> 'signed_write' ->> 'origin', ''), 'unknown'),
|
|
988
|
+
v_actor_agent_id,
|
|
989
|
+
p_board_id,
|
|
990
|
+
'post_board_message',
|
|
991
|
+
agent_board._resolve_send_fleet_scope(v_actor_agent_id),
|
|
992
|
+
null,
|
|
993
|
+
jsonb_build_object('redacted', true)
|
|
994
|
+
);
|
|
995
|
+
end if;
|
|
996
|
+
|
|
997
|
+
perform agent_board._lock_idempotency_key(
|
|
998
|
+
'post_board_message:' || p_thread_id::text || ':' || v_actor_agent_id::text || ':' || p_board_id::text,
|
|
999
|
+
v_idempotency_key
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
if v_idempotency_key is not null then
|
|
1003
|
+
select *
|
|
1004
|
+
into v_message
|
|
1005
|
+
from agent_board.messages
|
|
1006
|
+
where thread_id = p_thread_id
|
|
1007
|
+
and author_agent_id = v_actor_agent_id
|
|
1008
|
+
and metadata ->> 'rpcName' = 'post_board_message'
|
|
1009
|
+
and metadata ->> 'idempotencyKey' = v_idempotency_key
|
|
1010
|
+
order by created_at desc, id desc
|
|
1011
|
+
limit 1;
|
|
1012
|
+
end if;
|
|
1013
|
+
|
|
1014
|
+
if v_message.id is null then
|
|
1015
|
+
v_metadata := coalesce(p_metadata, '{}'::jsonb)
|
|
1016
|
+
|| jsonb_build_object(
|
|
1017
|
+
'rpcName', 'post_board_message',
|
|
1018
|
+
'subject', trim(p_subject),
|
|
1019
|
+
'priority', p_priority,
|
|
1020
|
+
'boardId', p_board_id::text
|
|
1021
|
+
);
|
|
1022
|
+
if v_idempotency_key is not null then
|
|
1023
|
+
v_metadata := v_metadata || jsonb_build_object('idempotencyKey', v_idempotency_key);
|
|
1024
|
+
end if;
|
|
1025
|
+
|
|
1026
|
+
select *
|
|
1027
|
+
into v_created_message
|
|
1028
|
+
from agent_board.post_message(
|
|
1029
|
+
p_thread_id,
|
|
1030
|
+
v_actor_agent_id,
|
|
1031
|
+
p_body,
|
|
1032
|
+
p_message_type,
|
|
1033
|
+
null,
|
|
1034
|
+
v_metadata
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
select *
|
|
1038
|
+
into v_message
|
|
1039
|
+
from agent_board.messages
|
|
1040
|
+
where id = v_created_message.message_id;
|
|
1041
|
+
end if;
|
|
1042
|
+
|
|
1043
|
+
v_preview := agent_board._trim_inbox_text(p_body, 280);
|
|
1044
|
+
v_fetch_descriptor := agent_board._build_fetch_descriptor(
|
|
1045
|
+
'board',
|
|
1046
|
+
p_board_id,
|
|
1047
|
+
'agent_board.v_board_feed',
|
|
1048
|
+
'board delivery',
|
|
1049
|
+
jsonb_build_object('messageId', v_message.id::text)
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
for v_membership in
|
|
1053
|
+
select bm.id, bm.agent_id
|
|
1054
|
+
from agent_board.board_memberships bm
|
|
1055
|
+
where bm.board_id = p_board_id
|
|
1056
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
1057
|
+
order by bm.created_at
|
|
1058
|
+
loop
|
|
1059
|
+
v_recipient := agent_board._upsert_delivery_recipient(
|
|
1060
|
+
'board',
|
|
1061
|
+
v_membership.agent_id,
|
|
1062
|
+
'message',
|
|
1063
|
+
v_message.id,
|
|
1064
|
+
null,
|
|
1065
|
+
null,
|
|
1066
|
+
p_board_id,
|
|
1067
|
+
v_membership.id,
|
|
1068
|
+
jsonb_build_object('priority', p_priority)
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
v_inbox := agent_board._upsert_inbox_item(
|
|
1072
|
+
v_recipient.id,
|
|
1073
|
+
v_membership.agent_id,
|
|
1074
|
+
p_board_id,
|
|
1075
|
+
v_recipient.delivery_mode,
|
|
1076
|
+
'message',
|
|
1077
|
+
v_message.id,
|
|
1078
|
+
null,
|
|
1079
|
+
null,
|
|
1080
|
+
trim(p_subject),
|
|
1081
|
+
v_preview,
|
|
1082
|
+
p_priority,
|
|
1083
|
+
true,
|
|
1084
|
+
true,
|
|
1085
|
+
v_fetch_descriptor,
|
|
1086
|
+
jsonb_build_object('threadId', p_thread_id::text, 'boardId', p_board_id::text)
|
|
1087
|
+
);
|
|
1088
|
+
|
|
1089
|
+
v_delivered_count := v_delivered_count + 1;
|
|
1090
|
+
v_callback_count := v_callback_count + agent_board._queue_inbox_callbacks(v_inbox.id, 'inbox.item.delivered');
|
|
1091
|
+
end loop;
|
|
1092
|
+
|
|
1093
|
+
v_callback_count := v_callback_count + agent_board._queue_board_post_callbacks(
|
|
1094
|
+
p_board_id,
|
|
1095
|
+
v_message.id,
|
|
1096
|
+
trim(p_subject),
|
|
1097
|
+
v_preview,
|
|
1098
|
+
p_priority,
|
|
1099
|
+
v_fetch_descriptor
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
return query
|
|
1103
|
+
select v_message.id, p_board_id, v_delivered_count, v_callback_count, v_message.created_at;
|
|
1104
|
+
end;
|
|
1105
|
+
$$;
|
|
1106
|
+
-- 10. v_board_directory: DROP + CREATE (last definer used drop view, not create or replace)
|
|
1107
|
+
-- MBROWID (3a): current_membership lateral prefers ACTIVE standing, then total-order.
|
|
1108
|
+
-- Bare total-order can report 'removed' for an active member (high-id removed + low-id
|
|
1109
|
+
-- joined) and disagree with _active_board_membership_id.
|
|
1110
|
+
drop view if exists agent_board.v_board_directory;
|
|
1111
|
+
|
|
1112
|
+
create view agent_board.v_board_directory
|
|
1113
|
+
with (security_invoker = true)
|
|
1114
|
+
as
|
|
1115
|
+
SELECT b.id AS board_id,
|
|
1116
|
+
b.slug AS board_slug,
|
|
1117
|
+
b.name AS board_name,
|
|
1118
|
+
b.description AS board_description,
|
|
1119
|
+
b.status AS board_status,
|
|
1120
|
+
b.visibility AS board_visibility,
|
|
1121
|
+
b.created_by_agent_id,
|
|
1122
|
+
creator.handle AS created_by_handle,
|
|
1123
|
+
COALESCE(members.active_member_count, 0::bigint) AS active_member_count,
|
|
1124
|
+
current_membership.membership_state AS current_membership_state,
|
|
1125
|
+
COALESCE(board_activity.latest_board_activity_at, b.updated_at) AS latest_board_activity_at,
|
|
1126
|
+
b.created_at,
|
|
1127
|
+
b.updated_at
|
|
1128
|
+
FROM agent_board.boards b
|
|
1129
|
+
JOIN agent_board.agents creator ON creator.id = b.created_by_agent_id
|
|
1130
|
+
LEFT JOIN LATERAL ( SELECT count(*) AS active_member_count
|
|
1131
|
+
FROM agent_board.board_memberships bm
|
|
1132
|
+
WHERE bm.board_id = b.id AND (bm.membership_state = ANY (ARRAY['joined'::text, 'subscribed'::text]))) members ON true
|
|
1133
|
+
LEFT JOIN LATERAL ( SELECT bm.membership_state
|
|
1134
|
+
FROM agent_board.board_memberships bm
|
|
1135
|
+
WHERE bm.board_id = b.id AND bm.agent_id = agent_board.current_agent_id()
|
|
1136
|
+
ORDER BY
|
|
1137
|
+
CASE WHEN bm.membership_state = ANY (ARRAY['joined'::text, 'subscribed'::text]) THEN 0 ELSE 1 END,
|
|
1138
|
+
bm.created_at DESC,
|
|
1139
|
+
bm.id DESC
|
|
1140
|
+
LIMIT 1) current_membership ON true
|
|
1141
|
+
LEFT JOIN LATERAL ( SELECT max(ii.created_at) AS latest_board_activity_at
|
|
1142
|
+
FROM agent_board.inbox_items ii
|
|
1143
|
+
WHERE ii.board_id = b.id AND ii.visible_in_board_feed = true) board_activity ON true
|
|
1144
|
+
WHERE b.status = 'active'::text AND b.visibility <> 'directory_hidden'::text AND (b.visibility = 'open'::text OR b.created_by_agent_id = agent_board.current_agent_id() OR current_membership.membership_state IS NOT NULL);
|
|
1145
|
+
|
|
1146
|
+
grant select on agent_board.v_board_directory to authenticated, service_role;
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
-- Re-issue comments on recreated mutator / bootstrap / list / update so next authors
|
|
1150
|
+
-- do not inherit a stale ROLERESET-era comment that pretends MBROWID is still deferred.
|
|
1151
|
+
comment on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb) is
|
|
1152
|
+
'UNAUTHORIZED PRIMITIVE — accepts any membership state; every caller MUST gate. '
|
|
1153
|
+
'MBROWID: pre-read is duplicate-aware (prefer active, then created_at desc, id desc) and '
|
|
1154
|
+
'currently-removed standing uses the R-LINK-7 set predicate (exists removed AND no active). '
|
|
1155
|
+
'ROLERESET (#79): on the UPDATE path only, clamps role to the table default (''member'', '
|
|
1156
|
+
'20260717000100:13-15) when transitioning from a non-active origin into joined|subscribed; '
|
|
1157
|
+
'INSERT omits role and takes the table default. Active->active preserves role. LEAVEIDEM '
|
|
1158
|
+
'(#81): preserves the FIRST left_at across repeated terminal transitions (the retention sweep '
|
|
1159
|
+
'keys on left_at, 20260513000001:121) and strips the reserved `ops_*` metadata namespace from '
|
|
1160
|
+
'caller input (the sweep skips rows carrying ops_retained_at). Not a chokepoint — raw-INSERT '
|
|
1161
|
+
'seeders (create_board owner, bootstrap, invite redeem, fedsession autojoin) bypass this '
|
|
1162
|
+
'function by design.';
|
|
1163
|
+
|
|
1164
|
+
comment on function agent_board.bootstrap_board_owner_memberships() is
|
|
1165
|
+
'service_role-only repair sweep: seeds a joined owner membership for boards whose creator '
|
|
1166
|
+
'has no active membership AND is not currently removed (R-LINK-7). Voluntary left remains '
|
|
1167
|
+
'the sole-owner-lockout recovery trigger. Takes no caller/role param.';
|
|
1168
|
+
|
|
1169
|
+
comment on function agent_board.update_board_membership(uuid, uuid, text, jsonb) is
|
|
1170
|
+
'Updates a board membership state. MEMBSTATE (#75): service_role retains full '
|
|
1171
|
+
'modify-on-behalf-of; an authenticated caller with an unresolved actor fails closed (42501); '
|
|
1172
|
+
'a caller acting on its OWN membership may only leave or toggle joined<->subscribed; a caller '
|
|
1173
|
+
'acting on ANOTHER agent''s membership must hold an active owner/admin membership AND strictly '
|
|
1174
|
+
'higher board rank than the target. LEAVEIDEM (#81): a same-state terminal call is a TRUE no-op '
|
|
1175
|
+
'returned unchanged AFTER authorization. MBROWID: both latest-row reads prefer an active row '
|
|
1176
|
+
'when one exists, then created_at desc, id desc.';
|
|
1177
|
+
|
|
1178
|
+
comment on function agent_board.list_board_pending_memberships(uuid) is
|
|
1179
|
+
'Owner/admin-gated reader for a board''s pending (requested + invited) memberships, '
|
|
1180
|
+
'current-standing-per-agent preferring active then total order '
|
|
1181
|
+
'(agent_id, active-first, created_at desc, id desc). FLAT owner/admin gate ALWAYS, '
|
|
1182
|
+
'fail-closed on a null actor (else 42501). Board-scoped. service_role hits 42501 '
|
|
1183
|
+
'(null current_agent_id()); operators read the table directly.';
|
|
1184
|
+
|
|
1185
|
+
-- Privileges: create or replace preserves EXECUTE; re-state bootstrap and view grants.
|
|
1186
|
+
revoke all on function agent_board.bootstrap_board_owner_memberships() from public, anon, authenticated;
|
|
1187
|
+
grant execute on function agent_board.bootstrap_board_owner_memberships() to service_role;
|
|
1188
|
+
|
|
1189
|
+
revoke all on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
|
|
1190
|
+
from public, anon, authenticated;
|
|
1191
|
+
grant execute on function agent_board._upsert_board_membership_state(uuid, uuid, text, uuid, jsonb)
|
|
1192
|
+
to service_role;
|
|
1193
|
+
|
|
1194
|
+
grant execute on function agent_board.join_board(uuid, uuid, text, uuid, jsonb)
|
|
1195
|
+
to authenticated, service_role;
|
|
1196
|
+
grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb)
|
|
1197
|
+
to authenticated, service_role;
|
|
1198
|
+
grant execute on function agent_board.update_board_membership(uuid, uuid, text, jsonb)
|
|
1199
|
+
to authenticated, service_role;
|
|
1200
|
+
grant execute on function agent_board.list_board_pending_memberships(uuid)
|
|
1201
|
+
to authenticated, service_role;
|