@consiliency/agent-board-schema 1.63.0 → 1.64.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/README.md +6 -0
- package/fixtures/already-applied.json +4 -0
- package/migrations/20261014000200_agent_board_last_owner.sql +239 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +6 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +3 -1
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +7 -0
- package/tests/agent_board_fk_lock_edges.test.sql +3 -2
- package/tests/agent_board_grant_target_role.test.sql +4 -4
- package/tests/agent_board_hosted_delegates.test.sql +5 -3
- package/tests/agent_board_invite_no_downgrade.test.sql +5 -3
- package/tests/agent_board_ledger_authority.test.sql +7 -0
- package/tests/agent_board_role_guard.test.sql +319 -0
- package/tests/agent_board_rolereset.test.sql +17 -27
- package/tests/agent_board_roomdeleg.test.sql +8 -0
- package/tests/agent_board_roomown.test.sql +3 -1
- package/tests/agent_board_rowid.test.sql +4 -1
- package/tests/catalog/function_execute_privileges.json +9 -0
- package/tests/catalog/p_metadata_parameters.json +4 -0
- package/tests/isolation/expected/last_owner_departure.out +562 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +8 -3
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +0 -3
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +0 -3
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +0 -3
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +0 -2
- package/tests/isolation/specs/claimpolicy_board_scope.spec +0 -2
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +0 -2
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +0 -2
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +0 -2
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +0 -2
- package/tests/isolation/specs/consent_approval_lock.spec +0 -1
- package/tests/isolation/specs/consent_emit_actual_state.spec +0 -1
- package/tests/isolation/specs/grant_target_role_race.spec +0 -1
- package/tests/isolation/specs/hosted_delegate_add_tombstone_race.spec +0 -1
- package/tests/isolation/specs/hosted_delegate_tombstone_revoke_race.spec +0 -1
- package/tests/isolation/specs/hosted_delegate_withdraw_evict_race.spec +0 -1
- package/tests/isolation/specs/last_owner_departure.spec +137 -0
- package/tests/isolation/specs/ledger_authority_revoke.spec +0 -3
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +0 -3
- package/tests/isolation/specs/outbox_wake_ordering.spec +0 -1
- package/tests/isolation/specs/session_identity_links.spec +3 -1
- package/tests/isolation/specs/session_identity_tombstone_onboarding.spec +0 -1
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +0 -1
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +0 -1
package/README.md
CHANGED
|
@@ -7,6 +7,12 @@ and materialize it — they do not author `agent_board` DDL.
|
|
|
7
7
|
|
|
8
8
|
## What's in the bundle
|
|
9
9
|
|
|
10
|
+
Schema 1.64.0 adds `20261014000200_agent_board_last_owner.sql`: a refusal-only
|
|
11
|
+
table-level last-active-owner invariant, preserving the 1.63.0 role-grant policy.
|
|
12
|
+
Concurrent departures serialize per board; recovery remains service_role-only.
|
|
13
|
+
SQL schema version remains 8. See `specs/rpc-contracts.md` for refusal/retry
|
|
14
|
+
semantics. This is the working-tree contract, not a deployment receipt.
|
|
15
|
+
|
|
10
16
|
- `migrations/` — the ordered, immutable SQL union (frozen baseline + lane
|
|
11
17
|
migrations + the Message Board-owned endpoint compatibility migration).
|
|
12
18
|
- `schema-manifest.json` — package version, `agent_board.schema_version`, ordered
|
|
@@ -644,6 +644,10 @@
|
|
|
644
644
|
{
|
|
645
645
|
"id": "20261014000100_agent_board_grant_target_role",
|
|
646
646
|
"sha256": "4a6c00546a1d2837f88eece1de082cc76eec1f85cce8cf9ac7998e42bf32a5c5"
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
"id": "20261014000200_agent_board_last_owner",
|
|
650
|
+
"sha256": "2fed8fc5a2c7c0ba9acbb12872b329db3f0902d96597c52cc50e21e4682c2fff"
|
|
647
651
|
}
|
|
648
652
|
]
|
|
649
653
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
-- Consiliency/message-board#675: row-level last-active-owner invariant, schema 1.64.0.
|
|
2
|
+
-- Main's grant_board_role body, ACL and COMMENT remain unchanged (schema 1.63.0).
|
|
3
|
+
-- The trigger is refusal-only: it never assigns NEW or clamps a role. The
|
|
4
|
+
-- 20260802000100 / 20260803000100 prohibition concerns a role-changing clamp
|
|
5
|
+
-- that would erase intentional owner/admin seeds; this UPDATE/DELETE guard
|
|
6
|
+
-- leaves all allowed writes and INSERT seeders unchanged.
|
|
7
|
+
|
|
8
|
+
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)
|
|
9
|
+
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)
|
|
10
|
+
LANGUAGE plpgsql
|
|
11
|
+
SECURITY DEFINER
|
|
12
|
+
SET search_path TO 'agent_board', 'public'
|
|
13
|
+
AS $function$
|
|
14
|
+
declare
|
|
15
|
+
v_membership agent_board.board_memberships%rowtype;
|
|
16
|
+
v_board agent_board.boards%rowtype;
|
|
17
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
18
|
+
v_is_service boolean := agent_board._is_service_role();
|
|
19
|
+
v_existing_state text;
|
|
20
|
+
v_target_role text;
|
|
21
|
+
v_actor_role text;
|
|
22
|
+
-- HOSTED (D7): the caller is the target hosted delegate's principal asking for `left`.
|
|
23
|
+
v_principal_withdraw boolean;
|
|
24
|
+
begin
|
|
25
|
+
if v_actor is null and not v_is_service then
|
|
26
|
+
raise exception 'current agent principal not resolved' using errcode = '42501';
|
|
27
|
+
end if;
|
|
28
|
+
|
|
29
|
+
-- Board authority lock first, before the target row and the sink's FK locks.
|
|
30
|
+
perform agent_board._lock_board_authority_ceiling(p_board_id);
|
|
31
|
+
|
|
32
|
+
v_principal_withdraw := not v_is_service
|
|
33
|
+
and v_actor <> p_agent_id
|
|
34
|
+
and p_membership_state = 'left'
|
|
35
|
+
and v_actor = agent_board._delegate_principal_agent_id(p_agent_id);
|
|
36
|
+
|
|
37
|
+
if not v_is_service and v_actor <> p_agent_id and not v_principal_withdraw then
|
|
38
|
+
select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
|
|
39
|
+
if v_board.id is null then
|
|
40
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
41
|
+
end if;
|
|
42
|
+
select bm.role
|
|
43
|
+
into v_actor_role
|
|
44
|
+
from agent_board.board_memberships bm
|
|
45
|
+
where bm.board_id = p_board_id
|
|
46
|
+
and bm.agent_id = v_actor
|
|
47
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
48
|
+
limit 1;
|
|
49
|
+
if v_actor_role is null or v_actor_role not in ('owner', 'admin') then
|
|
50
|
+
raise exception 'board membership change requires owner or admin role'
|
|
51
|
+
using errcode = '42501';
|
|
52
|
+
end if;
|
|
53
|
+
end if;
|
|
54
|
+
|
|
55
|
+
-- HOSTED: LOCKED, so every transition rule below is decided on the state the sink will
|
|
56
|
+
-- overwrite. Unlocked, an admin's uncommitted `removed` committed after this read, and the D7
|
|
57
|
+
-- withdrawal (and an ordinary self-leave, the same branch shape) then wrote `left` over it in
|
|
58
|
+
-- the sink, erasing the eviction R-LINK-7 reads (hosted_delegate_withdraw_evict_race). The
|
|
59
|
+
-- sink's own first lock is this same row in the same order, so the lock set is unchanged;
|
|
60
|
+
-- it is only taken earlier, before reads that lock nothing.
|
|
61
|
+
select bm.membership_state, bm.role
|
|
62
|
+
into v_existing_state, v_target_role
|
|
63
|
+
from agent_board.board_memberships bm
|
|
64
|
+
where bm.board_id = p_board_id
|
|
65
|
+
and bm.agent_id = p_agent_id
|
|
66
|
+
order by
|
|
67
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
68
|
+
bm.created_at desc,
|
|
69
|
+
bm.id desc
|
|
70
|
+
limit 1
|
|
71
|
+
for update;
|
|
72
|
+
if v_existing_state is null then
|
|
73
|
+
raise exception 'board membership not found' using errcode = 'P0002';
|
|
74
|
+
end if;
|
|
75
|
+
|
|
76
|
+
if v_principal_withdraw then
|
|
77
|
+
-- HOSTED (D7): the owner withdraws its own delegate whatever its board role, but ONLY from
|
|
78
|
+
-- the states a self-leave may leave. Never from `removed`: turning an eviction into `left`
|
|
79
|
+
-- would erase the standing R-LINK-7 reads and let the owner re-add the evicted delegate.
|
|
80
|
+
if v_existing_state not in ('joined', 'subscribed', 'invited', 'requested') then
|
|
81
|
+
raise exception 'a principal may withdraw its delegate only from an active or pending membership'
|
|
82
|
+
using errcode = '42501';
|
|
83
|
+
end if;
|
|
84
|
+
elsif not v_is_service then
|
|
85
|
+
if v_actor = p_agent_id then
|
|
86
|
+
if not (
|
|
87
|
+
(p_membership_state = 'left'
|
|
88
|
+
and v_existing_state in ('joined', 'subscribed', 'invited', 'requested'))
|
|
89
|
+
or (p_membership_state in ('joined', 'subscribed')
|
|
90
|
+
and v_existing_state in ('joined', 'subscribed'))
|
|
91
|
+
or (p_membership_state = v_existing_state
|
|
92
|
+
and p_membership_state in ('left', 'removed'))
|
|
93
|
+
) then
|
|
94
|
+
raise exception 'self membership change not permitted; use the join/approval path'
|
|
95
|
+
using errcode = '42501';
|
|
96
|
+
end if;
|
|
97
|
+
else
|
|
98
|
+
if agent_board._role_rank(v_actor_role)
|
|
99
|
+
<= agent_board._role_rank(
|
|
100
|
+
case
|
|
101
|
+
when p_membership_state in ('joined', 'subscribed')
|
|
102
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
103
|
+
then 'member'
|
|
104
|
+
else coalesce(v_target_role, '')
|
|
105
|
+
end) then
|
|
106
|
+
raise exception 'board membership change requires a higher board role than the target'
|
|
107
|
+
using errcode = '42501';
|
|
108
|
+
end if;
|
|
109
|
+
|
|
110
|
+
if p_membership_state in ('joined', 'subscribed')
|
|
111
|
+
and coalesce(v_existing_state, '') not in ('joined', 'subscribed')
|
|
112
|
+
and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
|
|
113
|
+
raise exception 'admitting % requires an act by that agent', p_agent_id
|
|
114
|
+
using errcode = '42501';
|
|
115
|
+
end if;
|
|
116
|
+
|
|
117
|
+
-- INVITEPOLICY (#90): seating an inbound-disclosure membership ('invited'/'requested')
|
|
118
|
+
-- for a target that has blocked THIS actor is refused, mirroring invite_board_member.
|
|
119
|
+
-- Without this, an owner/admin re-seats the exact row the block refuses. Active-admission
|
|
120
|
+
-- states are already consent-gated above; service_role/self never reach this branch.
|
|
121
|
+
if p_membership_state in ('invited', 'requested')
|
|
122
|
+
and not agent_board._invite_permitted(p_board_id, p_agent_id, v_actor) then
|
|
123
|
+
raise exception 'target has blocked invitations from this inviter on this board'
|
|
124
|
+
using errcode = '42501';
|
|
125
|
+
end if;
|
|
126
|
+
end if;
|
|
127
|
+
end if;
|
|
128
|
+
|
|
129
|
+
if not v_is_service
|
|
130
|
+
and p_membership_state = v_existing_state
|
|
131
|
+
and p_membership_state in ('left', 'removed') then
|
|
132
|
+
return query
|
|
133
|
+
select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
|
|
134
|
+
from agent_board.board_memberships bm
|
|
135
|
+
where bm.board_id = p_board_id
|
|
136
|
+
and bm.agent_id = p_agent_id
|
|
137
|
+
order by
|
|
138
|
+
case when bm.membership_state in ('joined', 'subscribed') then 0 else 1 end,
|
|
139
|
+
bm.created_at desc,
|
|
140
|
+
bm.id desc
|
|
141
|
+
limit 1;
|
|
142
|
+
return;
|
|
143
|
+
end if;
|
|
144
|
+
|
|
145
|
+
select * into v_membership
|
|
146
|
+
from agent_board._upsert_board_membership_state(
|
|
147
|
+
p_board_id,
|
|
148
|
+
p_agent_id,
|
|
149
|
+
p_membership_state,
|
|
150
|
+
null,
|
|
151
|
+
p_metadata
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return query
|
|
155
|
+
select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
|
|
156
|
+
end;
|
|
157
|
+
$function$;
|
|
158
|
+
|
|
159
|
+
-- Consiliency/message-board#675: enforce at the table so every RPC, raw writer,
|
|
160
|
+
-- DELETE and FK cascade has the same invariant. Existing ownerless boards remain usable.
|
|
161
|
+
create or replace function agent_board._board_memberships_last_owner_guard()
|
|
162
|
+
returns trigger
|
|
163
|
+
language plpgsql
|
|
164
|
+
security definer
|
|
165
|
+
set search_path = ''
|
|
166
|
+
as $function$
|
|
167
|
+
declare
|
|
168
|
+
v_key bigint := pg_catalog.hashtextextended(
|
|
169
|
+
'agent_board.board_authority_ceilings:' || old.board_id::text, 0);
|
|
170
|
+
begin
|
|
171
|
+
if old.role <> 'owner' or old.membership_state not in ('joined', 'subscribed') then
|
|
172
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
173
|
+
end if;
|
|
174
|
+
if tg_op = 'UPDATE' then
|
|
175
|
+
if new.board_id = old.board_id and new.agent_id = old.agent_id and new.role = 'owner'
|
|
176
|
+
and new.membership_state in ('joined', 'subscribed') then
|
|
177
|
+
return new;
|
|
178
|
+
end if;
|
|
179
|
+
end if;
|
|
180
|
+
-- Deleting the board itself is not leaving an existing board ownerless.
|
|
181
|
+
if not exists (select 1 from agent_board.boards where id = old.board_id) then
|
|
182
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
183
|
+
end if;
|
|
184
|
+
-- A transaction snapshot does not advance after an advisory-lock wait.
|
|
185
|
+
if current_setting('transaction_isolation') <> 'read committed' then
|
|
186
|
+
raise exception 'last_active_board_owner: owner transitions require read committed'
|
|
187
|
+
using errcode = '25001';
|
|
188
|
+
end if;
|
|
189
|
+
-- Require the board mutex to have been acquired BEFORE the row lock. This
|
|
190
|
+
-- covers raw DML and cascades without taking a waiting lock in reverse order.
|
|
191
|
+
if not exists (
|
|
192
|
+
select 1 from pg_catalog.pg_locks l
|
|
193
|
+
where l.locktype = 'advisory' and l.pid = pg_catalog.pg_backend_pid()
|
|
194
|
+
and l.database = (select oid from pg_catalog.pg_database where datname = current_database())
|
|
195
|
+
and l.classid = ((v_key >> 32) & 4294967295)::oid
|
|
196
|
+
and l.objid = (v_key & 4294967295)::oid and l.objsubid = 1
|
|
197
|
+
and l.mode = 'ExclusiveLock' and l.granted
|
|
198
|
+
) then
|
|
199
|
+
raise exception 'last_active_board_owner: acquire board authority lock before owner transition'
|
|
200
|
+
using errcode = '55000';
|
|
201
|
+
end if;
|
|
202
|
+
-- Re-entrant: it CANNOT wait, since this backend already holds the exclusive
|
|
203
|
+
-- key. Promote a session-level lock to transaction lifetime so releasing the
|
|
204
|
+
-- session lock early cannot let a second departure miss our uncommitted exit.
|
|
205
|
+
perform pg_catalog.pg_advisory_xact_lock(v_key);
|
|
206
|
+
-- A separate volatile statement has a fresh read-committed snapshot.
|
|
207
|
+
if not exists (
|
|
208
|
+
select 1 from agent_board.board_memberships bm
|
|
209
|
+
where bm.board_id = old.board_id and bm.id <> old.id
|
|
210
|
+
and bm.role = 'owner' and bm.membership_state in ('joined', 'subscribed')
|
|
211
|
+
) then
|
|
212
|
+
raise exception 'last_active_board_owner: cannot remove the last active owner'
|
|
213
|
+
using errcode = '23514';
|
|
214
|
+
end if;
|
|
215
|
+
if tg_op = 'DELETE' then return old; else return new; end if;
|
|
216
|
+
end;
|
|
217
|
+
$function$;
|
|
218
|
+
|
|
219
|
+
revoke all on function agent_board._board_memberships_last_owner_guard()
|
|
220
|
+
from public, anon, authenticated, service_role, agent_board_delegate;
|
|
221
|
+
comment on function agent_board._board_memberships_last_owner_guard() is
|
|
222
|
+
'Consiliency/message-board#675: table-level UPDATE/DELETE guard against removing the last joined/subscribed owner of an existing board, including raw DML and cascades. No service-role bypass. Owner transitions require read committed and serialize to commit on the board authority advisory key; a pre-held exclusive board advisory lock is required (55000 otherwise) and reacquired re-entrantly for transaction lifetime. All waiting locks precede row locks. Existing ownerless boards and unrelated writes are unaffected; recovery by restoring ownership remains service_role-only.';
|
|
223
|
+
|
|
224
|
+
create trigger trg_board_memberships_last_owner_guard
|
|
225
|
+
before update or delete on agent_board.board_memberships
|
|
226
|
+
for each row execute function agent_board._board_memberships_last_owner_guard();
|
|
227
|
+
comment on trigger trg_board_memberships_last_owner_guard on agent_board.board_memberships is
|
|
228
|
+
'Refuse only a transition that removes an existing active owner and leaves no other active owner; every membership writer is covered by construction. Refusal-only: NEW is never rewritten. A future hard agent delete must acquire every owned board advisory lock before agent or membership row locks.';
|
|
229
|
+
|
|
230
|
+
revoke all on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
|
|
231
|
+
from public, anon, agent_board_delegate;
|
|
232
|
+
grant execute on function agent_board.update_board_membership(uuid,uuid,text,jsonb)
|
|
233
|
+
to authenticated, service_role;
|
|
234
|
+
|
|
235
|
+
comment on function agent_board.update_board_membership(uuid,uuid,text,jsonb) is
|
|
236
|
+
'Authorized membership state transitions with existing self, principal-withdrawal, rank, consent and idempotency rules. Consiliency/message-board#675: board advisory lock before target membership/FK locks; the table refuses any transition removing the last active owner.';
|
|
237
|
+
|
|
238
|
+
comment on function agent_board._lock_board_authority_ceiling(uuid) is
|
|
239
|
+
'Board-keyed transaction advisory lock serializing the ABSENCE of a board_authority_ceilings row, held to commit. WRITERS[serialized: set_board_authority_ceiling][UNSERIALIZED: (none)] READERS[serialized: add_delegate_to_board, grant_board_role, link_session_alias, mint_delegated_child, onboard_federated_fleet_agent, readmit_session_identity][UNSERIALIZED: _redeem_unbounded_invite_for_agent, create_invite_link] The SIDSCHEMA readers take the mutexes for every requested or existing descendant room before root, identity and membership locks. The two UNSERIALIZED readers still evaluate a missing ceiling as default-owner without serializing its absence; that known race (message-board#277) is not changed here. Both lists are machine-compared against the catalog; a new reader/writer or changed serialization state fails by name. Internal invoker helper, not an authorization gate; caller privileges determine table access. Consiliency/message-board#675: also the per-board departure mutex, acquired by update_board_membership before row locks and held through commit; all destructive owner transitions require this key, including direct writes and hard agent-deletion cascades.';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.64.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 162,
|
|
50
|
+
"aggregateSha256": "b6b24a1c50a1ac51cd935f6ad2091876a0b0ff98f001d776c89445a889416745",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1497,6 +1497,15 @@
|
|
|
1497
1497
|
"sourceRef": "message-board",
|
|
1498
1498
|
"bytes": 14704,
|
|
1499
1499
|
"sha256": "4a6c00546a1d2837f88eece1de082cc76eec1f85cce8cf9ac7998e42bf32a5c5"
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
"order": 161,
|
|
1503
|
+
"id": "20261014000200_agent_board_last_owner",
|
|
1504
|
+
"filename": "20261014000200_agent_board_last_owner.sql",
|
|
1505
|
+
"origin": "message-board-forward",
|
|
1506
|
+
"sourceRef": "message-board",
|
|
1507
|
+
"bytes": 13141,
|
|
1508
|
+
"sha256": "2fed8fc5a2c7c0ba9acbb12872b329db3f0902d96597c52cc50e21e4682c2fff"
|
|
1500
1509
|
}
|
|
1501
1510
|
],
|
|
1502
1511
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -24,6 +24,9 @@ const historyExact = () => ({ applied: ordered.map((m) => ({ id: m.id, sha256: m
|
|
|
24
24
|
const union = () => ordered.filter((m) => m.origin !== 'message-board-forward');
|
|
25
25
|
|
|
26
26
|
describe('manifest integrity', () => {
|
|
27
|
+
it('pins the exact owner-authorized A3/A10.3 test amendment for Consiliency/message-board#675', () => {
|
|
28
|
+
expect(sha256(readFileSync(new URL('../tests/agent_board_grant_target_role.test.sql', import.meta.url)))).toBe('5c0a857d8c138f368235fa345a18c76fafa773cfe8eb57185b232d466d222f93');
|
|
29
|
+
});
|
|
27
30
|
it('aggregate digest matches recomputation over ordered per-file digests', () => {
|
|
28
31
|
const agg = sha256(Buffer.from(ordered.map((e) => `${e.order} ${e.id} ${e.sha256}`).join('\n')));
|
|
29
32
|
expect(agg).toBe(manifest.aggregateSha256);
|
|
@@ -38,9 +41,9 @@ describe('manifest integrity', () => {
|
|
|
38
41
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
42
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
43
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE + one ROOMCONTRACT + one CALLBACKCONSUMER + one ENROLLTOKENSINGLEUSE + one ROOMCONTRACT-ACTIVEFIRST + one ONBOARD + one HOSTED + one HOSTED-FOLLOWUPS + one OWNPOST + one SIDSCHEMA + one SIDOWNER + one GRANTTARGETROLE (#674) forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 =
|
|
44
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT + one SESSIONCHALLENGESUPERSEDE + one ROOMCONTRACT + one CALLBACKCONSUMER + one ENROLLTOKENSINGLEUSE + one ROOMCONTRACT-ACTIVEFIRST + one ONBOARD + one HOSTED + one HOSTED-FOLLOWUPS + one OWNPOST + one SIDSCHEMA + one SIDOWNER + one GRANTTARGETROLE (#674) + one LASTOWNER (Consiliency/message-board#675) forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 162)', () => {
|
|
42
45
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
46
|
+
expect(manifest.migrationCount).toBe(162);
|
|
44
47
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
45
48
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
46
49
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -360,6 +363,7 @@ describe('manifest integrity', () => {
|
|
|
360
363
|
'20261012000100_agent_board_sidowner.sql',
|
|
361
364
|
// #674: grant_board_role checks the target's current role (C-A + C-B).
|
|
362
365
|
'20261014000100_agent_board_grant_target_role.sql',
|
|
366
|
+
'20261014000200_agent_board_last_owner.sql',
|
|
363
367
|
]) {
|
|
364
368
|
expect(names.has(f)).toBe(true);
|
|
365
369
|
}
|
|
@@ -700,6 +700,7 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
|
|
|
700
700
|
-- than naming the table itself. Internal — it reads rows for an arbitrary agent id with no
|
|
701
701
|
-- authorization of its own, so service_role only, like every other helper here.
|
|
702
702
|
'agent_board._actor_fleet_revoked(uuid) [definer] => service_role',
|
|
703
|
+
'agent_board._board_memberships_last_owner_guard() [definer] => (none)',
|
|
703
704
|
'agent_board._lock_board_authority_ceiling(uuid) [invoker] => service_role',
|
|
704
705
|
'agent_board._redeem_fleet_invite_for_agent(text,uuid) [definer] => service_role',
|
|
705
706
|
'agent_board._redeem_invite_for_agent(text,uuid) [definer] => service_role',
|
|
@@ -736,7 +737,8 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
|
|
|
736
737
|
'agent_board.signed_grant_board_role(jsonb,jsonb) [definer] => service_role',
|
|
737
738
|
-- SIDSCHEMA integrity repair: the verified Portal withdrawal now takes board mutexes
|
|
738
739
|
-- before the instance/family rows. Its service-only transport is unchanged.
|
|
739
|
-
'agent_board.tombstone_delegate_instance(text,text,text,text) [definer] => service_role'
|
|
740
|
+
'agent_board.tombstone_delegate_instance(text,text,text,text) [definer] => service_role',
|
|
741
|
+
'agent_board.update_board_membership(uuid,uuid,text,jsonb) [definer] => authenticated,service_role'
|
|
740
742
|
]::text[],
|
|
741
743
|
'every function on this migration set''s surface is declared with its definer flag and exact EXECUTE grantees'
|
|
742
744
|
);
|
|
@@ -837,6 +837,13 @@ select is(
|
|
|
837
837
|
'0/0', 'generic-invite rejection leaves uses and redemption counts unchanged'
|
|
838
838
|
);
|
|
839
839
|
|
|
840
|
+
-- Preserve a second owner while exercising the invite creator's lost membership.
|
|
841
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status)
|
|
842
|
+
values ('16210000-0000-0000-0000-000000000002', 'agent', 'atomic-runtime-coowner', 'Atomic Runtime Coowner', 'active');
|
|
843
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, role)
|
|
844
|
+
select board_id, '16210000-0000-0000-0000-000000000002', 'joined', 'owner' from _atomic_cfg;
|
|
845
|
+
select agent_board._lock_board_authority_ceiling(board_id) from _atomic_cfg;
|
|
846
|
+
|
|
840
847
|
update agent_board.board_memberships
|
|
841
848
|
set membership_state='removed', left_at=now(), updated_at=now()
|
|
842
849
|
where board_id=(select board_id from _atomic_cfg)
|
|
@@ -297,7 +297,8 @@ select is(
|
|
|
297
297
|
'grant_board_role: agents(share) then memberships(update) [advisory]',
|
|
298
298
|
'invite_board_member: memberships(update) then agents(key share) [unserialised]',
|
|
299
299
|
'join_board: memberships(update) then agents(key share) [unserialised]',
|
|
300
|
-
|
|
300
|
+
-- ROLEGUARD: the departure RPC serializes on the board key before its sink.
|
|
301
|
+
'leave_board: memberships(update) then agents(key share) [advisory]',
|
|
301
302
|
-- SIDSCHEMA role writers use the existing consent sink and board mutex.
|
|
302
303
|
-- Their installed lock order is asserted, not exempted from the class.
|
|
303
304
|
'link_session_alias: agents(update) then memberships(update) [advisory]',
|
|
@@ -319,7 +320,7 @@ select is(
|
|
|
319
320
|
-- SIDSCHEMA integrity repair: board mutexes precede owner/instance and family row locks,
|
|
320
321
|
-- matching add_delegate_to_board's board-before-instance order. No withdrawal exemption.
|
|
321
322
|
'tombstone_delegate_instance: agents(update) then memberships(update) [advisory]',
|
|
322
|
-
'update_board_membership: memberships(update) then agents(key share) [
|
|
323
|
+
'update_board_membership: memberships(update) then agents(key share) [advisory]'
|
|
323
324
|
]::text[],
|
|
324
325
|
'FKL-9: the agents+board_memberships lock class, FK edges included (section 9''s text scan sees 3 of these)');
|
|
325
326
|
|
|
@@ -611,9 +611,9 @@ select results_eq(format('select label, membership_state, role from pg_temp.g674
|
|
|
611
611
|
$$values ('G', 'joined', 'owner')$$,
|
|
612
612
|
'A3.pre');
|
|
613
613
|
select pg_temp.g674_as('A3', 'G');
|
|
614
|
-
select
|
|
614
|
+
select throws_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A3'), pg_temp.g674_a('A3', 'G'), 'admin'), '23514', 'last_active_board_owner: cannot remove the last active owner', 'A3.call');
|
|
615
615
|
select pg_temp.g674_service();
|
|
616
|
-
select is(pg_temp.g674_post('A3', 'G'), 'G[joined/
|
|
616
|
+
select is(pg_temp.g674_post('A3', 'G'), 'G[joined/owner/pinned] owners=1', 'A3.post');
|
|
617
617
|
|
|
618
618
|
-- A4: admin -> member, admin: ceiling 3<=3, then 3>2
|
|
619
619
|
insert into _g674_case values ('A4', pg_temp.g674_board('{"O": "owner", "G": "admin", "T": "member", "_creator": "O"}'::jsonb));
|
|
@@ -711,9 +711,9 @@ select results_eq(format('select label, membership_state, role from pg_temp.g674
|
|
|
711
711
|
$$values ('G', 'joined', 'owner')$$,
|
|
712
712
|
'A10.3.pre');
|
|
713
713
|
select pg_temp.g674_as('A10.3', 'G');
|
|
714
|
-
select
|
|
714
|
+
select throws_ok(format('select * from agent_board.grant_board_role(%L::uuid, %L::uuid, %L, null)', pg_temp.g674_b('A10.3'), pg_temp.g674_a('A10.3', 'G'), 'admin'), '23514', 'last_active_board_owner: cannot remove the last active owner', 'A10.3.call');
|
|
715
715
|
select pg_temp.g674_service();
|
|
716
|
-
select is(pg_temp.g674_post('A10.3', 'G'), 'G[joined/
|
|
716
|
+
select is(pg_temp.g674_post('A10.3', 'G'), 'G[joined/owner/pinned] owners=1', 'A10.3.post');
|
|
717
717
|
|
|
718
718
|
-- A10.4: A4 with the role gate on
|
|
719
719
|
insert into _g674_case values ('A10.4', pg_temp.g674_board('{"O": "owner", "G": "admin", "T": "member", "_creator": "O", "_gate": true}'::jsonb));
|
|
@@ -165,16 +165,17 @@ begin
|
|
|
165
165
|
end loop;
|
|
166
166
|
end $$;
|
|
167
167
|
|
|
168
|
-
-- Keyed agents: K (board owner/admin everywhere), K2 (member), Z1 (only on BZ)
|
|
168
|
+
-- Keyed agents: K (board owner/admin everywhere), K2 (member), Z1 (only on BZ),
|
|
169
|
+
-- and a coowner of BW so its principal-departure cascade leaves an active owner.
|
|
169
170
|
insert into auth.users(id, instance_id, aud, role, email)
|
|
170
171
|
select ('5e000000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
|
|
171
172
|
'00000000-0000-0000-0000-000000000000', 'authenticated', 'authenticated', 'hosted-k' || n || '@t.test'
|
|
172
|
-
from generate_series(1,
|
|
173
|
+
from generate_series(1, 4) n;
|
|
173
174
|
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
|
|
174
175
|
select ('5e100000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid, 'agent', 'agent',
|
|
175
176
|
'hosted-keyed-' || n, 'Hosted Keyed ' || n, 'active',
|
|
176
177
|
('5e000000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid
|
|
177
|
-
from generate_series(1,
|
|
178
|
+
from generate_series(1, 4) n;
|
|
178
179
|
select pg_temp.put('K', '5e100000-0000-4000-8000-000000000001'),
|
|
179
180
|
pg_temp.put('K.auth', '5e000000-0000-4000-8000-000000000001'),
|
|
180
181
|
pg_temp.put('K2', '5e100000-0000-4000-8000-000000000002'),
|
|
@@ -200,6 +201,7 @@ values (pg_temp.u('BX'), pg_temp.u('K'), 'owner', 'joined', pg_temp.u('K')),
|
|
|
200
201
|
(pg_temp.u('BZ'), pg_temp.u('K'), 'owner', 'joined', pg_temp.u('K')),
|
|
201
202
|
(pg_temp.u('BZ'), pg_temp.u('Z1'), 'member', 'joined', pg_temp.u('K')),
|
|
202
203
|
(pg_temp.u('BW'), pg_temp.u('OA'), 'owner', 'joined', pg_temp.u('OA')),
|
|
204
|
+
(pg_temp.u('BW'), '5e100000-0000-4000-8000-000000000004', 'owner', 'joined', pg_temp.u('OA')),
|
|
203
205
|
(pg_temp.u('BW'), pg_temp.u('K'), 'member', 'joined', pg_temp.u('OA'));
|
|
204
206
|
|
|
205
207
|
-- ===========================================================================
|
|
@@ -513,14 +513,16 @@ select is(
|
|
|
513
513
|
-- A new view over this table is then a loud event that forces its author to decide whether it
|
|
514
514
|
-- is writable, which is the decision that would otherwise be made silently.
|
|
515
515
|
select is(
|
|
516
|
-
(select coalesce(array_agg(t.tgname::text
|
|
516
|
+
(select coalesce(array_agg(t.tgname::text || ':' || t.tgtype::text || ':' || p.proname::text
|
|
517
|
+
order by t.tgname::text), array[]::text[])
|
|
517
518
|
from pg_trigger t
|
|
518
519
|
join pg_class c on c.oid = t.tgrelid
|
|
519
520
|
join pg_namespace n on n.oid = c.relnamespace
|
|
521
|
+
join pg_proc p on p.oid = t.tgfoid
|
|
520
522
|
where n.nspname = 'agent_board' and c.relname = 'board_memberships'
|
|
521
523
|
and not t.tgisinternal),
|
|
522
|
-
array[]::text[],
|
|
523
|
-
'#317:
|
|
524
|
+
array['trg_board_memberships_last_owner_guard:27:_board_memberships_last_owner_guard']::text[],
|
|
525
|
+
'#317: only the exact UPDATE/DELETE last-owner guard is installed; new state-rewriting triggers name themselves (preserved-state controls are in agent_board_role_guard)');
|
|
524
526
|
|
|
525
527
|
select is(
|
|
526
528
|
(select coalesce(array_agg(r.rulename::text order by r.rulename::text), array[]::text[])
|
|
@@ -652,6 +652,13 @@ select is(
|
|
|
652
652
|
|
|
653
653
|
select pg_temp.service();
|
|
654
654
|
|
|
655
|
+
-- Preserve a coowner while testing the returning owner's role clamp.
|
|
656
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
|
|
657
|
+
values ('27610000-0000-4000-8000-000000000007','agent','agent','k276-coowner','K276 Coowner','active');
|
|
658
|
+
insert into agent_board.board_memberships(board_id,agent_id,role,membership_state,created_by_agent_id)
|
|
659
|
+
values ('27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000007','owner','joined','27610000-0000-4000-8000-000000000003');
|
|
660
|
+
select agent_board._lock_board_authority_ceiling('27620000-0000-4000-8000-000000000002');
|
|
661
|
+
|
|
655
662
|
select lives_ok(
|
|
656
663
|
$$create temp table _cl as select agent_board._upsert_board_membership_state(
|
|
657
664
|
'27620000-0000-4000-8000-000000000002','27610000-0000-4000-8000-000000000003',
|