@consiliency/agent-board-schema 1.25.0 → 1.28.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.
@@ -0,0 +1,1036 @@
1
+ -- Consiliency/message-board#276 — a fleet-revoked agent loses BOARD AUTHORITY, not just SEND.
2
+ --
3
+ -- THE RATIFIED DECISION. Revoking a fleet agent removes its ability to GRANT BOARD ROLES as
4
+ -- well as to send. Fleet revocation is a principal-level act, not merely a machine-binding
5
+ -- change. This migration makes the schema say that.
6
+ --
7
+ -- THE DIVERGENCE THIS CLOSES, measured rather than recalled.
8
+ -- `agent_board.revoke_fleet_subject('agent', X)` (newest definition 20260901000100:1062)
9
+ -- writes an agent-subject `fleet_revocation_events` row and flips every
10
+ -- `fleet_machine_agents` binding for X to 'revoked'. It does NOT touch `agents.status` and
11
+ -- does NOT revoke X's signing keys — deliberately; `_revoke_agent_principal`
12
+ -- (20260901000100:1277) is the primitive that does both, and it is a DIFFERENT act.
13
+ -- `evaluate_fleet_sender_policy` (newest 20260826000100:120) consults the ledger, so X cannot
14
+ -- SEND. Every board-authority predicate reads `agents.status` and the board membership, and
15
+ -- NONE of the three authority chokepoints mentioned the ledger — enumerated from pg_proc, not
16
+ -- from memory. So the same principal, under the same revocation, got two answers: refused a
17
+ -- message, allowed to hand out `owner` on a board.
18
+ --
19
+ -- WHERE THE CHECK LIVES — ONE SITE PER CHOKEPOINT, BOTH ROUTES BY CONSTRUCTION.
20
+ -- `agent_board.grant_board_role` is granted to `authenticated` (20260723000200:72) and is
21
+ -- also the inner call of `signed_grant_board_role` (20260908000100:361). A catalog sweep over
22
+ -- EVERY namespace (`prosrc ~ '\mgrant_board_role\s*\('`, plus pg_views and pg_trigger) finds
23
+ -- exactly ONE other reference in the database: `agent_board.signed_grant_board_role`. So a
24
+ -- check inside `grant_board_role` is inherited by the signed route by construction — it
25
+ -- cannot be closed on one path and left open on the other. The same holds for
26
+ -- `set_board_authority_ceiling` (sole other referrer: `signed_set_board_authority_ceiling`)
27
+ -- and `set_board_role_gate` (no other referrer at all).
28
+ --
29
+ -- Putting the check in `_assert_signed_authority_signer_live` instead was rejected on the
30
+ -- record: it would close the NARROWER door (signed, service_role-only, evidence-recorded)
31
+ -- and leave the WIDER one (unsigned, `authenticated`, no evidence) open, while making both
32
+ -- teams believe ledger revocation was enforced. `_grant_within_ceiling` was rejected as the
33
+ -- single site for a different reason: `set_board_role_gate` never calls it, so it is not a
34
+ -- chokepoint of the class, and widening it would change invite-redemption semantics for
35
+ -- `create_invite_link` / `_redeem_unbounded_invite_for_agent` — the same shape as #269's
36
+ -- refusal to extend `_signed_write_actor_id`.
37
+ --
38
+ -- ============================================================================
39
+ -- JUDGEMENT 1 — WHAT "REVOKED" MEANS WHEN AN AGENT HAS SEVERAL FLEET BINDINGS.
40
+ -- ============================================================================
41
+ -- The predicate is the existence of an agent-subject `fleet_revocation_events` row. It
42
+ -- inspects NO binding. That is not a simplification; it is the answer
43
+ -- `evaluate_fleet_sender_policy` already gives to the identical question, and the two must
44
+ -- agree or #276 ships a third inconsistency while fixing the second:
45
+ --
46
+ -- * The sender policy selects a binding with `fma.lifecycle_state='active' and
47
+ -- fm.lifecycle_state='active'` and NO machine scope when `p_sender_machine_id` is null
48
+ -- (20260826000100:161-170) — so an agent whose binding on machine A was revoked while a
49
+ -- binding on machine B stays active still resolves a binding and still SENDS.
50
+ -- * It then denies unconditionally on
51
+ -- `fre.subject_kind='agent' and fre.subject_id = p_sender_agent_id`
52
+ -- (20260826000100:189) — an agent-subject ledger row denies the agent everywhere, on
53
+ -- every machine, regardless of surviving bindings.
54
+ --
55
+ -- So per-binding revocation (`revoke_fleet_machine_agent_binding`, and a machine or domain
56
+ -- revocation) is a PER-MACHINE fact that the sender policy answers by candidate selection,
57
+ -- while the agent-subject ledger row is the AGENT-WIDE fact. `revoke_fleet_subject('agent')`
58
+ -- writes exactly that agent-wide row and revokes every binding in one statement
59
+ -- (`where agent_id = p_subject_id`, no machine scope, 20260901000100:1251-1253) — the multi-binding
60
+ -- case cannot arise FROM an agent revocation. Authority therefore keys on the same row the
61
+ -- send path keys on, and "revoked from one machine, active on another" keeps board authority
62
+ -- exactly as it keeps send.
63
+ --
64
+ -- NO SUPERSESSION CLAUSE, and that asymmetry is deliberate and copied. The sender policy
65
+ -- honours `fleet_machines.reactivated_at` for MACHINE-subject events only (#146); its
66
+ -- trust_domain and agent clauses have no supersession, and `reactivate_fleet_machine`'s own
67
+ -- comment states it "never un-revokes the machine's trust_domain or sender agent". An
68
+ -- `effective_at` comparison here would be a divergence, not a refinement.
69
+ --
70
+ -- ============================================================================
71
+ -- JUDGEMENT 2 — AN AGENT WITH NO FLEET BINDING MUST NOT BE BLOCKED.
72
+ -- ============================================================================
73
+ -- Humans and non-fleet agents have no `fleet_machine_agents` row at all. A predicate phrased
74
+ -- as "the actor has an active binding" would refuse EVERY non-fleet grantor — a capability
75
+ -- regression far worse than the hole, and precisely the shape this repo's holes take (the
76
+ -- exemption category becomes the defect). The predicate here is the PRESENCE of a revocation
77
+ -- row, so a principal that was never in a fleet is unaffected by construction: there is no
78
+ -- row to find, no binding is consulted, and the function returns without raising.
79
+ --
80
+ -- The exemption is asserted POSITIVELY in agent_board_ledger_authority.test.sql, never by the
81
+ -- absence of a match: a non-fleet agent completes a real grant and the granted role is read
82
+ -- back. The inverse mutation (make the helper raise when NO row exists) fails that assertion,
83
+ -- which is what makes it load-bearing rather than decorative.
84
+ --
85
+ -- ============================================================================
86
+ -- JUDGEMENT 3 — THE LOCK, AND THE TOTAL ORDER FOR BOTH ROUTES.
87
+ -- ============================================================================
88
+ -- The read IS locked. An unlocked `exists` would leave exactly the TOCTOU #269 refused to
89
+ -- accept one function away: the gateway/authenticated caller reads "no revocation", a
90
+ -- concurrent `revoke_fleet_subject('agent')` commits, and the grant lands anyway. #269 closed
91
+ -- that window for `agents.status`; leaving it open for the ledger in the same call graph
92
+ -- would be an inconsistency with a documented decision.
93
+ --
94
+ -- The lock is `agents` FOR SHARE on the ACTOR — the same row, in the same mode, that
95
+ -- `_assert_signed_authority_signer_live` already takes, chosen for the same reasons (FOR KEY
96
+ -- SHARE would not conflict with a plain status change's FOR NO KEY UPDATE; FOR UPDATE would
97
+ -- block every concurrent FK insert referencing the agent). It serializes because BOTH writers
98
+ -- of an agent-subject ledger row take `agents` FOR UPDATE BEFORE the write, enumerated from
99
+ -- pg_proc rather than assumed:
100
+ --
101
+ -- agent_board.revoke_fleet_subject fleet_machines(FOR UPDATE, uuid order)
102
+ -- -> agents(FOR UPDATE) :1116
103
+ -- -> fleet_machine_agents(FOR UPDATE)
104
+ -- -> fleet_revocation_events(write) :1195
105
+ -- agent_board._revoke_agent_principal agents(FOR UPDATE) :1292
106
+ -- -> agent_signing_keys(FOR UPDATE, uuid order)
107
+ -- -> fleet_revocation_events(write) :1353
108
+ --
109
+ -- Both interleavings are fail-closed. A revocation that committed earlier is SEEN (the share
110
+ -- lock reads the latest committed row). One that starts later BLOCKS on the share lock until
111
+ -- the authority write commits, and lands afterwards — a grant that beat the revocation, which
112
+ -- is the same outcome as a grant one millisecond earlier. That "every agent-subject ledger
113
+ -- writer locks `agents` first" property is itself pinned by a catalog assertion, so a future
114
+ -- writer that skips the lock fails the suite and names itself instead of silently reopening
115
+ -- this window.
116
+ --
117
+ -- TOTAL ORDERS. The canonical order this schema already uses is
118
+ -- boards -> auth.users -> agents -> board_memberships -> agent_signing_keys
119
+ -- (#194 established boards before auth.users after a reproduced `deadlock detected`;
120
+ -- onboard_federated_fleet_agent takes auth.users:751 -> agents:760 -> board_memberships:769,
121
+ -- and later agents:945 -> agent_signing_keys:946). The new lock is inserted at `agents`'
122
+ -- canonical slot in each function — which is BEFORE the write in `grant_board_role` (whose
123
+ -- first lock is board_memberships) and AFTER the write in the two setters (whose first lock
124
+ -- is boards). The placement is therefore not stylistic and must not be "tidied" to be uniform.
125
+ --
126
+ -- `grant_board_role` additionally takes the board advisory lock, which it did not before. That
127
+ -- was not a design choice: adding the ledger gate makes it lock BOTH agents and
128
+ -- board_memberships, and agent_board_signer_lifecycle.test.sql section 9 pins from the call
129
+ -- graph that every member of that class takes the advisory lock. The assertion failed and named
130
+ -- this function; the lock was added rather than the assertion weakened (see the comment at the
131
+ -- call site). It also retires one of the three `UNSERIALIZED` ceiling-authorization readers
132
+ -- 20260901000110 left open and declined to determine.
133
+ --
134
+ -- grant_board_role (unsigned, authenticated)
135
+ -- advisory(board) -> agents(SHARE) -> board_memberships(UPDATE)
136
+ -- signed_grant_board_role
137
+ -- advisory(board) -> advisory(board, re-entrant no-op inside the inner grant)
138
+ -- -> agents(SHARE) -> board_memberships(UPDATE)
139
+ -- -> agents(SHARE, same row, no-op) -> agent_signing_keys(SHARE)
140
+ -- set_board_authority_ceiling (unsigned and signed)
141
+ -- advisory(board) -> boards(KEY SHARE, via board_authority_ceilings FK)
142
+ -- -> agents(SHARE) [-> agent_signing_keys(SHARE) on the signed route]
143
+ -- set_board_role_gate (authenticated only — service_role has no owner membership)
144
+ -- boards(UPDATE) -> agents(SHARE)
145
+ --
146
+ -- Checked by name against the three functions #269 F4 named:
147
+ -- * revoke_fleet_subject — fleet_machines -> agents -> fleet_machine_agents. It
148
+ -- acquires `agents` while holding fleet_machines rows, which NO authority chokepoint
149
+ -- touches, and it touches neither boards nor board_memberships. No shared second operand,
150
+ -- so no cycle in either direction.
151
+ -- * _revoke_agent_principal — agents -> agent_signing_keys. Same order as every route
152
+ -- above (agents before keys); it takes no boards or board_memberships lock.
153
+ -- * onboard_federated_fleet_agent — advisory -> boards -> auth.users -> agents ->
154
+ -- board_memberships -> agents -> agent_signing_keys. Every route above is a subsequence
155
+ -- of that order, which is the point of siting the lock at the canonical slot. This is what
156
+ -- makes the placement asymmetric: hoisting the check ABOVE the write in the two setters
157
+ -- would give `agents -> boards`, inverting against onboarding and against the federated
158
+ -- enroll routes on the (agents, boards) pair — the exact ABBA #269 F4 reproduced. Pushing
159
+ -- it BELOW the write in `grant_board_role` would give `board_memberships -> agents`, which
160
+ -- is the inversion #269's own header records reproducing as `deadlock detected` — and the
161
+ -- unsigned route has no advisory lock to be rescued by.
162
+ --
163
+ -- Correctness is unaffected by the position in the two setters: the function is one
164
+ -- transaction, so a raise after the write rolls the write back. This is #269's ratified
165
+ -- argument for the same placement, not a new one.
166
+ --
167
+ -- ============================================================================
168
+ -- THE INVITE PATHS — WHY MINT IS LOCKED AND REDEEM IS NOT.
169
+ -- ============================================================================
170
+ -- Gating the three chokepoints alone left an equivalent route open, and shipping that would
171
+ -- have been worse than the gap: the header would have claimed a property the schema did not
172
+ -- have. `board_invite_tokens_preset_role_check` permits an `admin` preset; `create_invite_link`
173
+ -- refuses only `owner` presets (R-LINK-3) and otherwise gates on `_grant_within_ceiling`, which
174
+ -- reads the creator's ROLE and never the ledger; `_redeem_unbounded_invite_for_agent` writes
175
+ -- `preset_role` into `board_memberships`. So a ledger-revoked owner or admin — still active,
176
+ -- still a member, keys intact, because revocation deliberately leaves all three alone — could
177
+ -- mint an admin link and have the redeemer become admin. Conferring admin through a link is
178
+ -- granting a board role; only the route differs. BOTH halves are gated: gating mint alone
179
+ -- leaves outstanding links live for up to their TTL, and gating redeem alone lets a revoked
180
+ -- agent keep stockpiling codes for later.
181
+ --
182
+ -- WHOSE LEDGER STATE GOVERNS REDEMPTION: THE CREATOR'S. This is not a new policy — the
183
+ -- function already implements it twice. R-LINK-2 requires the CREATOR to still be an `active`
184
+ -- agent, and the re-bind requires the CREATOR's current authority to still cover the preset.
185
+ -- #276 adds a third clause to that same creator-governed block. The redeemer is RECEIVING a
186
+ -- membership, not conferring one; its own standing is handled by `current_agent_id()` (which
187
+ -- resolves only active agents) and by the R-LINK-7 removed-agent clause. A fleet-revoked
188
+ -- redeemer therefore stays able to redeem, which is correct.
189
+ --
190
+ -- MINT IS LOCKED, REDEEM IS NOT, AND THE ASYMMETRY IS THE WHOLE POINT.
191
+ --
192
+ -- MINT. The actor IS the principal whose authority is spent, exactly as in the three
193
+ -- chokepoints, so it gets the same share-locked assert. Placed AFTER the token insert, the
194
+ -- order is boards(KEY SHARE, through board_invite_tokens.board_id) -> agents(SHARE), which is
195
+ -- canonical. Its only in-schema caller, create_fleet_onboarding_invite, already holds
196
+ -- boards(FOR UPDATE) -> fleet_machines(FOR UPDATE) -> fleet_trust_domains(FOR UPDATE) before
197
+ -- it mints, so the agents acquisition is last in that path too; and revoke_fleet_subject
198
+ -- takes fleet_machines -> agents in the SAME direction, so the pair cannot invert.
199
+ --
200
+ -- WHAT THE MINT LOCK ACTUALLY BUYS: nothing, on this path, and that is recorded rather than
201
+ -- claimed. Mutation-tested by removing the FOR SHARE: the mint isolation permutation is
202
+ -- BYTE-IDENTICAL — it still waits, still fails closed, still leaves no token. The reason is
203
+ -- board_invite_tokens_created_by_agent_id_fkey: the token INSERT already takes FOR KEY SHARE
204
+ -- on agents(creator), which conflicts with the revoker's FOR UPDATE, and the insert precedes
205
+ -- the gate. So the atomicity on the mint path is supplied by the FK, and by the time the gate
206
+ -- runs this transaction already holds that row. The assert is kept anyway — one helper and
207
+ -- one discipline across all five gated sites beats a second, subtly different predicate, and
208
+ -- upgrading KEY SHARE to SHARE on a row this transaction already holds adds no new wait — but
209
+ -- nothing here should be read as "the FOR SHARE is what serializes the mint". It is not. The
210
+ -- isolation permutation pins the OUTCOME (blocks, then fails closed, no token escapes); it
211
+ -- does not pin this lock, and no permutation can, because the insert always precedes the gate.
212
+ --
213
+ -- REDEEM. Every way of taking a row lock on the creator here CLOSES A CYCLE. Enumerated
214
+ -- rather than asserted:
215
+ --
216
+ -- * The board advisory lock — the fix signed_grant_board_role needed — is the one option
217
+ -- that can be ruled out with a line number. `_redeem_unbounded_invite_for_agent` is
218
+ -- reached from `enroll_federated_identity`, which takes `boards FOR KEY SHARE` at
219
+ -- 20260903000100:112-114 — hoisted to exactly that point by #194 to fix a REPRODUCED
220
+ -- `deadlock detected` — and only reaches the redemption at :244. Taking the advisory
221
+ -- lock inside the redemption therefore acquires it while `boards` is already held,
222
+ -- giving boards -> advisory against onboard_federated_fleet_agent's advisory -> boards.
223
+ -- That is #269 F4's shape, created by the fix rather than prevented by it.
224
+ -- * `agents FOR SHARE` BEFORE the membership write gives, on the standalone
225
+ -- `redeem_invite_link` path (which holds no boards lock of its own),
226
+ -- agents -> boards(KEY SHARE via the membership FK) — inverting against onboarding's
227
+ -- boards -> agents.
228
+ -- * `agents FOR SHARE` AFTER the membership write gives board_memberships -> agents,
229
+ -- inverting against onboarding's agents(FOR UPDATE) -> board_memberships; SHARE and
230
+ -- FOR UPDATE do conflict, so that one is a real wait on both sides.
231
+ --
232
+ -- So the ledger read here is UNLOCKED, via _actor_fleet_revoked.
233
+ --
234
+ -- THE GENERAL RULE, stated because the next reader will ask why this check is unlocked when
235
+ -- #269 locked and this migration locks four other sites: LOCK WHEN THE REVOKED PRINCIPAL IS
236
+ -- THE TRANSACTION'S OWN ACTOR; DO NOT WHEN IT IS A THIRD PARTY. In a chokepoint the actor's
237
+ -- `agents` row is already in the transaction's natural lock order, so the lock is free and
238
+ -- refusing it would be gratuitous. In redemption the revoked principal is the link's CREATOR,
239
+ -- who is not otherwise in this transaction at all, so acquiring its row is a brand-new
240
+ -- cross-relation edge — and in this particular call graph, one with a reproduced ABBA
241
+ -- history, every placement of that edge closes a cycle (enumerated above). Both decisions
242
+ -- stay defensible under that rule; neither is defensible under "always lock" or "never lock".
243
+ --
244
+ -- Decisively, the ADJACENT check already follows it: R-LINK-2's creator-status test takes no
245
+ -- lock at all, and this clause is the same predicate class about the same principal at the
246
+ -- same point. Locking one and not the other would be incoherent whichever way it went.
247
+ --
248
+ -- THE EXACT RESIDUAL BOUND, because an inexact disclosure is worse than none. It is NOT
249
+ -- "one redemption" — that was the first wording here and it was wrong in the general case.
250
+ -- It is AT MOST ONE REDEMPTION PER OUTSTANDING TOKEN, and the reason is a lock that is
251
+ -- already here: this function takes `board_invite_tokens FOR UPDATE` as its FIRST statement,
252
+ -- before the ledger read. So two redemptions of the SAME token cannot both be between their
253
+ -- ledger read and their commit — the second blocks on the token row, and when it proceeds it
254
+ -- takes a fresh snapshot and sees the committed revocation. Redemptions of DIFFERENT tokens
255
+ -- share no row and are independent, so a creator holding K outstanding links can have up to
256
+ -- K redemptions slip, one per link. K is bounded by what that creator minted before it was
257
+ -- revoked, and each link's own `max_uses` bounds nothing here (the cap limits total uses, not
258
+ -- the size of this window). The mint gate means K cannot grow after the revocation.
259
+ --
260
+ -- Both halves of that bound are pinned rather than argued: a positional assertion requires
261
+ -- the token lock to precede the ledger read (delete it and the per-token half of this
262
+ -- sentence stops being true), and an isolation permutation drives two redemptions of ONE
263
+ -- token across a committing revocation, recording the first succeeding and the second
264
+ -- refused.
265
+ --
266
+ -- UNIFORM ERROR ON THE REDEEM SIDE. Every deny arm in the redemption raises the same
267
+ -- 'invite is not usable' 42501 so a code-bearer cannot distinguish a revoked creator from a
268
+ -- dead, expired or exhausted link. The #276 clause uses that same message: a `fleet_revoked:`
269
+ -- prefix there would build the lifecycle oracle the function documents refusing to build, and
270
+ -- would leak it to whoever holds the code — who is not the principal being described. Mint
271
+ -- keeps the prefix, because there the caller is asking about ITSELF.
272
+ --
273
+ -- FLEET ONBOARDING INHERITS THE REDEEM GATE through `_redeem_fleet_invite_for_agent`: a fleet
274
+ -- onboarding invite minted by a since-revoked owner stops admitting. That is a real behaviour
275
+ -- change on a live path, and it is what R-LINK-2 already does on suspension. Asserted
276
+ -- positively rather than left to be discovered.
277
+ --
278
+ -- ============================================================================
279
+ -- SCOPE — THE CLASS, DETERMINED FROM pg_catalog.
280
+ -- ============================================================================
281
+ -- The issue names role grants. The class is every function that WRITES a board-authority
282
+ -- relation, derived by scanning stripped bodies (never raw prosrc — #196/#199) for
283
+ -- ANY `insert into agent_board.board_memberships`
284
+ -- `update agent_board.board_memberships … set … role =`
285
+ -- `insert into|update agent_board.board_authority_ceilings`
286
+ -- `update agent_board.boards … set … role_gate_enforced =`
287
+ --
288
+ -- THE MEMBERSHIPS ARM IS SHAPE-BLIND ON PURPOSE, and it did not start that way. The first
289
+ -- version matched only the UPDATE shape, and review found two writes it could not see:
290
+ -- `create_board` — grant execute to `authenticated` (20260515000000:484, restated
291
+ -- 20260723000100:118) — seeds `role = 'owner'` with an INSERT, and its
292
+ -- `on conflict … do update set role = 'owner'` is an UPDATE of bm.role that the UPDATE-shaped
293
+ -- regex STILL cannot match, because the table name precedes `do update`. So the arm now matches
294
+ -- any insert into the relation at all. Measured against the shipped catalog, the shape-blind
295
+ -- form costs nothing — it names exactly the same five membership writers the role-token form
296
+ -- did — and it is strictly stronger: on-conflict clauses exist only inside an insert, so it
297
+ -- subsumes that shape by construction, and it also catches a positional insert naming no
298
+ -- columns, which any column-name-matching form would miss. Generalising the shape is the whole
299
+ -- lesson: a guard that enumerates the syntax it expects is evaded by the syntax it did not.
300
+ --
301
+ -- A FOURTH ARM covers the invite MINT: any `insert into agent_board.board_invite_tokens`. A
302
+ -- token carrying a preset_role IS a deferred grant, so the relation belongs in the class even
303
+ -- though it is not a membership. INSERT only, deliberately: `revoke_invite_link` UPDATEs the
304
+ -- same table to REDUCE authority, and a revoked agent killing its own outstanding links is
305
+ -- desirable, not something to gate. The arm is shape-blind for the same reason the memberships
306
+ -- arm is; the catalog confirms `create_invite_link` is its only inserter
307
+ -- (`create_fleet_onboarding_invite` delegates to it rather than writing the table).
308
+ --
309
+ -- Against the shipped catalog that set is exactly nine:
310
+ -- GATED HERE (5): grant_board_role, set_board_authority_ceiling, set_board_role_gate,
311
+ -- create_invite_link, _redeem_unbounded_invite_for_agent
312
+ -- EXEMPT (4): _upsert_board_membership_state, create_board,
313
+ -- _fedsession_autojoin_default_board, bootstrap_board_owner_memberships
314
+ -- All five gated: leaving either setter open would let a revoked owner refused a role grant
315
+ -- still RAISE the board ceiling (which changes what every grantor may hand out) or flip
316
+ -- `role_gate_enforced` (which disables the creator-exemption enforcement on invites), and
317
+ -- leaving either invite half open would leave an equivalent route to conferring `admin`.
318
+ --
319
+ -- THE FOUR EXEMPTIONS, each disposed of POSITIVELY and never by absence of a match. Every
320
+ -- clause below is a live assertion in agent_board_ledger_authority.test.sql that reads the
321
+ -- catalog or drives the function, not a claim in this comment:
322
+ -- * `_upsert_board_membership_state` — its INSERT branch omits `role` entirely (table
323
+ -- default) and its UPDATE branch is the ROLERESET clamp: the literal 'member' on
324
+ -- non-active -> active re-entry, `bm.role` otherwise, so it can only ever DEMOTE. Its
325
+ -- EXACT identity-argument string is asserted (not a `role` word match — `_` is a word
326
+ -- character in POSIX regex, so `\mrole\M` does NOT match `p_role`, and every parameter in
327
+ -- this codebase is `p_*`; the word-match form was evadable by adding `p_role`). Not
328
+ -- EXECUTE-able by authenticated/anon/public.
329
+ -- * `create_board` — the one authenticated-callable exemption. It writes the literal 'owner'
330
+ -- for the board's own `created_by_agent_id` (the resolved actor) on a board it just
331
+ -- inserted; its exact identity-argument string is asserted, so it has no role parameter to
332
+ -- be handed. The slug-COLLISION branch refuses any actor without an active owner membership
333
+ -- and deliberately does NOT re-seed (20260723000100), so it cannot elevate a membership on
334
+ -- a pre-existing board. What it CAN do is manufacture a brand-new board with the actor as
335
+ -- owner while that actor is ledger-revoked. That is the residual below, and it is asserted
336
+ -- as behaviour rather than described.
337
+ -- * `_fedsession_autojoin_default_board` — service_role only; the literal 'member', the base
338
+ -- role, so it cannot confer authority at all.
339
+ -- * `bootstrap_board_owner_memberships` — service_role only, zero arguments; it backfills the
340
+ -- board's own recorded `owner_agent_id`, conferring nothing the board did not already say.
341
+ --
342
+ -- NAMED RESIDUAL, deliberately out of scope (Consiliency/message-board#280). It is now ONE
343
+ -- function, not three: the invite paths named in the first revision of this header are GATED
344
+ -- above, so the only remaining way a ledger-revoked agent touches board authority is
345
+ -- `create_board` — creating a fresh board and being seeded its owner (`agents.status` stays
346
+ -- 'active' under fleet revocation, which is this migration's own precondition).
347
+ --
348
+ -- It is left open on a stated argument, not by omission: it confers nothing on ANYONE ELSE.
349
+ -- The ratified criterion is about granting board roles, and here there is no grantee — the
350
+ -- board did not exist a moment earlier, so no pre-existing principal or resource comes under
351
+ -- the revoked agent's authority, and the collision branch already refuses a non-owner and does
352
+ -- not re-seed. Both halves are asserted as behaviour in the pgTAP file (it CAN create the board
353
+ -- and be seeded owner; it still CANNOT grant a role on the board it just created, nor mint a
354
+ -- link there), so #280 closing flips a test deliberately rather than silently.
355
+ --
356
+ -- A SECOND BOUNDED RESIDUAL, recorded so the next reader does not have to re-derive it, and
357
+ -- recorded CONDITIONALLY because the condition is the whole argument. The plain membership
358
+ -- admission RPCs — `invite_board_member`, `update_board_membership`, `resolve_board_request`,
359
+ -- and the rest of the `_upsert_board_membership_state` caller set — are NOT gated. A
360
+ -- ledger-revoked agent that still holds a board seat can therefore still admit plain members
361
+ -- to a board it already belongs to, and can still move membership STATE.
362
+ --
363
+ -- That is outside the ratified criterion, and the reason is mechanical rather than a judgement
364
+ -- call: NONE of those functions declares a role-bearing parameter, so none of them can confer
365
+ -- a ROLE. They admit at the membership floor, and a `member` holds no authority to grant
366
+ -- anything onward — so no authority is transferred and there is nothing for the criterion
367
+ -- ("removes its ability to grant board roles") to bite on. Extending the gate to them would be
368
+ -- an extension of the owner's decision, not an application of it.
369
+ --
370
+ -- THE CONDITION IS ASSERTED, NOT ASSUMED. `EXEMPTION A4` in agent_board_ledger_authority.test.sql
371
+ -- sweeps the ENTIRE caller set of `_upsert_board_membership_state` and pins each one's EXACT
372
+ -- identity-argument string. If anybody ever adds a role parameter to one of those functions —
373
+ -- or adds a new caller that has one — that assertion fails and names it, and the paragraph
374
+ -- above stops being true at the same moment. This claim is deliberately written as conditional
375
+ -- so that the day the condition breaks, the record already says so rather than having to be
376
+ -- re-litigated.
377
+ --
378
+ -- KNOWN, DELIBERATE GAP, inherited and restated so the class assertion is not over-read:
379
+ -- service_role retains direct INSERT/UPDATE grants on `board_authority_ceilings` and `boards`
380
+ -- (operator provisioning, 20260717000400). A direct table write bypasses every function-level
381
+ -- gate, including this one. Only the RPC path is covered. Closing that needs a table trigger
382
+ -- and is a separate decision.
383
+ --
384
+ -- BLAST RADIUS. The three functions keep their signatures, so CREATE OR REPLACE preserves
385
+ -- their ACLs; the revokes, grants and COMMENTs are restated anyway because the behaviour
386
+ -- changed and because this repo has been bitten by a recreation that silently re-granted
387
+ -- PUBLIC. The helper is a fresh CREATE and therefore restates revoke/grant/comment in this
388
+ -- same migration. No caller signature changes: the SDK (`grantBoardRole`,
389
+ -- `setBoardAuthorityCeiling`, `setBoardRoleGate`), the MCP tools and the CLI all call the
390
+ -- same RPCs with the same arguments. The only behavioural delta is a new 42501 for a
391
+ -- principal carrying an agent-subject revocation row — which is precisely the decision.
392
+ -- A service_role caller that SUPPLIES a revoked `p_grantor_agent_id` is refused too: the
393
+ -- check binds the PRINCIPAL the write is attributed to, not the connection role. That is
394
+ -- correct under the ratified decision — a gateway acting for a revoked agent is the case
395
+ -- #276 was filed about — and it is asserted, not assumed.
396
+
397
+ -- ---------------------------------------------------------------------------
398
+ -- 1a. The ledger predicate itself, WITHOUT a lock.
399
+ --
400
+ -- Split out from the assert helper for the invite REDEEM path, which must consult the ledger
401
+ -- but must not take a row lock — see "THE INVITE PATHS" in the header for the reproduced
402
+ -- cycles that lock would create. Keeping the predicate in one function means the locked and
403
+ -- unlocked callers cannot drift into asking two different questions, and the class assertion
404
+ -- can count both call shapes as "consults the ledger".
405
+ -- ---------------------------------------------------------------------------
406
+ create or replace function agent_board._actor_fleet_revoked(p_actor_agent_id uuid)
407
+ returns boolean
408
+ language sql
409
+ stable
410
+ security definer
411
+ set search_path = agent_board, public
412
+ as $$
413
+ select exists (
414
+ select 1
415
+ from agent_board.fleet_revocation_events fre
416
+ where fre.subject_kind = 'agent'
417
+ and fre.subject_id = p_actor_agent_id
418
+ );
419
+ $$;
420
+
421
+ comment on function agent_board._actor_fleet_revoked(uuid) is
422
+ '#276 fleet-revocation predicate: true when an agent-subject agent_board.fleet_revocation_events row exists for this principal. AGENT-WIDE and binding-blind, matching evaluate_fleet_sender_policy''s agent clause, with no effective_at/supersession term (the sender policy honours reactivated_at for MACHINE-subject events only). Takes NO lock: callers that can safely serialise go through _assert_actor_not_fleet_revoked, which share-locks the agents row first; the invite redeem path calls this directly because acquiring the creator''s agents row there closes a deadlock cycle against enroll_federated_identity and onboard_federated_fleet_agent (see 20260909000100 header). Internal: reads for an arbitrary agent id with no authorization of its own.';
423
+
424
+ revoke all on function agent_board._actor_fleet_revoked(uuid) from public, anon, authenticated;
425
+ grant execute on function agent_board._actor_fleet_revoked(uuid) to service_role;
426
+
427
+ -- ---------------------------------------------------------------------------
428
+ -- 1b. The LOCKED assert, for callers whose actor is the revoked principal itself.
429
+ -- ---------------------------------------------------------------------------
430
+ create or replace function agent_board._assert_actor_not_fleet_revoked(
431
+ p_actor_agent_id uuid,
432
+ p_operation text
433
+ )
434
+ returns void
435
+ language plpgsql
436
+ security definer
437
+ set search_path = agent_board, public
438
+ as $$
439
+ declare
440
+ v_revoked boolean;
441
+ begin
442
+ -- Fail closed on an unresolved actor. Unreachable from the three chokepoints (each resolves
443
+ -- or gates its actor first), which is exactly why it is called DIRECTLY by a pgTAP assertion
444
+ -- rather than left as a branch no mutation could reach (CLAUDE.md rule 2).
445
+ if p_actor_agent_id is null then
446
+ raise exception 'fleet_revoked_unresolved: % requires a resolved actor',
447
+ coalesce(nullif(btrim(p_operation), ''), 'board authority write')
448
+ using errcode = '42501';
449
+ end if;
450
+
451
+ -- The serialization point. FOR SHARE on the actor's agents row, taken BEFORE the ledger
452
+ -- read: both agent-subject ledger writers take FOR UPDATE on this row before their write,
453
+ -- so a concurrent revocation either already committed (and is read below) or blocks here
454
+ -- until this transaction commits. A missing agents row takes no lock and raises nothing —
455
+ -- an id that names no agent has no ledger row either, and requiring existence here would be
456
+ -- a widening this issue did not decide.
457
+ perform 1 from agent_board.agents a where a.id = p_actor_agent_id for share;
458
+
459
+ v_revoked := agent_board._actor_fleet_revoked(p_actor_agent_id);
460
+
461
+ if v_revoked then
462
+ raise exception 'fleet_revoked: % is not permitted for a fleet-revoked agent',
463
+ coalesce(nullif(btrim(p_operation), ''), 'board authority write')
464
+ using errcode = '42501';
465
+ end if;
466
+ end;
467
+ $$;
468
+
469
+ comment on function agent_board._assert_actor_not_fleet_revoked(uuid, text) is
470
+ '#276 fleet-revocation gate for the board AUTHORITY chokepoints. Takes FOR SHARE on the actor''s agents row and then refuses (42501, prefix ''fleet_revoked:'') when an agent-subject agent_board.fleet_revocation_events row exists for that actor. The predicate is the AGENT-WIDE ledger row and inspects no fleet_machine_agents binding, which is the same question evaluate_fleet_sender_policy answers the same way: a binding revoked on one machine while another stays active does not deny the agent, an agent-subject ledger row denies it everywhere. No effective_at/supersession clause — the sender policy honours reactivated_at for MACHINE-subject events only, and reactivate_fleet_machine never un-revokes a sender agent. A principal with NO fleet binding at all (a human, a non-fleet agent) has no such row and passes, so the gate cannot regress non-fleet grantors. The share lock is the serialization point: both writers of an agent-subject row (revoke_fleet_subject, _revoke_agent_principal) take FOR UPDATE on the same agents row before writing, so a racing revocation either is seen or blocks until this transaction commits. FOR SHARE, not FOR KEY SHARE (which would not conflict with a status change''s FOR NO KEY UPDATE) and not FOR UPDATE (which would block every concurrent FK insert naming the agent) — the same choice _assert_signed_authority_signer_live makes on the same row. A null actor raises fleet_revoked_unresolved. Internal: it locks and reads rows for an arbitrary agent id with no authorization of its own.';
471
+
472
+ revoke all on function agent_board._assert_actor_not_fleet_revoked(uuid, text) from public, anon, authenticated;
473
+ grant execute on function agent_board._assert_actor_not_fleet_revoked(uuid, text) to service_role;
474
+
475
+ -- ---------------------------------------------------------------------------
476
+ -- 2. grant_board_role — verbatim from 20260802000100:170 plus the gate, placed immediately
477
+ -- after actor resolution and BEFORE the membership pre-check, so the function's lock order
478
+ -- becomes agents -> board_memberships (see TOTAL ORDERS in the header).
479
+ -- ---------------------------------------------------------------------------
480
+ create or replace function agent_board.grant_board_role(
481
+ p_board_id uuid,
482
+ p_agent_id uuid,
483
+ p_grantee_role text,
484
+ p_grantor_agent_id uuid default null
485
+ )
486
+ returns table(board_id uuid, agent_id uuid, role text, updated_at timestamptz)
487
+ language plpgsql
488
+ security definer
489
+ set search_path = agent_board, public
490
+ as $$
491
+ declare
492
+ v_grantor uuid;
493
+ v_updated agent_board.board_memberships%rowtype;
494
+ begin
495
+ if p_grantee_role not in ('owner', 'admin', 'member', 'observer') then
496
+ raise exception 'invalid grantee role' using errcode = '22023';
497
+ end if;
498
+
499
+ -- #276, FORCED BY AN EXISTING CLASS ASSERTION, not chosen. Adding the ledger gate makes this
500
+ -- function row-lock `agents` (transitively, through _assert_actor_not_fleet_revoked) as well
501
+ -- as board_memberships, which puts it in the class agent_board_signer_lifecycle.test.sql
502
+ -- section 9 pins from the call-graph closure: every function locking BOTH relations must take
503
+ -- the board advisory lock that serialises them against onboard_federated_fleet_agent. That
504
+ -- assertion FAILED and named this function when the gate was added without this line.
505
+ --
506
+ -- The tempting reply is that this function's order is agents -> board_memberships, the SAME
507
+ -- direction as onboarding, so it cannot close the F4 cycle. That is true today and is exactly
508
+ -- the ordering argument #269 refused: mutual exclusion survives a lock being added downstream,
509
+ -- a re-derived ordering does not. So the lock is taken rather than the assertion relaxed.
510
+ --
511
+ -- It also closes a gap 20260901000110 left open and explicitly declined to determine: this
512
+ -- function reads the ceiling for authorization (via _grant_within_ceiling below) and was one
513
+ -- of the three readers that did NOT take the lock serialising the ceiling row's ABSENCE, so a
514
+ -- concurrent first-ceiling insert could be missed and the read pass as default-owner. It is
515
+ -- now serialised, and agent_board_atomic_fleet_onboarding.test.sql's ceiling-reader registry
516
+ -- records it as such.
517
+ --
518
+ -- FIRST, before any row lock, so both routes are advisory -> agents -> board_memberships.
519
+ -- Re-taking it inside signed_grant_board_role (which already holds it) is a no-op: advisory
520
+ -- transaction locks are re-entrant within a session.
521
+ perform agent_board._lock_board_authority_ceiling(p_board_id);
522
+
523
+ -- Grantor is the RESOLVED actor. service_role -> supplied arg; authenticated ->
524
+ -- current_agent_id() and raises 42501 if null (suspended/unmapped) or if the
525
+ -- supplied arg <> current_agent_id(). This closes the single-principal escalation.
526
+ v_grantor := agent_board._resolved_actor_agent_id(p_grantor_agent_id, 'p_grantor_agent_id');
527
+
528
+ -- #276. Fleet revocation removes board authority, not only send. Sited HERE — inside the
529
+ -- chokepoint, not in the signed wrapper — because signed_grant_board_role is the only other
530
+ -- referrer of this function in the whole database, so the signed route inherits this check
531
+ -- by construction and the unsigned `authenticated` route (the wider door) is closed by the
532
+ -- same line. Before the membership pre-check so the lock order is agents -> board_memberships,
533
+ -- matching onboard_federated_fleet_agent; below the write it would be the inversion #269
534
+ -- reproduced as `deadlock detected`, and this route has no advisory lock to be rescued by.
535
+ perform agent_board._assert_actor_not_fleet_revoked(v_grantor, 'grant_board_role');
536
+
537
+ if not exists (
538
+ select 1 from agent_board.board_memberships bm
539
+ where bm.board_id = p_board_id and bm.agent_id = p_agent_id
540
+ and bm.membership_state in ('joined', 'subscribed')
541
+ ) then
542
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
543
+ end if;
544
+
545
+ -- Ceiling consumption: grantee-bounded against min(grantor role, board ceiling).
546
+ if not agent_board._grant_within_ceiling(v_grantor, p_board_id, p_grantee_role) then
547
+ raise exception 'grant exceeds grantor role or board authority ceiling' using errcode = '42501';
548
+ end if;
549
+
550
+ update agent_board.board_memberships bm
551
+ set role = p_grantee_role,
552
+ updated_at = now()
553
+ where bm.board_id = p_board_id
554
+ and bm.agent_id = p_agent_id
555
+ and bm.membership_state in ('joined', 'subscribed')
556
+ returning * into v_updated;
557
+
558
+ -- CR finding: the state predicate above closes the unauthorized WRITE, but on its own it
559
+ -- CREATES a false-success path. If an eviction commits between the pre-check and this
560
+ -- UPDATE (the TOCTOU window the mirrored predicate exists for), the UPDATE matches zero
561
+ -- rows, v_updated stays NULL, and the RETURN QUERY below would emit ONE ALL-NULL ROW with
562
+ -- no error -- a caller that checks "did I get a row back?" reads that as a completed grant.
563
+ -- Fail closed with the same P0002 the pre-check raises, so the serial and racing cases are
564
+ -- indistinguishable to the caller.
565
+ if not found then
566
+ raise exception 'grantee has no membership on this board' using errcode = 'P0002';
567
+ end if;
568
+
569
+ return query
570
+ select v_updated.board_id, v_updated.agent_id, v_updated.role, v_updated.updated_at;
571
+ end;
572
+ $$;
573
+
574
+ comment on function agent_board.grant_board_role(uuid, uuid, text, uuid) is
575
+ 'Live board-role grant chokepoint. Grantor is the resolved actor (_resolved_actor_agent_id: '
576
+ 'service_role uses the supplied arg, authenticated uses current_agent_id() and fails closed on '
577
+ 'null/mismatch). #276: the resolved grantor is then checked against the fleet revocation ledger '
578
+ 'by _assert_actor_not_fleet_revoked -- an agent carrying an agent-subject fleet_revocation_events '
579
+ 'row cannot complete a grant (42501, ''fleet_revoked:''), which is the same answer '
580
+ 'evaluate_fleet_sender_policy already gives that principal on the send path. The check sits in '
581
+ 'THIS function rather than in signed_grant_board_role because signed_grant_board_role is the only '
582
+ 'other referrer of it in the database, so the signed route inherits the gate and the wider '
583
+ 'unsigned authenticated route is closed by the same line; it is placed before the membership '
584
+ 'pre-check so the lock order is agents(FOR SHARE) -> board_memberships, matching '
585
+ 'onboard_federated_fleet_agent. A principal with no fleet binding has no such row and is '
586
+ 'unaffected. Then requires an ACTIVE grantee membership (joined|subscribed; P0002 otherwise) -- '
587
+ 'predicate on both the pre-check and the UPDATE (TOCTOU). Then enforces '
588
+ '_grant_within_ceiling(grantor, board, grantee_role) before setting board_memberships.role. '
589
+ 'ROLERESET (#79): non-active rows cannot be re-armed with an elevated role.';
590
+
591
+ revoke all on function agent_board.grant_board_role(uuid, uuid, text, uuid) from public, anon;
592
+ grant execute on function agent_board.grant_board_role(uuid, uuid, text, uuid) to authenticated, service_role;
593
+
594
+ -- ---------------------------------------------------------------------------
595
+ -- 3. set_board_authority_ceiling — verbatim from 20260907000100:59 plus the gate, placed
596
+ -- AFTER the upsert so the order stays advisory -> boards -> agents. Hoisting it would give
597
+ -- agents -> boards and invert against onboard_federated_fleet_agent and the federated
598
+ -- enroll routes on the (agents, boards) pair. One transaction, so the raise rolls the
599
+ -- upsert back.
600
+ -- ---------------------------------------------------------------------------
601
+ create or replace function agent_board.set_board_authority_ceiling(
602
+ p_board_id uuid,
603
+ p_max_grantable_role text,
604
+ p_actor_agent_id uuid default null
605
+ )
606
+ returns table(
607
+ ceiling_board_id uuid,
608
+ ceiling_max_grantable_role text
609
+ )
610
+ language plpgsql
611
+ security definer
612
+ set search_path = agent_board, public
613
+ as $$
614
+ declare
615
+ v_actor uuid;
616
+ begin
617
+ if p_max_grantable_role not in ('owner', 'admin', 'member', 'observer') then
618
+ raise exception 'max_grantable_role must be one of owner/admin/member/observer' using errcode = '22023';
619
+ end if;
620
+
621
+ -- Same board-keyed advisory lock every ceiling READER for authorization takes.
622
+ -- Held to commit, it makes the insert of a first ceiling row serialize against a
623
+ -- concurrent authorization that would otherwise observe the pre-insert ABSENCE and
624
+ -- pass as default-owner. Taken before the owner gate so the gate and the write are
625
+ -- one critical section.
626
+ perform agent_board._lock_board_authority_ceiling(p_board_id);
627
+
628
+ -- #247. Actor resolution, NOT a widening of the gate. With p_actor_agent_id omitted this
629
+ -- is the pre-#247 expression for every role, so no existing caller changes behaviour
630
+ -- (D4). When it IS supplied, _resolved_actor_agent_id binds it: service_role takes the
631
+ -- argument (this is the signed_* gateway path, where the argument is the gateway-VERIFIED
632
+ -- signer, never a raw caller claim), while authenticated still resolves to
633
+ -- current_agent_id() and raises 42501 if the supplied id differs. The owner gate below is
634
+ -- untouched and remains the only thing that authorizes the write.
635
+ if p_actor_agent_id is null then
636
+ v_actor := agent_board.current_agent_id();
637
+ else
638
+ v_actor := agent_board._resolved_actor_agent_id(p_actor_agent_id, 'p_actor_agent_id');
639
+ end if;
640
+
641
+ if v_actor is null
642
+ or not exists (
643
+ select 1
644
+ from agent_board.board_memberships bm
645
+ where bm.board_id = p_board_id
646
+ and bm.agent_id = v_actor
647
+ and bm.role = 'owner'
648
+ and bm.membership_state in ('joined', 'subscribed')
649
+ ) then
650
+ raise exception 'only a board owner may set the board authority ceiling' using errcode = '42501';
651
+ end if;
652
+
653
+ insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
654
+ values (p_board_id, p_max_grantable_role)
655
+ on conflict (board_id) do update
656
+ set max_grantable_role = excluded.max_grantable_role,
657
+ updated_at = now();
658
+
659
+ -- #276, AFTER the upsert by design (see the header's TOTAL ORDERS). The ceiling insert
660
+ -- reaches boards through board_authority_ceilings' FK, so checking here keeps this
661
+ -- function's order advisory -> boards -> agents, which is onboard_federated_fleet_agent's
662
+ -- order and the signed wrapper's. Same transaction: this raise rolls the upsert back, so
663
+ -- position costs nothing in correctness. Raising the ceiling is a strictly larger authority
664
+ -- act than a single grant, so a ledger-revoked owner must not reach it either.
665
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_authority_ceiling');
666
+
667
+ return query
668
+ select p_board_id, p_max_grantable_role;
669
+ end;
670
+ $$;
671
+
672
+ comment on function agent_board.set_board_authority_ceiling(uuid, text, uuid) is
673
+ 'Owner-gated setter for a board''s authority ceiling: takes the board-keyed _lock_board_authority_ceiling advisory lock, then upserts board_authority_ceilings only when the resolved actor is an owner of the board (else raises 42501). The actor is current_agent_id() when p_actor_agent_id is omitted — the pre-#247 behaviour for every role — and _resolved_actor_agent_id(p_actor_agent_id) when it is supplied, which lets the service_role-only signed_set_board_authority_ceiling gateway act for the gateway-verified signer while still forcing an authenticated caller to match current_agent_id(). #276: after the upsert it calls _assert_actor_not_fleet_revoked, so an actor carrying an agent-subject fleet_revocation_events row cannot raise or lower a board ceiling (42501, ''fleet_revoked:'') — the same principal the send path already refuses. It sits AFTER the upsert, not before, so the lock order stays advisory -> boards -> agents; hoisting it would give agents -> boards and invert against onboard_federated_fleet_agent on the (agents, boards) pair. The function is one transaction, so the raise rolls the upsert back. Leaves _grant_within_ceiling semantics unchanged.';
674
+
675
+ revoke all on function agent_board.set_board_authority_ceiling(uuid, text, uuid) from public, anon;
676
+ grant execute on function agent_board.set_board_authority_ceiling(uuid, text, uuid) to authenticated, service_role;
677
+
678
+ -- ---------------------------------------------------------------------------
679
+ -- 4. set_board_role_gate — verbatim from 20260725000100:22 plus the gate, placed AFTER the
680
+ -- boards UPDATE for the same lock-order reason (boards -> agents). This function has no
681
+ -- signed wrapper and no other referrer anywhere in the database.
682
+ -- ---------------------------------------------------------------------------
683
+ create or replace function agent_board.set_board_role_gate(
684
+ p_board_id uuid,
685
+ p_enforced boolean
686
+ )
687
+ returns table(
688
+ board_id uuid,
689
+ role_gate_enforced boolean,
690
+ updated_at timestamptz
691
+ )
692
+ language plpgsql
693
+ security definer
694
+ set search_path = agent_board, public
695
+ as $$
696
+ declare
697
+ v_actor uuid := agent_board.current_agent_id();
698
+ v_row agent_board.boards%rowtype;
699
+ begin
700
+ if p_enforced is null then
701
+ raise exception 'p_enforced must not be null' using errcode = '22023';
702
+ end if;
703
+
704
+ -- Owner gate: only an active owner-role member may flip the gate. service_role
705
+ -- (current_agent_id() = null) is not an owner and is rejected here (it keeps its
706
+ -- direct table-write grant for operator provisioning).
707
+ if v_actor is null
708
+ or not exists (
709
+ select 1
710
+ from agent_board.board_memberships bm
711
+ where bm.board_id = p_board_id
712
+ and bm.agent_id = v_actor
713
+ and bm.role = 'owner'
714
+ and bm.membership_state in ('joined', 'subscribed')
715
+ ) then
716
+ raise exception 'only a board owner may set the role gate' using errcode = '42501';
717
+ end if;
718
+
719
+ update agent_board.boards b
720
+ set role_gate_enforced = p_enforced,
721
+ updated_at = now()
722
+ where b.id = p_board_id
723
+ returning * into v_row;
724
+
725
+ if v_row.id is null then
726
+ raise exception 'board not found' using errcode = 'P0002';
727
+ end if;
728
+
729
+ -- #276, AFTER the boards UPDATE so this function's order is boards -> agents, the canonical
730
+ -- one (#194). Flipping role_gate_enforced decides whether the creator-identity exemption
731
+ -- applies to invite_board_member and resolve_board_request, so it is an authority write in
732
+ -- the same class as a grant. Same transaction: the raise rolls the UPDATE back.
733
+ perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_role_gate');
734
+
735
+ return query
736
+ select v_row.id, v_row.role_gate_enforced, v_row.updated_at;
737
+ end;
738
+ $$;
739
+
740
+ comment on function agent_board.set_board_role_gate(uuid, boolean) is
741
+ 'Owner-gated setter for a board''s role_gate_enforced flag: UPDATEs boards.role_gate_enforced only when current_agent_id() is an active owner of the board (else 42501; null p_enforced -> 22023; missing board -> P0002). service_role is not an owner here. #276: after the UPDATE it calls _assert_actor_not_fleet_revoked, so an owner carrying an agent-subject fleet_revocation_events row cannot flip the gate (42501, ''fleet_revoked:''); the flag decides whether the creator-identity exemption applies to invite_board_member and resolve_board_request, which makes it an authority write in the same class as a role grant. Placed after the UPDATE so the lock order is boards -> agents, the canonical order (#194); the function is one transaction, so the raise rolls the UPDATE back.';
742
+
743
+ revoke all on function agent_board.set_board_role_gate(uuid, boolean) from public, anon;
744
+ grant execute on function agent_board.set_board_role_gate(uuid, boolean) to authenticated, service_role;
745
+
746
+ -- ---------------------------------------------------------------------------
747
+ -- 5. create_invite_link — the MINT half of the invite route. Verbatim from
748
+ -- 20260724000100 plus the gate, placed AFTER the token insert so the order is
749
+ -- boards(KEY SHARE, via board_invite_tokens.board_id) -> agents(SHARE), the canonical
750
+ -- direction. Hoisting it would give agents -> boards, inverting against
751
+ -- onboard_federated_fleet_agent and the federated enroll routes.
752
+ --
753
+ -- LOCKED, like the three chokepoints, because the actor here IS the principal whose
754
+ -- authority is being spent: minting an admin link is conferring admin on whoever redeems
755
+ -- it, deferred. Checked acyclic against create_fleet_onboarding_invite (its only in-schema
756
+ -- caller), which holds boards -> fleet_machines -> fleet_trust_domains before it mints, and
757
+ -- against revoke_fleet_subject, which takes fleet_machines -> agents in that same direction.
758
+ -- ---------------------------------------------------------------------------
759
+ create or replace function agent_board.create_invite_link(
760
+ p_board_id uuid,
761
+ p_preset_role text,
762
+ p_preset_membership_state text default 'joined',
763
+ p_max_uses integer default null,
764
+ p_expires_at timestamptz default null,
765
+ p_created_by_agent_id uuid default null,
766
+ p_metadata jsonb default '{}'::jsonb
767
+ )
768
+ returns table(
769
+ invite_id uuid, board_id uuid, code text, preset_role text,
770
+ preset_membership_state text, max_uses integer, uses_count integer,
771
+ expires_at timestamptz, created_at timestamptz
772
+ )
773
+ language plpgsql
774
+ security definer
775
+ set search_path = agent_board, public
776
+ as $$
777
+ declare
778
+ v_creator uuid;
779
+ v_expires timestamptz;
780
+ v_code text;
781
+ v_row agent_board.board_invite_tokens%rowtype;
782
+ begin
783
+ if p_preset_role not in ('owner', 'admin', 'member', 'observer') then
784
+ raise exception 'invalid preset role' using errcode = '22023';
785
+ end if;
786
+ -- R-LINK-3: owner presets are not invite-linkable in v1 (a leaked owner link mints
787
+ -- co-owners). admin presets must be bounded: a use cap AND a short (<= 7d) TTL.
788
+ if p_preset_role = 'owner' then
789
+ raise exception 'owner presets are not invite-linkable' using errcode = '22023';
790
+ end if;
791
+ if coalesce(p_preset_membership_state, 'joined') not in ('joined', 'subscribed') then
792
+ raise exception 'invalid preset membership state' using errcode = '22023';
793
+ end if;
794
+
795
+ -- Effective creator: service_role -> supplied arg (create-on-behalf-of);
796
+ -- authenticated -> current_agent_id() and rejects arg <> current (42501).
797
+ v_creator := agent_board._resolved_actor_agent_id(p_created_by_agent_id, 'p_created_by_agent_id');
798
+
799
+ -- Grantor-bounded at mint: the creator's live authority must cover the preset role.
800
+ if not agent_board._grant_within_ceiling(v_creator, p_board_id, p_preset_role) then
801
+ raise exception 'preset role exceeds creator role or board authority ceiling' using errcode = '42501';
802
+ end if;
803
+
804
+ -- TTL: default 7d, hard cap 30d, reject a past/now expiry.
805
+ v_expires := coalesce(p_expires_at, now() + interval '7 days');
806
+ if v_expires <= now() then
807
+ raise exception 'invite expiry must be in the future' using errcode = '22023';
808
+ end if;
809
+ if v_expires > now() + interval '30 days' then
810
+ v_expires := now() + interval '30 days';
811
+ end if;
812
+
813
+ -- R-LINK-3 (admin): require a use cap and a <= 7d TTL.
814
+ if p_preset_role = 'admin' then
815
+ if p_max_uses is null then
816
+ raise exception 'admin invite links require a bounded p_max_uses' using errcode = '22023';
817
+ end if;
818
+ if v_expires > now() + interval '7 days' then
819
+ raise exception 'admin invite links require a TTL of at most 7 days' using errcode = '22023';
820
+ end if;
821
+ end if;
822
+
823
+ -- 256-bit opaque code, base64url (schema-qualify gen_random_bytes; encode/translate/
824
+ -- rtrim are pg_catalog builtins).
825
+ v_code := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
826
+
827
+ insert into agent_board.board_invite_tokens as t(
828
+ code, board_id, preset_role, preset_membership_state, max_uses, expires_at,
829
+ created_by_agent_id, metadata
830
+ )
831
+ values (
832
+ v_code, p_board_id, p_preset_role, coalesce(p_preset_membership_state, 'joined'),
833
+ p_max_uses, v_expires, v_creator, coalesce(p_metadata, '{}'::jsonb)
834
+ )
835
+ returning * into v_row;
836
+
837
+ -- #276. AFTER the insert, so the order is boards(KEY SHARE via the token's board_id FK) ->
838
+ -- agents(SHARE) -- see the header. One transaction, so this raise discards the token; a
839
+ -- revoked creator never gets a code back. The `fleet_revoked:` prefix is kept here (unlike
840
+ -- the redeem side) because the caller is asking about ITSELF, so there is no oracle to leak.
841
+ perform agent_board._assert_actor_not_fleet_revoked(v_creator, 'create_invite_link');
842
+
843
+ return query
844
+ select v_row.id, v_row.board_id, v_row.code, v_row.preset_role,
845
+ v_row.preset_membership_state, v_row.max_uses, v_row.uses_count,
846
+ v_row.expires_at, v_row.created_at;
847
+ end;
848
+ $$;
849
+
850
+ comment on function agent_board.create_invite_link(uuid, text, text, integer, timestamptz, uuid, jsonb) is
851
+ 'Mints a shareable board invite link. Grantor-bounded (creator authority must cover the preset role via _grant_within_ceiling). Forbids owner presets; admin presets require a use cap + <=7d TTL. TTL default 7d, hard-capped 30d. Creator resolved via _resolved_actor_agent_id. Returns the opaque code (creator-only). #276: a creator carrying an agent-subject fleet_revocation_events row cannot mint at all (42501, ''fleet_revoked:'') — an admin-preset link is a deferred grant of admin, so minting one is conferring a board role by another route. The gate calls _assert_actor_not_fleet_revoked (share-locked) and sits AFTER the token insert so the lock order is boards(KEY SHARE via the board_id FK) -> agents; the raise discards the token in the same transaction, so a revoked creator never receives a code.';
852
+
853
+ revoke all on function agent_board.create_invite_link(uuid, text, text, integer, timestamptz, uuid, jsonb) from public, anon;
854
+ grant execute on function agent_board.create_invite_link(uuid, text, text, integer, timestamptz, uuid, jsonb) to authenticated, service_role;
855
+
856
+ -- ---------------------------------------------------------------------------
857
+ -- 6. _redeem_unbounded_invite_for_agent — the REDEEM half. Verbatim from 20260724000100
858
+ -- (as amended) plus ONE clause added to the existing creator-lifecycle block.
859
+ --
860
+ -- UNLOCKED, and that is the deliberate half of this migration. See "THE INVITE PATHS" in
861
+ -- the header: every way of taking a row lock on the CREATOR here closes a cycle, and the
862
+ -- adjacent R-LINK-2 creator-status check has always been unlocked.
863
+ --
864
+ -- UNIFORM MESSAGE, not the `fleet_revoked:` prefix the other five gates use. Every deny arm
865
+ -- in this function is the same 'invite is not usable' 42501 precisely so a code-bearer
866
+ -- cannot tell a revoked creator from a dead, expired or exhausted link. A distinguishable
867
+ -- message here would build the lifecycle oracle this function documents refusing to build,
868
+ -- and it would leak it to whoever holds the code — who is not the principal being described.
869
+ -- ---------------------------------------------------------------------------
870
+ create or replace function agent_board._redeem_unbounded_invite_for_agent(
871
+ p_code text,
872
+ p_agent_id uuid
873
+ )
874
+ returns table(board_id uuid, agent_id uuid, role text, membership_state text, updated_at timestamptz)
875
+ language plpgsql
876
+ security definer
877
+ set search_path = agent_board, public
878
+ as $$
879
+ -- OUT params board_id/agent_id/role/membership_state/updated_at share names with the
880
+ -- membership columns referenced in the on-conflict target + update set below; prefer
881
+ -- the column in any collision (mirrors ROOMOWN create_board).
882
+ #variable_conflict use_column
883
+ declare
884
+ v_inv agent_board.board_invite_tokens%rowtype;
885
+ v_led agent_board.board_invite_redemptions%rowtype;
886
+ v_m agent_board.board_memberships%rowtype;
887
+ begin
888
+ -- Lock the token row FIRST (concurrency invariant: the exhaustion check and the
889
+ -- same-txn uses_count increment happen under this lock, and concurrent redeemers
890
+ -- of the same token serialize here).
891
+ select * into v_inv
892
+ from agent_board.board_invite_tokens t
893
+ where t.code = p_code
894
+ for update;
895
+ if not found then
896
+ raise exception 'invite is not usable' using errcode = '42501';
897
+ end if;
898
+
899
+ -- M1 idempotency ordering: an existing (invite, agent) ledger row means this is a
900
+ -- replay -> return the existing membership and SKIP every exhaustion/ceiling check
901
+ -- (a maxed link re-redeemed by the same agent is a no-op, NOT 42501).
902
+ select * into v_led
903
+ from agent_board.board_invite_redemptions r
904
+ where r.invite_id = v_inv.id
905
+ and r.agent_id = p_agent_id;
906
+ if found then
907
+ select * into v_m
908
+ from agent_board.board_memberships bm
909
+ where bm.id = v_led.membership_id;
910
+ return query
911
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
912
+ return;
913
+ end if;
914
+
915
+ -- Genuinely new redemption: uniform 42501 for revoked / expired / exhausted
916
+ -- (no P0002-vs-42501 existence oracle).
917
+ if v_inv.revoked_at is not null then
918
+ raise exception 'invite is not usable' using errcode = '42501';
919
+ end if;
920
+ if v_inv.expires_at <= now() then
921
+ raise exception 'invite is not usable' using errcode = '42501';
922
+ end if;
923
+ if v_inv.max_uses is not null and v_inv.uses_count >= v_inv.max_uses then
924
+ raise exception 'invite is not usable' using errcode = '42501';
925
+ end if;
926
+
927
+ -- R-LINK-2 suspension containment: the creator must still be an ACTIVE agent
928
+ -- (revoke_agent suspends but does not drop memberships, so an outstanding link
929
+ -- from a compromised-then-suspended owner must stop minting).
930
+ if not exists (
931
+ select 1 from agent_board.agents a
932
+ where a.id = v_inv.created_by_agent_id and a.status = 'active'
933
+ ) then
934
+ -- Uniform message (see other 42501s) so a code-bearer cannot distinguish
935
+ -- "creator suspended" from a dead/expired/exhausted link (no lifecycle oracle).
936
+ raise exception 'invite is not usable' using errcode = '42501';
937
+ end if;
938
+
939
+ -- #276 fleet containment, the sibling of R-LINK-2 one column over. Fleet revocation
940
+ -- deliberately leaves agents.status = 'active', so the check above passes for a
941
+ -- ledger-revoked creator and an outstanding admin link kept conferring admin -- the same
942
+ -- principal the send path already refuses. Keyed on the CREATOR because the creator is who
943
+ -- confers here; the redeemer is receiving, and its own standing is handled by
944
+ -- current_agent_id() and by the R-LINK-7 clause below.
945
+ --
946
+ -- UNLOCKED (_actor_fleet_revoked, not the share-locking assert) and with the SAME uniform
947
+ -- message as every other arm. Both choices are load-bearing, not stylistic: see the header.
948
+ if agent_board._actor_fleet_revoked(v_inv.created_by_agent_id) then
949
+ raise exception 'invite is not usable' using errcode = '42501';
950
+ end if;
951
+
952
+ -- Re-bind: the creator's CURRENT authority must still cover the preset role
953
+ -- (stale admin-link auto-degrades if the creator was demoted).
954
+ if not agent_board._grant_within_ceiling(v_inv.created_by_agent_id, v_inv.board_id, v_inv.preset_role) then
955
+ raise exception 'invite is not usable' using errcode = '42501';
956
+ end if;
957
+
958
+ -- R-LINK-7 kicked-agent re-entry: refuse when the agent's CURRENT standing on the
959
+ -- board is 'removed' — i.e. a 'removed' row exists AND there is no active
960
+ -- (joined/subscribed) membership. The active partial-unique index does NOT cover
961
+ -- 'removed' rows, so a fresh 'joined' insert would otherwise bypass revocation +
962
+ -- join-approval. Scoping to "removed and not currently active" avoids a false
963
+ -- negative for an agent who was removed and then legitimately re-added (an active
964
+ -- row present ⇒ the rank-max upsert handles them correctly). Voluntary 'left' is
965
+ -- intentionally NOT blocked (self-departure).
966
+ if exists (
967
+ select 1 from agent_board.board_memberships bm
968
+ where bm.board_id = v_inv.board_id
969
+ and bm.agent_id = p_agent_id
970
+ and bm.membership_state = 'removed'
971
+ ) and not exists (
972
+ select 1 from agent_board.board_memberships bm
973
+ where bm.board_id = v_inv.board_id
974
+ and bm.agent_id = p_agent_id
975
+ and bm.membership_state in ('joined', 'subscribed')
976
+ ) then
977
+ raise exception 'invite is not usable' using errcode = '42501';
978
+ end if;
979
+
980
+ -- Record the ledger row (idempotent under concurrent same-agent redemption).
981
+ insert into agent_board.board_invite_redemptions as r(invite_id, agent_id)
982
+ values (v_inv.id, p_agent_id)
983
+ on conflict (invite_id, agent_id) do nothing;
984
+
985
+ -- B1 atomic rank-max membership upsert: never downgrade a higher existing role,
986
+ -- race-safe across concurrent tokens. On conflict targets the active partial-unique
987
+ -- index; the CASE lifts ONLY when the preset outranks the current role.
988
+ insert into agent_board.board_memberships (
989
+ board_id, agent_id, membership_state, role, created_by_agent_id
990
+ )
991
+ values (
992
+ v_inv.board_id, p_agent_id, v_inv.preset_membership_state, v_inv.preset_role,
993
+ v_inv.created_by_agent_id
994
+ )
995
+ on conflict (board_id, agent_id) where (membership_state in ('joined', 'subscribed'))
996
+ do update set
997
+ role = case
998
+ when agent_board._role_rank(excluded.role) > agent_board._role_rank(agent_board.board_memberships.role)
999
+ then excluded.role
1000
+ else agent_board.board_memberships.role
1001
+ end,
1002
+ updated_at = now()
1003
+ returning * into v_m;
1004
+
1005
+ -- The redeemer places THEMSELVES -- the public wrapper passes current_agent_id() and takes no
1006
+ -- target parameter -- so this emits target-authored: redeeming a link IS the consenting act.
1007
+ --
1008
+ -- v_m.membership_state, NOT v_inv.preset_membership_state. The ON CONFLICT above lifts only
1009
+ -- the ROLE and leaves an existing membership_state untouched, so an already-`subscribed`
1010
+ -- member redeeming a `joined` link stays subscribed. Emitting the token's preset would
1011
+ -- record a resulting state the row does not have -- a ledger that contradicts the table it
1012
+ -- describes. Recording the redemption as a fresh consent act is right; recording the wrong
1013
+ -- outcome is not.
1014
+ perform agent_board._emit_membership_event(
1015
+ v_inv.board_id, p_agent_id, v_m.membership_state, 'consent_link_redeem');
1016
+
1017
+ -- Consume one use and bind the ledger row to the resulting membership.
1018
+ update agent_board.board_invite_tokens t
1019
+ set uses_count = t.uses_count + 1
1020
+ where t.id = v_inv.id;
1021
+
1022
+ update agent_board.board_invite_redemptions r
1023
+ set membership_id = v_m.id
1024
+ where r.invite_id = v_inv.id
1025
+ and r.agent_id = p_agent_id;
1026
+
1027
+ return query
1028
+ select v_m.board_id, v_m.agent_id, v_m.role, v_m.membership_state, v_m.updated_at;
1029
+ end;
1030
+ $$;
1031
+
1032
+ comment on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) is
1033
+ 'Internal ROOMLINK redemption core (service_role-only). Locks the token FOR UPDATE, no-ops idempotently on a replayed (invite, agent), then gates revoked/expired/exhausted (uniform 42501), creator-active (R-LINK-2), creator NOT fleet-revoked (#276), creator ceiling re-bind, and kicked-agent refusal (R-LINK-7) before an atomic rank-max membership upsert + uses_count increment. Shared by redeem_invite_link and the /enroll hook. #276 keys on the CREATOR, the principal who confers here, and is the sibling of R-LINK-2 one column over: fleet revocation leaves agents.status = ''active'', so an outstanding admin link from a ledger-revoked creator kept conferring admin. It calls _actor_fleet_revoked (UNLOCKED) rather than the share-locking assert the other five gates use, because this function is reached from enroll_federated_identity while a boards row lock is already held, and every placement of a creator row lock here closes a cycle against onboard_federated_fleet_agent; the adjacent R-LINK-2 check has likewise always been unlocked. It raises the same uniform ''invite is not usable'' message as every other arm so a code-bearer cannot distinguish a revoked creator from a dead link.';
1034
+
1035
+ revoke all on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) from public, anon, authenticated;
1036
+ grant execute on function agent_board._redeem_unbounded_invite_for_agent(text, uuid) to service_role;