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