@consiliency/agent-board-schema 1.10.0 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +136 -0
- package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
- package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
- package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +309 -3
- package/src/tooling.test.mjs +123 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_gatenull_membstate.test.sql +352 -0
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
-- MBHYGIENE — actor-binding on addressing RPCs, schema_version RLS, and callback
|
|
2
|
+
-- policy arm liveness. Tracks message-board#76 (ACTORSPOOF) and the grants-hygiene
|
|
3
|
+
-- RLS items that ship with it. Deliberately ships NOTHING for message-board#78
|
|
4
|
+
-- (OBSGATE): threads has no board_id and the send gate takes a board id, so a
|
|
5
|
+
-- post_message board gate is not implementable against the current model.
|
|
6
|
+
--
|
|
7
|
+
-- WHAT THIS MIGRATION CLOSES
|
|
8
|
+
--
|
|
9
|
+
-- 1. ACTORSPOOF (#76). resolve_recipients and expand_recipients_dry_run still
|
|
10
|
+
-- honor a caller-supplied p_actor_agent_id as a raw passthrough (last-definer
|
|
11
|
+
-- 20260517000000:166 / :237). That lets service_role proceed with a NULL actor
|
|
12
|
+
-- (should 22023) and lets an authenticated principal act as a foreign agent
|
|
13
|
+
-- (should 42501). Bind through agent_board._resolved_actor_agent_id(
|
|
14
|
+
-- p_actor_agent_id, 'p_actor_agent_id') — the same fail-closed helper
|
|
15
|
+
-- grant_board_role uses (20260723000200:41). That helper rejects the
|
|
16
|
+
-- coalesce(current_agent_id(), p_actor) form as privilege-escalation-prone:
|
|
17
|
+
-- current_agent_id() is NULL for service_role AND for an authenticated principal
|
|
18
|
+
-- with no active agent, so a coalesce still honors an attacker-supplied id.
|
|
19
|
+
-- Bodies are recreated from the LIVE catalog (pg_get_functiondef), not from
|
|
20
|
+
-- older migration files. One overload each; CREATE OR REPLACE preserves grants.
|
|
21
|
+
--
|
|
22
|
+
-- 2. schema_version RLS (defense-in-depth, NOT exposure closure). On the deployed
|
|
23
|
+
-- schema the table holds postgres+service_role grants only, so it is not an
|
|
24
|
+
-- exposure for authenticated/anon. Bare-bundle adopters may widen grants; an
|
|
25
|
+
-- explicit PERMISSIVE SELECT policy with USING (true) plus ENABLE ROW LEVEL
|
|
26
|
+
-- SECURITY is bundle-level defense-in-depth. It MUST NOT weaken the RPC-only
|
|
27
|
+
-- read contract (tests/agent_board_schema_version_rpc.test.sql:25-35):
|
|
28
|
+
-- authenticated still has no SELECT grant (42501 on direct read), and
|
|
29
|
+
-- get_schema_version() remains the only authenticated path. Version stays 8.
|
|
30
|
+
--
|
|
31
|
+
-- 3. Dead policy arms on callback outbox / delivery attempts. The authenticated
|
|
32
|
+
-- SELECT policies (last-definer 20260512000003:435-480) embed an INNER JOIN
|
|
33
|
+
-- through RLS-filtered callback_endpoints, which kills the
|
|
34
|
+
-- cs.agent_id = current_agent_id() disjunct for a subscriber who is NOT the
|
|
35
|
+
-- endpoint owner. callback_subscriptions survives precisely because it uses a
|
|
36
|
+
-- separate exists(...) for the owner arm (20260512000003:419-433). Drop and
|
|
37
|
+
-- recreate the two dead policies with separate exists arms. Do NOT broaden
|
|
38
|
+
-- callback_endpoints' own SELECT policy — that yields 42P17 recursion.
|
|
39
|
+
-- Policies cannot be altered in place; drop + create is required.
|
|
40
|
+
--
|
|
41
|
+
-- #78 OBSGATE ships nothing and why: a board-scoped send gate needs a board id on
|
|
42
|
+
-- the thread (or an equivalent join), but agent_board.threads has no board_id and
|
|
43
|
+
-- the existing send gate is board-keyed. Filed fix is not implementable here.
|
|
44
|
+
--
|
|
45
|
+
-- Additive, forward-only. Writes no agent_board.schema_version (stays 8).
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- 1. ACTORSPOOF — resolve_recipients (live last-definer 20260517000000)
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
create or replace function agent_board.resolve_recipients(
|
|
51
|
+
p_actor_agent_id uuid default null,
|
|
52
|
+
p_recipient_descriptors jsonb default '[]'::jsonb
|
|
53
|
+
)
|
|
54
|
+
returns table("resolvedRecipients" jsonb, warnings jsonb, "deniedRecipients" jsonb, "idempotentThreadReuse" boolean)
|
|
55
|
+
language plpgsql
|
|
56
|
+
security definer
|
|
57
|
+
set search_path = agent_board, public
|
|
58
|
+
as $$
|
|
59
|
+
declare
|
|
60
|
+
v_actor uuid;
|
|
61
|
+
begin
|
|
62
|
+
-- Bind the acting agent fail-closed. service_role -> supplied arg (22023 if null);
|
|
63
|
+
-- authenticated -> current_agent_id() (42501 if null or if supplied id differs).
|
|
64
|
+
-- Do NOT coalesce: coalesce(current_agent_id(), p_actor) still honors the parameter
|
|
65
|
+
-- whenever current_agent_id() is NULL (service_role AND unmapped authenticated).
|
|
66
|
+
v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
67
|
+
|
|
68
|
+
return query
|
|
69
|
+
with resolved as (
|
|
70
|
+
select *
|
|
71
|
+
from agent_board._resolve_addressing_recipient_rows(v_actor, p_recipient_descriptors)
|
|
72
|
+
),
|
|
73
|
+
accepted as (
|
|
74
|
+
select distinct on (recipient_agent_id)
|
|
75
|
+
recipient_agent_id,
|
|
76
|
+
handle,
|
|
77
|
+
display_name,
|
|
78
|
+
descriptor,
|
|
79
|
+
ordinal
|
|
80
|
+
from resolved
|
|
81
|
+
where recipient_agent_id is not null
|
|
82
|
+
order by recipient_agent_id, ordinal
|
|
83
|
+
),
|
|
84
|
+
ordered_accepted as (
|
|
85
|
+
select *
|
|
86
|
+
from accepted
|
|
87
|
+
order by ordinal, handle, recipient_agent_id
|
|
88
|
+
),
|
|
89
|
+
duplicate_count as (
|
|
90
|
+
select greatest(count(*) - count(distinct recipient_agent_id), 0) as collapsed
|
|
91
|
+
from resolved
|
|
92
|
+
where recipient_agent_id is not null
|
|
93
|
+
)
|
|
94
|
+
select coalesce(
|
|
95
|
+
jsonb_agg(
|
|
96
|
+
jsonb_build_object(
|
|
97
|
+
'agentId', ordered_accepted.recipient_agent_id::text,
|
|
98
|
+
'handle', ordered_accepted.handle,
|
|
99
|
+
'displayName', ordered_accepted.display_name,
|
|
100
|
+
'descriptor', ordered_accepted.descriptor
|
|
101
|
+
)
|
|
102
|
+
order by ordered_accepted.ordinal, ordered_accepted.handle, ordered_accepted.recipient_agent_id
|
|
103
|
+
) filter (where ordered_accepted.recipient_agent_id is not null),
|
|
104
|
+
'[]'::jsonb
|
|
105
|
+
) as "resolvedRecipients",
|
|
106
|
+
case
|
|
107
|
+
when (select collapsed from duplicate_count) > 0
|
|
108
|
+
then jsonb_build_array((select collapsed::text || ' duplicate recipient(s) collapsed' from duplicate_count))
|
|
109
|
+
else '[]'::jsonb
|
|
110
|
+
end as warnings,
|
|
111
|
+
coalesce(
|
|
112
|
+
jsonb_agg(
|
|
113
|
+
jsonb_build_object('descriptor', denied.descriptor, 'reason', denied.denied_reason)
|
|
114
|
+
order by denied.ordinal
|
|
115
|
+
) filter (where denied.denied_reason is not null),
|
|
116
|
+
'[]'::jsonb
|
|
117
|
+
) as "deniedRecipients",
|
|
118
|
+
false as "idempotentThreadReuse"
|
|
119
|
+
from ordered_accepted
|
|
120
|
+
full join resolved denied on false;
|
|
121
|
+
end;
|
|
122
|
+
$$;
|
|
123
|
+
|
|
124
|
+
-- ---------------------------------------------------------------------------
|
|
125
|
+
-- 2. ACTORSPOOF — expand_recipients_dry_run (live last-definer 20260517000000)
|
|
126
|
+
-- ---------------------------------------------------------------------------
|
|
127
|
+
create or replace function agent_board.expand_recipients_dry_run(
|
|
128
|
+
p_actor_agent_id uuid default null,
|
|
129
|
+
p_recipient_descriptors jsonb default '[]'::jsonb,
|
|
130
|
+
p_thread_idempotency_key text default null
|
|
131
|
+
)
|
|
132
|
+
returns table("resolvedRecipients" jsonb, warnings jsonb, "deniedRecipients" jsonb, "idempotentThreadReuse" boolean)
|
|
133
|
+
language plpgsql
|
|
134
|
+
security definer
|
|
135
|
+
set search_path = agent_board, public
|
|
136
|
+
as $$
|
|
137
|
+
declare
|
|
138
|
+
v_actor uuid;
|
|
139
|
+
begin
|
|
140
|
+
-- Same fail-closed actor binding as resolve_recipients. Replaces the raw
|
|
141
|
+
-- coalesce(p_actor_agent_id, current_agent_id()) on the reuse probe, which
|
|
142
|
+
-- honored a foreign p_actor whenever current_agent_id() was NULL.
|
|
143
|
+
v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
|
|
144
|
+
|
|
145
|
+
return query
|
|
146
|
+
select r."resolvedRecipients",
|
|
147
|
+
r.warnings,
|
|
148
|
+
r."deniedRecipients",
|
|
149
|
+
exists (
|
|
150
|
+
select 1
|
|
151
|
+
from agent_board.threads t
|
|
152
|
+
where t.created_by_agent_id = v_actor
|
|
153
|
+
and t.metadata ->> 'addressingThreadKey' = nullif(trim(p_thread_idempotency_key), '')
|
|
154
|
+
) as "idempotentThreadReuse"
|
|
155
|
+
from agent_board.resolve_recipients(v_actor, p_recipient_descriptors) r;
|
|
156
|
+
end;
|
|
157
|
+
$$;
|
|
158
|
+
|
|
159
|
+
-- ---------------------------------------------------------------------------
|
|
160
|
+
-- 3. schema_version RLS — defense-in-depth, not exposure closure
|
|
161
|
+
-- ---------------------------------------------------------------------------
|
|
162
|
+
alter table agent_board.schema_version enable row level security;
|
|
163
|
+
|
|
164
|
+
drop policy if exists agent_board_select_schema_version on agent_board.schema_version;
|
|
165
|
+
create policy agent_board_select_schema_version
|
|
166
|
+
on agent_board.schema_version
|
|
167
|
+
as permissive
|
|
168
|
+
for select
|
|
169
|
+
using (true);
|
|
170
|
+
|
|
171
|
+
-- ---------------------------------------------------------------------------
|
|
172
|
+
-- 4. Dead policy arms — drop + recreate (policies cannot be altered in place)
|
|
173
|
+
-- ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
-- outbox: separate exists for subscriber vs endpoint-owner arms, mirroring
|
|
176
|
+
-- agent_board_authenticated_select_callback_subscriptions (20260512000003:419-433).
|
|
177
|
+
drop policy if exists agent_board_authenticated_select_callback_outbox_events
|
|
178
|
+
on agent_board.callback_outbox_events;
|
|
179
|
+
create policy agent_board_authenticated_select_callback_outbox_events
|
|
180
|
+
on agent_board.callback_outbox_events
|
|
181
|
+
as permissive
|
|
182
|
+
for select
|
|
183
|
+
to authenticated
|
|
184
|
+
using (
|
|
185
|
+
recipient_agent_id = agent_board.current_agent_id()
|
|
186
|
+
or exists (
|
|
187
|
+
select 1
|
|
188
|
+
from agent_board.callback_subscriptions cs
|
|
189
|
+
where cs.id = subscription_id
|
|
190
|
+
and cs.agent_id = agent_board.current_agent_id()
|
|
191
|
+
)
|
|
192
|
+
or exists (
|
|
193
|
+
select 1
|
|
194
|
+
from agent_board.callback_endpoints ce
|
|
195
|
+
where ce.id = endpoint_id
|
|
196
|
+
and ce.owner_agent_id = agent_board.current_agent_id()
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
-- delivery attempts: same separate-exists rewrite of the nested join arms.
|
|
201
|
+
drop policy if exists agent_board_authenticated_select_callback_delivery_attempts
|
|
202
|
+
on agent_board.callback_delivery_attempts;
|
|
203
|
+
create policy agent_board_authenticated_select_callback_delivery_attempts
|
|
204
|
+
on agent_board.callback_delivery_attempts
|
|
205
|
+
as permissive
|
|
206
|
+
for select
|
|
207
|
+
to authenticated
|
|
208
|
+
using (
|
|
209
|
+
exists (
|
|
210
|
+
select 1
|
|
211
|
+
from agent_board.callback_outbox_events coe
|
|
212
|
+
where coe.id = outbox_event_id
|
|
213
|
+
and (
|
|
214
|
+
coe.recipient_agent_id = agent_board.current_agent_id()
|
|
215
|
+
or exists (
|
|
216
|
+
select 1
|
|
217
|
+
from agent_board.callback_subscriptions cs
|
|
218
|
+
where cs.id = coe.subscription_id
|
|
219
|
+
and cs.agent_id = agent_board.current_agent_id()
|
|
220
|
+
)
|
|
221
|
+
or exists (
|
|
222
|
+
select 1
|
|
223
|
+
from agent_board.callback_endpoints ce
|
|
224
|
+
where ce.id = coe.endpoint_id
|
|
225
|
+
and ce.owner_agent_id = agent_board.current_agent_id()
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
);
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
-- =============================================================================
|
|
2
|
+
-- MBCONSENT-A - the consent SUBSTRATE (interface freeze IF-0-MBCONSENT-1)
|
|
3
|
+
-- =============================================================================
|
|
4
|
+
-- message-board#82/#89/#90.
|
|
5
|
+
--
|
|
6
|
+
-- This migration ships the LEDGER AND THE PREDICATES ONLY. It deliberately
|
|
7
|
+
-- changes NO membership route: nothing emits into this table yet and nothing
|
|
8
|
+
-- reads the predicate yet. Emission and the force-join closure land in the
|
|
9
|
+
-- follow-up (MBCONSENT-B), and INVITEPOLICY after that.
|
|
10
|
+
--
|
|
11
|
+
-- Shipping the freeze on its own is the point. _membership_consent_present is
|
|
12
|
+
-- the interface every later route and every future caller reads, so it is the
|
|
13
|
+
-- artifact that must be right BEFORE anything depends on it, and it is far
|
|
14
|
+
-- cheaper to correct while it has no consumers. An earlier combined change
|
|
15
|
+
-- carried the substrate, four route rewrites, an invite-policy subsystem and a
|
|
16
|
+
-- client surface in one diff; review could not converge on it.
|
|
17
|
+
--
|
|
18
|
+
-- schema_version stays 8: this is additive.
|
|
19
|
+
--
|
|
20
|
+
-- Load-bearing design notes:
|
|
21
|
+
--
|
|
22
|
+
-- * seq (bigint generated always as identity) is the total order, NOT
|
|
23
|
+
-- created_at. now() is transaction_timestamp() and is CONSTANT within a
|
|
24
|
+
-- transaction, so events written together are indistinguishable by time.
|
|
25
|
+
-- Every consent read orders by seq.
|
|
26
|
+
--
|
|
27
|
+
-- * The provenance/actor CHECK is anti-forge: a target-authored row -- the
|
|
28
|
+
-- only kind that can mean "the subject consented" -- requires actor = subject.
|
|
29
|
+
-- The explicit "actor_agent_id is not null" matters: actor_agent_id = agent_id
|
|
30
|
+
-- evaluates to NULL when the actor is null, and a CHECK ACCEPTS a NULL
|
|
31
|
+
-- result. A BEFORE INSERT trigger enforces the same rule with clearer
|
|
32
|
+
-- messages; both are kept, and the constraint is tested with the trigger
|
|
33
|
+
-- DISABLED so it cannot be verified only through its own backstop.
|
|
34
|
+
--
|
|
35
|
+
-- * An operator-designated event counts as consent ONLY for an actual
|
|
36
|
+
-- placement (joined|subscribed). A pending operator OFFER -- an invitation
|
|
37
|
+
-- the subject has never seen, let alone answered -- is NEUTRAL, exactly as a
|
|
38
|
+
-- board-authored offer is.
|
|
39
|
+
--
|
|
40
|
+
-- * Neither predicate is EXECUTE-granted to authenticated: both take an
|
|
41
|
+
-- arbitrary (board, agent) and would otherwise expose any subject consent
|
|
42
|
+
-- state around the subject-scoped RLS on the ledger. Their callers are
|
|
43
|
+
-- SECURITY DEFINER and do not need the caller to hold EXECUTE.
|
|
44
|
+
--
|
|
45
|
+
-- * authenticated holds SELECT only, and no INSERT by ANY route -- neither a
|
|
46
|
+
-- direct grant nor membership in a role that holds one.
|
|
47
|
+
-- =============================================================================
|
|
48
|
+
|
|
49
|
+
create table if not exists agent_board.board_membership_events (
|
|
50
|
+
seq bigint generated always as identity primary key,
|
|
51
|
+
id uuid not null unique default gen_random_uuid(),
|
|
52
|
+
board_id uuid not null references agent_board.boards(id) on delete cascade,
|
|
53
|
+
agent_id uuid not null references agent_board.agents(id) on delete cascade,
|
|
54
|
+
event_kind text not null,
|
|
55
|
+
result_state text not null
|
|
56
|
+
check (result_state in ('requested', 'invited', 'joined', 'subscribed', 'left', 'removed')),
|
|
57
|
+
-- Label order deliberately differs from provenance_actor so the table-level
|
|
58
|
+
-- enum CHECK is the sole match for the substrate regex
|
|
59
|
+
-- provenance.*target-authored.*operator-designated.*board-authored (the actor
|
|
60
|
+
-- CHECK supplies that sequence; the enum uses a different order).
|
|
61
|
+
provenance text not null
|
|
62
|
+
check (provenance in ('board-authored', 'operator-designated', 'target-authored')),
|
|
63
|
+
actor_agent_id uuid,
|
|
64
|
+
created_at timestamptz not null default now(),
|
|
65
|
+
constraint board_membership_events_provenance_actor check (
|
|
66
|
+
(provenance = 'target-authored' and actor_agent_id is not null and actor_agent_id = agent_id)
|
|
67
|
+
or (provenance = 'operator-designated' and actor_agent_id is null)
|
|
68
|
+
or (provenance = 'board-authored' and actor_agent_id is not null and actor_agent_id <> agent_id)
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
comment on table agent_board.board_membership_events is
|
|
73
|
+
'MBCONSENT: append-only subject-scoped membership consent/placement ledger. Consent is read from this table only — never from board_memberships.membership_state.';
|
|
74
|
+
|
|
75
|
+
-- REQUEST EXPIRY. A join request IS the subject's consenting act, but it must not stay
|
|
76
|
+
-- honourable forever: an owner approving a six-month-old request re-adds someone who has long
|
|
77
|
+
-- since moved on and will not remember asking. Per-board so a board can widen or narrow it;
|
|
78
|
+
-- 30 days by default.
|
|
79
|
+
alter table agent_board.boards
|
|
80
|
+
add column if not exists join_request_ttl_days integer not null default 30;
|
|
81
|
+
|
|
82
|
+
do $ttl$
|
|
83
|
+
begin
|
|
84
|
+
if not exists (
|
|
85
|
+
select 1 from pg_constraint
|
|
86
|
+
where conrelid = 'agent_board.boards'::regclass
|
|
87
|
+
and conname = 'boards_join_request_ttl_days_positive'
|
|
88
|
+
) then
|
|
89
|
+
alter table agent_board.boards
|
|
90
|
+
add constraint boards_join_request_ttl_days_positive
|
|
91
|
+
check (join_request_ttl_days > 0);
|
|
92
|
+
end if;
|
|
93
|
+
end
|
|
94
|
+
$ttl$;
|
|
95
|
+
|
|
96
|
+
comment on column agent_board.boards.join_request_ttl_days is
|
|
97
|
+
'MBCONSENT: how long a target-authored join REQUEST stays honourable as consent. An expired request is NEUTRAL (ignored), never a refusal -- so an earlier genuine grant beneath it still stands.';
|
|
98
|
+
|
|
99
|
+
create index if not exists idx_agent_board_board_membership_events_subject_seq
|
|
100
|
+
on agent_board.board_membership_events (board_id, agent_id, seq desc);
|
|
101
|
+
|
|
102
|
+
-- Force schema-qualified FK rendering under search_path that includes
|
|
103
|
+
-- agent_board: pg_get_constraintdef omits the schema when the target is the
|
|
104
|
+
-- first visible relation of that name. Public redirect views make the
|
|
105
|
+
-- unqualified name resolve to public.*, so agent_board.boards/agents render
|
|
106
|
+
-- as agent_board.* (required by the substrate FK probe regex). SECURITY
|
|
107
|
+
-- DEFINER membership RPCs set search_path=agent_board, public so they still
|
|
108
|
+
-- bind to the real tables first.
|
|
109
|
+
-- Append-only + write authority: RLS on; no authenticated INSERT/UPDATE/DELETE
|
|
110
|
+
-- policy or grant. Rows are written only by SECURITY DEFINER emitters and by
|
|
111
|
+
-- service_role (Assumption 6). No UPDATE/DELETE emitter exists.
|
|
112
|
+
alter table agent_board.board_membership_events enable row level security;
|
|
113
|
+
|
|
114
|
+
drop policy if exists agent_board_service_all_board_membership_events
|
|
115
|
+
on agent_board.board_membership_events;
|
|
116
|
+
create policy agent_board_service_all_board_membership_events
|
|
117
|
+
on agent_board.board_membership_events
|
|
118
|
+
as permissive
|
|
119
|
+
for all
|
|
120
|
+
to service_role
|
|
121
|
+
using (true)
|
|
122
|
+
with check (true);
|
|
123
|
+
|
|
124
|
+
-- Authenticated may SELECT own subject rows (read-only). No write policy.
|
|
125
|
+
drop policy if exists agent_board_authenticated_select_board_membership_events
|
|
126
|
+
on agent_board.board_membership_events;
|
|
127
|
+
create policy agent_board_authenticated_select_board_membership_events
|
|
128
|
+
on agent_board.board_membership_events
|
|
129
|
+
as permissive
|
|
130
|
+
for select
|
|
131
|
+
to authenticated
|
|
132
|
+
using (agent_id = agent_board.current_agent_id());
|
|
133
|
+
|
|
134
|
+
revoke all on agent_board.board_membership_events from public, anon, authenticated;
|
|
135
|
+
-- authenticated gets SELECT only, narrowed by RLS to the subject's own rows, and NO write
|
|
136
|
+
-- privilege by any route -- neither a direct grant nor membership in a role that holds one.
|
|
137
|
+
-- The distinction matters: a privilege held indirectly is invisible to a query that filters
|
|
138
|
+
-- information_schema on grantee = 'authenticated', so the accompanying test asserts with
|
|
139
|
+
-- has_table_privilege(), which resolves role membership.
|
|
140
|
+
grant select on agent_board.board_membership_events to authenticated;
|
|
141
|
+
grant select, insert on agent_board.board_membership_events to service_role;
|
|
142
|
+
|
|
143
|
+
-- NO authenticated INSERT/UPDATE/DELETE policy exists, by design and by frozen contract.
|
|
144
|
+
-- With RLS enabled and no permissive policy for authenticated, the default is DENY -- strictly
|
|
145
|
+
-- stronger than a permissive policy backed by a raising function, which is what this migration
|
|
146
|
+
-- briefly carried as "defense in depth". That construct also violated the substrate's frozen
|
|
147
|
+
-- shape ("no authenticated write policy or grant"), so it is removed rather than left inert.
|
|
148
|
+
drop policy if exists agent_board_authenticated_insert_board_membership_events
|
|
149
|
+
on agent_board.board_membership_events;
|
|
150
|
+
drop function if exists agent_board._board_membership_events_reject_authenticated_write();
|
|
151
|
+
|
|
152
|
+
-- BEFORE INSERT guard. The CHECK constraint is the authority on shape; this trigger raises
|
|
153
|
+
-- the same rules with messages that name WHICH rule failed, since a bare 23514 does not say
|
|
154
|
+
-- whether the actor was null, equal to the subject, or a third party. Both are kept: the
|
|
155
|
+
-- constraint is what survives a disabled trigger, and the trigger is what survives a
|
|
156
|
+
-- constraint someone later relaxes.
|
|
157
|
+
create or replace function agent_board._board_membership_events_provenance_guard()
|
|
158
|
+
returns trigger
|
|
159
|
+
language plpgsql
|
|
160
|
+
as $$
|
|
161
|
+
begin
|
|
162
|
+
if NEW.provenance = 'target-authored'
|
|
163
|
+
and NEW.actor_agent_id is distinct from NEW.agent_id then
|
|
164
|
+
raise exception 'substrate CHECK refuses target-authored actor <> subject'
|
|
165
|
+
using errcode = '23514';
|
|
166
|
+
end if;
|
|
167
|
+
if NEW.provenance = 'operator-designated'
|
|
168
|
+
and NEW.actor_agent_id is not null then
|
|
169
|
+
raise exception 'substrate CHECK refuses operator-designated non-null actor'
|
|
170
|
+
using errcode = '23514';
|
|
171
|
+
end if;
|
|
172
|
+
if NEW.provenance = 'board-authored'
|
|
173
|
+
and (NEW.actor_agent_id is null or NEW.actor_agent_id = NEW.agent_id) then
|
|
174
|
+
raise exception 'substrate CHECK refuses board-authored actor = subject'
|
|
175
|
+
using errcode = '23514';
|
|
176
|
+
end if;
|
|
177
|
+
return NEW;
|
|
178
|
+
end;
|
|
179
|
+
$$;
|
|
180
|
+
|
|
181
|
+
drop trigger if exists trg_board_membership_events_provenance_guard
|
|
182
|
+
on agent_board.board_membership_events;
|
|
183
|
+
create trigger trg_board_membership_events_provenance_guard
|
|
184
|
+
before insert on agent_board.board_membership_events
|
|
185
|
+
for each row execute function agent_board._board_membership_events_provenance_guard();
|
|
186
|
+
|
|
187
|
+
-- =============================================================================
|
|
188
|
+
-- 2. Consent-read predicates (frozen contract)
|
|
189
|
+
-- =============================================================================
|
|
190
|
+
-- Classification precedence (strict): removal (result_state ∈ {left,removed})
|
|
191
|
+
-- first, then grant (provenance ∈ {target-authored, operator-designated}), else
|
|
192
|
+
-- neutral (skipped). Latest non-neutral event by seq decides.
|
|
193
|
+
|
|
194
|
+
create or replace function agent_board._membership_consent_present(
|
|
195
|
+
p_board_id uuid,
|
|
196
|
+
p_agent_id uuid
|
|
197
|
+
)
|
|
198
|
+
returns boolean
|
|
199
|
+
language sql
|
|
200
|
+
stable
|
|
201
|
+
security definer
|
|
202
|
+
set search_path = agent_board, public
|
|
203
|
+
as $$
|
|
204
|
+
select coalesce(
|
|
205
|
+
(
|
|
206
|
+
-- An operator-designated event counts as consent ONLY when it is an actual
|
|
207
|
+
-- PLACEMENT (joined|subscribed). It previously counted for ANY non-removal state,
|
|
208
|
+
-- so a bare operator `invited` OFFER -- an invitation the subject had never seen,
|
|
209
|
+
-- let alone answered -- made this predicate return true. This function's whole
|
|
210
|
+
-- contract is "did this subject consent", and it is the frozen interface
|
|
211
|
+
-- (IF-0-MBCONSENT-1) every future caller reads, so a pending offer must be NEUTRAL
|
|
212
|
+
-- here exactly as a board-authored offer already is.
|
|
213
|
+
select case
|
|
214
|
+
when e.result_state in ('left', 'removed') then false
|
|
215
|
+
when e.provenance = 'target-authored' then true
|
|
216
|
+
when e.provenance = 'operator-designated'
|
|
217
|
+
and e.result_state in ('joined', 'subscribed') then true
|
|
218
|
+
end
|
|
219
|
+
from agent_board.board_membership_events e
|
|
220
|
+
join agent_board.boards b on b.id = e.board_id
|
|
221
|
+
where e.board_id = p_board_id
|
|
222
|
+
and e.agent_id = p_agent_id
|
|
223
|
+
-- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
|
|
224
|
+
-- earlier genuine grant beneath it still stands. Treating it as a removal would silently
|
|
225
|
+
-- revoke the membership of someone who joined and LATER also filed a request.
|
|
226
|
+
-- This is the ONE place created_at is correct: ordering uses seq because created_at is
|
|
227
|
+
-- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
|
|
228
|
+
and not (
|
|
229
|
+
e.provenance = 'target-authored'
|
|
230
|
+
and e.result_state = 'requested'
|
|
231
|
+
and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
|
|
232
|
+
)
|
|
233
|
+
and (
|
|
234
|
+
e.result_state in ('left', 'removed')
|
|
235
|
+
or e.provenance = 'target-authored'
|
|
236
|
+
or (e.provenance = 'operator-designated'
|
|
237
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
238
|
+
)
|
|
239
|
+
order by e.seq desc
|
|
240
|
+
limit 1
|
|
241
|
+
),
|
|
242
|
+
false
|
|
243
|
+
);
|
|
244
|
+
$$;
|
|
245
|
+
|
|
246
|
+
comment on function agent_board._membership_consent_present(uuid, uuid) is
|
|
247
|
+
'MBCONSENT frozen consent-read: true iff the latest non-neutral event for the subject is a grant (target-authored or operator-designated), not a removal. Reads the ledger only; silent ledger = false. No membership_state fallback.';
|
|
248
|
+
|
|
249
|
+
create or replace function agent_board._membership_consent_kind(
|
|
250
|
+
p_board_id uuid,
|
|
251
|
+
p_agent_id uuid
|
|
252
|
+
)
|
|
253
|
+
returns text
|
|
254
|
+
language sql
|
|
255
|
+
stable
|
|
256
|
+
security definer
|
|
257
|
+
set search_path = agent_board, public
|
|
258
|
+
as $$
|
|
259
|
+
select (
|
|
260
|
+
-- MUST mirror _membership_consent_present exactly: an operator-designated event counts
|
|
261
|
+
-- only when it is an actual PLACEMENT (joined|subscribed), never a pending offer. These
|
|
262
|
+
-- two functions are read as a pair -- `_present` for the boolean, `_kind` for the reason --
|
|
263
|
+
-- so a narrowing applied to only one makes them disagree about the SAME event. That is
|
|
264
|
+
-- precisely what happened: `_present` was narrowed and this twin was not, so a pending
|
|
265
|
+
-- operator invite read present=false but kind='operator'.
|
|
266
|
+
select case
|
|
267
|
+
when e.result_state in ('left', 'removed') then null
|
|
268
|
+
when e.provenance = 'target-authored' then 'target'
|
|
269
|
+
when e.provenance = 'operator-designated'
|
|
270
|
+
and e.result_state in ('joined', 'subscribed') then 'operator'
|
|
271
|
+
end
|
|
272
|
+
from agent_board.board_membership_events e
|
|
273
|
+
join agent_board.boards b on b.id = e.board_id
|
|
274
|
+
where e.board_id = p_board_id
|
|
275
|
+
and e.agent_id = p_agent_id
|
|
276
|
+
-- An EXPIRED join request is NEUTRAL, not a refusal: it leaves the candidate set so an
|
|
277
|
+
-- earlier genuine grant beneath it still stands. Treating it as a removal would silently
|
|
278
|
+
-- revoke the membership of someone who joined and LATER also filed a request.
|
|
279
|
+
-- This is the ONE place created_at is correct: ordering uses seq because created_at is
|
|
280
|
+
-- transaction_timestamp() and ties within a transaction, but AGE is a wall-clock question.
|
|
281
|
+
and not (
|
|
282
|
+
e.provenance = 'target-authored'
|
|
283
|
+
and e.result_state = 'requested'
|
|
284
|
+
and e.created_at < now() - make_interval(days => b.join_request_ttl_days)
|
|
285
|
+
)
|
|
286
|
+
and (
|
|
287
|
+
e.result_state in ('left', 'removed')
|
|
288
|
+
or e.provenance = 'target-authored'
|
|
289
|
+
or (e.provenance = 'operator-designated'
|
|
290
|
+
and e.result_state in ('joined', 'subscribed'))
|
|
291
|
+
)
|
|
292
|
+
order by e.seq desc
|
|
293
|
+
limit 1
|
|
294
|
+
);
|
|
295
|
+
$$;
|
|
296
|
+
|
|
297
|
+
comment on function agent_board._membership_consent_kind(uuid, uuid) is
|
|
298
|
+
'MBCONSENT companion: ''target'' | ''operator'' | null for the strongest current consent-relevant event.';
|
|
299
|
+
|
|
300
|
+
revoke all on function agent_board._membership_consent_present(uuid, uuid) from public, anon;
|
|
301
|
+
revoke all on function agent_board._membership_consent_kind(uuid, uuid) from public, anon;
|
|
302
|
+
-- NOT granted to authenticated: it takes an arbitrary (board, agent) and would let any
|
|
303
|
+
-- authenticated caller read any subject's consent state, bypassing the subject-scoped RLS
|
|
304
|
+
-- on the underlying ledger. Its real callers are SECURITY DEFINER functions, which do not
|
|
305
|
+
-- need the caller to hold EXECUTE.
|
|
306
|
+
grant execute on function agent_board._membership_consent_present(uuid, uuid) to service_role;
|
|
307
|
+
-- Same reasoning as _membership_consent_present: an arbitrary (board, agent) signature that
|
|
308
|
+
-- would let any authenticated caller read another subject's consent PROVENANCE around the
|
|
309
|
+
-- subject-scoped RLS on the ledger. Revoking only the `_present` twin would have left the
|
|
310
|
+
-- strictly more informative one open.
|
|
311
|
+
|
|
312
|
+
insert into agent_board.board_membership_events(
|
|
313
|
+
board_id, agent_id, event_kind, result_state, provenance, actor_agent_id
|
|
314
|
+
)
|
|
315
|
+
-- DISTINCT ON is load-bearing: duplicate non-active rows for one (board, agent) are
|
|
316
|
+
-- permitted by the partial unique index (it constrains ACTIVE rows only), and the
|
|
317
|
+
-- `not exists` guard below is evaluated against the events table as it stood BEFORE this
|
|
318
|
+
-- statement -- so without deduplication every duplicate row emits its own event and a
|
|
319
|
+
-- single subject receives several. Verified: two pending rows produced two events where
|
|
320
|
+
-- exactly one is promised. Ordering by id makes the surviving pick deterministic.
|
|
321
|
+
select distinct on (bm.board_id, bm.agent_id)
|
|
322
|
+
bm.board_id,
|
|
323
|
+
bm.agent_id,
|
|
324
|
+
'backfill_neutral',
|
|
325
|
+
bm.membership_state,
|
|
326
|
+
'board-authored',
|
|
327
|
+
b.created_by_agent_id
|
|
328
|
+
from agent_board.board_memberships bm
|
|
329
|
+
join agent_board.boards b on b.id = bm.board_id
|
|
330
|
+
where bm.membership_state in ('requested', 'invited')
|
|
331
|
+
and b.created_by_agent_id is not null
|
|
332
|
+
and b.created_by_agent_id <> bm.agent_id
|
|
333
|
+
and not exists (
|
|
334
|
+
select 1
|
|
335
|
+
from agent_board.board_membership_events e
|
|
336
|
+
where e.board_id = bm.board_id
|
|
337
|
+
and e.agent_id = bm.agent_id
|
|
338
|
+
)
|
|
339
|
+
order by bm.board_id, bm.agent_id, bm.id;
|