@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.
- package/fixtures/already-applied.json +8 -0
- package/migrations/20260908000100_agent_board_signer_lifecycle_revalidation.sql +451 -0
- package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +2 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +23 -2
- package/tests/agent_board_ledger_authority.test.sql +1010 -0
- package/tests/agent_board_signed_authority_provenance.test.sql +24 -1
- package/tests/agent_board_signer_lifecycle.test.sql +715 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/isolation/expected/ledger_authority_revoke.out +483 -0
- package/tests/isolation/expected/signed_grant_onboarding_deadlock.out +220 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +307 -0
- package/tests/isolation/specs/ledger_authority_revoke.spec +290 -0
- package/tests/isolation/specs/signed_grant_onboarding_deadlock.spec +159 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +186 -0
|
@@ -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;
|