@consiliency/agent-board-schema 1.25.0 → 1.27.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.
@@ -500,6 +500,10 @@
500
500
  {
501
501
  "id": "20260907000100_agent_board_signed_authority_provenance",
502
502
  "sha256": "21111e289e0bb70ac3236f5422476c0c51d0bca5a815c11dd4e48b6932cf7c6b"
503
+ },
504
+ {
505
+ "id": "20260908000100_agent_board_signer_lifecycle_revalidation",
506
+ "sha256": "bfb1ae60f69dae0a0aaf1007dbf14e171f324cdced1caf9f4d490f41a60c14e5"
503
507
  }
504
508
  ]
505
509
  }
@@ -0,0 +1,451 @@
1
+ -- #269 — atomic signer-lifecycle revalidation for the two signed AUTHORITY wrappers.
2
+ --
3
+ -- WHAT THIS CLOSES. Portal verifies the Ed25519 signature, reads agents.status and the
4
+ -- signing key's lifecycle, and reserves replay — then calls
5
+ -- agent_board.signed_grant_board_role / signed_set_board_authority_ceiling in a SEPARATE
6
+ -- PostgREST round trip. A suspension or key revocation that commits between those two calls
7
+ -- is invisible to the wrapper: #247 (20260907000100) binds the gateway-verified actor and
8
+ -- rechecks BOARD authority, but reads nothing about the SIGNER's own lifecycle. Enumerated
9
+ -- from the shipped source rather than from memory: neither wrapper mentions agents.status,
10
+ -- agent_signing_keys, proof_state or expires_at. So a revoked or suspended owner completes an
11
+ -- authority mutation inside that window. Portal cannot make verification and the wrapper call
12
+ -- one transaction across two HTTP calls; the fix is schema-owned.
13
+ --
14
+ -- WHERE THE CHECK LIVES, AND WHY NOT IN _signed_write_actor_id.
15
+ -- _signed_write_actor_id (20260710000000) is the shared chokepoint of SEVEN wrappers
16
+ -- (enumerated from pg_proc: the five message/task wrappers of 20260628000003 and
17
+ -- 20260711000100, plus the two #247 authority wrappers). Extending IT is the tempting move
18
+ -- and it is wrong, for a reason that is empirical rather than stylistic: Portal's
19
+ -- dispatchSignedWrite still calls the three message wrappers when the write is NOT verified
20
+ -- and the enforcement mode allows unsigned (`observe`), with a decision whose signing_key_id
21
+ -- is null. A live-key requirement in the shared helper would fail-close that path — a
22
+ -- capability regression on five wrappers, none of which #269 is about.
23
+ --
24
+ -- So the revalidation is its own helper called by the two authority wrappers, and the CLASS
25
+ -- is pinned from the catalog instead of by hand: agent_board_signer_lifecycle.test.sql
26
+ -- asserts that among all `signed\_%` wrappers, the set whose stripped body calls an authority
27
+ -- chokepoint (grant_board_role / set_board_authority_ceiling / set_board_role_gate) is
28
+ -- EXACTLY the set whose stripped body calls this helper — both directions. A future signed
29
+ -- authority wrapper that forgets the call fails that assertion and names itself.
30
+ --
31
+ -- WHY THE CALL SITE IS AFTER THE INNER FUNCTION, NOT BEFORE. Two independent reasons; do not
32
+ -- "optimize" it earlier.
33
+ --
34
+ -- 1. LOCK ORDER. The revalidation takes agents then agent_signing_keys. Placed AFTER the
35
+ -- inner call, the ceiling wrapper's total order is
36
+ -- advisory(board) -> boards -> agents -> agent_signing_keys
37
+ -- which is onboard_federated_fleet_agent's order verbatim (20260901000110: advisory
38
+ -- hoisted above the boards row lock; 20260901000100:1277 locks agent before key). Placed
39
+ -- BEFORE, it becomes agents -> advisory -> boards, inverting against onboarding on
40
+ -- (advisory, agents) — and the two transactions plausibly share both operands, because
41
+ -- the fleet invite creator and the board owner signing a ceiling change are routinely
42
+ -- the same agent on the same board. That is the exact ABBA shape 20260901000110
43
+ -- documents having introduced and then fixed.
44
+ --
45
+ -- THAT ARGUMENT WAS RIGHT ABOUT THE CEILING WRAPPER AND WRONG ABOUT THE GRANT WRAPPER,
46
+ -- which does not reach the advisory lock at all. See TOTAL ORDERS below: the grant
47
+ -- wrapper's own order was board_memberships -> agents, an ABBA against onboarding that
48
+ -- the "verbatim" claim above obscured by reasoning only about the ceiling path. It is
49
+ -- fixed by acquiring the board advisory lock in the grant wrapper, not by moving the
50
+ -- revalidation.
51
+ -- 2. IT PRESERVES EVERY EXISTING #247 ASSERTION. Two of them drive a wrapper with a
52
+ -- decision whose agent_id is agent 3 while signing_key_id is agent 1's key, and assert
53
+ -- the INNER gate's message ('%exceeds grantor role%', '%only a board owner%'). Checking
54
+ -- first would raise signer_key_not_owned before the inner gate ever runs and rewrite
55
+ -- both.
56
+ --
57
+ -- Correctness is unaffected by the position: the wrapper is one transaction, so a raise here
58
+ -- rolls back the inner write. The three interleavings are all fail-closed — a revocation that
59
+ -- committed earlier is SEEN (the row lock reads the latest committed row), one that starts
60
+ -- later BLOCKS on the share lock until this transaction commits, and one already in flight
61
+ -- makes this transaction block and then observe the revoked row.
62
+ --
63
+ -- TOTAL ORDERS, stated explicitly so no future reader has to reconstruct them from two
64
+ -- migrations. Every lock either authority wrapper takes, in acquisition order:
65
+ --
66
+ -- signed_grant_board_role
67
+ -- advisory(board) -> board_memberships(grantee, UPDATE) -> agents(signer, FOR SHARE)
68
+ -- -> agent_signing_keys(decision key, FOR SHARE)
69
+ -- signed_set_board_authority_ceiling
70
+ -- advisory(board) -> board_authority_ceilings (+ boards via FK) -> agents(signer, FOR SHARE)
71
+ -- -> agent_signing_keys(decision key, FOR SHARE)
72
+ -- onboard_federated_fleet_agent (20260901000110, for comparison)
73
+ -- board_invite_tokens(:274) -> fleet_onboarding_invite_bounds(:281) -> advisory(board)(:290)
74
+ -- -> boards -> fleet_machines -> fleet_trust_domains -> advisory(fingerprint)
75
+ -- -> auth.users -> agents(:349) -> board_memberships(creator)(:367)
76
+ -- -> board_authority_ceilings(:369) -> board_memberships(enrollee)(:397)
77
+ -- -> agents(enrolled)(:543) -> agent_signing_keys(:544)
78
+ --
79
+ -- WHY THIS IS ACYCLIC, and why the argument is mutual exclusion rather than ordering. Both
80
+ -- wrappers take advisory(board) as their FIRST lock. Onboarding requests it holding only
81
+ -- board_invite_tokens and fleet_onboarding_invite_bounds, two relations no authority wrapper
82
+ -- ever touches. So on any shared board the three transactions are serialised at the advisory
83
+ -- lock, and nothing either does afterwards can close a cycle. An ordering argument would have
84
+ -- to be re-verified every time a lock is added anywhere downstream; this one does not.
85
+ --
86
+ -- THE CEILING WRAPPER NEEDS NO CODE CHANGE — its inner set_board_authority_ceiling already
87
+ -- takes the advisory lock first (20260901000110:146, preserved through the #247 recreation).
88
+ -- Only the grant wrapper had to acquire it. Do not "symmetrise" this by adding a second
89
+ -- acquisition to the ceiling wrapper: it would be redundant, not safer.
90
+ --
91
+ -- LOCK MODE — FOR SHARE, on both rows, and neither weaker nor stronger.
92
+ -- * FOR KEY SHARE is too weak. Every lifecycle write here is a plain UPDATE of a non-key
93
+ -- column (agents.status, agent_signing_keys.status), which takes FOR NO KEY UPDATE — and
94
+ -- FOR NO KEY UPDATE does NOT conflict with FOR KEY SHARE. The suspend would sail past.
95
+ -- * FOR UPDATE is too strong, and the cost is not hypothetical: FOR UPDATE conflicts with
96
+ -- FOR KEY SHARE, which is what EVERY concurrent insert of a row referencing this agent
97
+ -- takes through its foreign key (a message, an inbox item, a membership). One signed
98
+ -- authority write would block unrelated traffic for its whole transaction. FOR SHARE
99
+ -- conflicts with FOR NO KEY UPDATE and FOR UPDATE — exactly the lifecycle writers — and
100
+ -- not with FOR KEY SHARE, so FK traffic is untouched. It also lets two concurrent signed
101
+ -- writes by the same signer proceed, which FOR UPDATE would needlessly serialize.
102
+ --
103
+ -- LOCK ORDER — agents BEFORE agent_signing_keys, taken from the NEWEST definitions, not the
104
+ -- first. 20260718001100's _revoke_agent_principal locked keys first; the live one
105
+ -- (20260901000100:1277) locks `agents ... for update` and THEN loops the keys in id order,
106
+ -- and revoke_fleet_subject's signing_key branch does the same (agent, then key). Matching the
107
+ -- transaction this gate exists to serialize against is the whole point, so this helper takes
108
+ -- agents first too.
109
+ --
110
+ -- KNOWN AND STATED: today every RPC that REVOKES a key (_revoke_agent_principal,
111
+ -- revoke_fleet_subject) locks the agent row first, so the agents lock alone would already
112
+ -- serialize them. The agent_signing_keys FOR SHARE is load-bearing against a key-ONLY writer:
113
+ -- issue_signing_key_challenge locks the key row and no agent row, and the table's UPDATE
114
+ -- privilege is held by the owner alone (service_role has SELECT only), so owner-level
115
+ -- incident DML is the other. The isolation spec pins that lock with the challenge RPC rather
116
+ -- than claiming it from the code.
117
+ --
118
+ -- WHAT IS CHECKED, AND WHAT IS DELIBERATELY NOT RE-CHECKED. The predicate is: the decision's
119
+ -- verification_status is EXACTLY 'verified'; agents.status = 'active'; the key row identified
120
+ -- by the decision exists, is owned by that same actor, has status = 'active', and is
121
+ -- unexpired.
122
+ --
123
+ -- THE verification_status HALF, AND THE ARGUMENT IT REPLACES. The first version of this
124
+ -- helper checked no outcome at all, and the header argued that refusing an ABSENT key was
125
+ -- sufficient because "granting a role is not an operation with an observe-only meaning".
126
+ -- That argument was wrong in a way worth recording: key-absence is a PROXY for unsigned, and
127
+ -- the proxy leaks. A verification that FAILED plausibly still names the key it resolved, so a
128
+ -- decision reading {agent_id: <live owner>, signing_key_id: <live key>,
129
+ -- verification_status: 'failed'} satisfied every check in the chain -- _signed_write_actor_id
130
+ -- (20260710000000:53-63) asks only that an actor be named, this helper asked only that a key
131
+ -- be named and live, and _record_signed_write_evidence explicitly ADMITS 'failed'
132
+ -- (20260710000000:88). Reproduced on a database built from the manifest: the grant COMPLETED
133
+ -- and the evidence row recorded 'failed' beside a mutation that had succeeded. The proxy is
134
+ -- now a second line behind the outcome itself.
135
+ --
136
+ -- WHY EXACTLY 'verified' AND NOT A SET. The column vocabulary is
137
+ -- ('unsigned','observed','warn','verified','failed') -- the CHECK at 20260628000000:89,
138
+ -- mirrored by the client's catalog_vocabularies.ts. It is tempting to admit 'warn' as a
139
+ -- "verified with a caveat", and that reading is simply false:
140
+ -- specs/agent-provenance-signing.md:258-259 defines 'observed' and 'warn' as the labels an
141
+ -- UNSIGNED write receives under enforcement_mode 'observe' and 'warn'. They are enforcement
142
+ -- artifacts, not verification outcomes, so admitting either would re-open this hole under a
143
+ -- different name. 'verified' is the only value meaning a signature was checked and passed.
144
+ -- The comparison is `is distinct from`, so absent, empty and unknown values fail CLOSED in
145
+ -- the same branch -- including the values of the DIFFERENT, client-side
146
+ -- SigningVerificationStatus vocabulary ('signed', 'valid_signature': contract.ts:268), which
147
+ -- must never satisfy a gate written against the evidence vocabulary.
148
+ --
149
+ -- NOT HOISTED INTO _signed_write_actor_id, for the reason the next section gives at length:
150
+ -- the five message/task wrappers legitimately dispatch unsigned under 'observe'. This gate is
151
+ -- authority-only, and the class assertion in agent_board_signer_lifecycle.test.sql section 6
152
+ -- is what keeps "authority-only" true as wrappers are added.
153
+ --
154
+ -- The two fleet-policy call sites that accept ('verified','warn','observed')
155
+ -- (20260628000005:305, 20260826000100:242, 20260629000101:305) are a DIFFERENT function class
156
+ -- with a weaker bar and are deliberately not touched here. `proof_state = 'proven'` and
157
+ -- `revoked_at is null` are NOT repeated in the predicate: the table CHECKs
158
+ -- agent_signing_keys_active_requires_proof (status <> 'active' or proof_state = 'proven') and
159
+ -- agent_signing_keys_revoked_shape (revoked_at is not null exactly when status is
160
+ -- revoked/compromised) make both consequences of status = 'active'. Restating them would add
161
+ -- two branches that NO mutation could make an assertion fail on — an unpinnable construct
162
+ -- (CLAUDE.md rule 2). The reliance is asserted instead: the pgTAP file proves each CHECK
163
+ -- positively, by trying to write an active-unproven and an active-with-revoked_at row and
164
+ -- requiring the constraint to reject it, so dropping a CHECK fails the suite rather than
165
+ -- silently unguarding this predicate.
166
+ --
167
+ -- WHICH IDENTIFIER NAMES THE KEY. `signing_key_id` — the primary key — not key_id/key_version.
168
+ -- It is the row identity Portal already resolved to verify the signature and the one it hands
169
+ -- reserve_signed_write_replay(p_signing_key_id), so it is the only identifier that names the
170
+ -- SAME row the signature was checked against. key_id and key_version are unique only PER
171
+ -- AGENT, so resolving by them would need the actor to disambiguate and would silently pick a
172
+ -- different row if the decision's actor and key disagreed. They are still validated, as a
173
+ -- consistency assertion: _record_signed_write_evidence copies key_id and key_version from the
174
+ -- decision into signature_evidence verbatim, so a decision whose text identifiers disagree
175
+ -- with the row this helper just revalidated would mint an evidence row naming a key that was
176
+ -- never checked. That is refused, not recorded.
177
+ --
178
+ -- BLAST RADIUS. Zero on the five pre-existing wrappers: _signed_write_actor_id is untouched
179
+ -- and they gain no call. The two authority wrappers keep their (jsonb, jsonb) signature, so
180
+ -- CREATE OR REPLACE preserves their ACLs; their COMMENTs are restated because their behaviour
181
+ -- changed. The new helper is a fresh CREATE and therefore restates revoke/grant/comment in
182
+ -- this same migration — a SECURITY DEFINER function grants EXECUTE to PUBLIC by default.
183
+
184
+ -- 1. The revalidation chokepoint.
185
+ create or replace function agent_board._assert_signed_authority_signer_live(
186
+ p_actor_agent_id uuid,
187
+ p_gateway_decision jsonb
188
+ )
189
+ returns void
190
+ language plpgsql
191
+ security definer
192
+ set search_path = agent_board, public
193
+ as $$
194
+ declare
195
+ v_signing_key_id uuid := nullif(p_gateway_decision ->> 'signing_key_id', '')::uuid;
196
+ v_decision_key_id text := nullif(p_gateway_decision ->> 'key_id', '');
197
+ v_decision_key_version integer := nullif(p_gateway_decision ->> 'key_version', '')::integer;
198
+ v_verification_status text := nullif(p_gateway_decision ->> 'verification_status', '');
199
+ v_agent_status text;
200
+ v_key record;
201
+ begin
202
+ if p_actor_agent_id is null then
203
+ raise exception 'signer_not_active: signed authority write requires a resolved signer'
204
+ using errcode = '42501';
205
+ end if;
206
+
207
+ -- THE OUTCOME ITSELF, checked before anything else. Everything below revalidates the
208
+ -- signer's LIFECYCLE; none of it asks whether the signature actually verified, and the
209
+ -- gateway's own verdict is the only thing that can answer that. Refusing an absent key
210
+ -- (below) is a PROXY for "unsigned" and it leaks: a verification that FAILED plausibly
211
+ -- still names the key it resolved, so a decision carrying verification_status='failed'
212
+ -- beside a live signer and a live key passed every other check here and COMPLETED the
213
+ -- authority mutation, leaving an evidence row that recorded 'failed' next to a write that
214
+ -- succeeded. Checked first, and before any row lock, because an unverified decision has no
215
+ -- standing regardless of whose key it names.
216
+ --
217
+ -- EXACTLY 'verified', not a set. The column vocabulary is
218
+ -- ('unsigned','observed','warn','verified','failed') (20260628000000:89, mirrored in the
219
+ -- client's catalog_vocabularies.ts). Of those, 'observed' and 'warn' are NOT weaker
220
+ -- verifications -- specs/agent-provenance-signing.md:258-259 defines both as the labels an
221
+ -- UNSIGNED write receives under enforcement_mode 'observe' and 'warn' respectively. So
222
+ -- 'verified' is the only value that means a signature was checked and passed, and the
223
+ -- accepted set has exactly one member. `is distinct from` makes absent, empty and unknown
224
+ -- fail CLOSED in the same branch -- which also covers the client-side
225
+ -- SigningVerificationStatus vocabulary ('signed', 'valid_signature': contract.ts:268), a
226
+ -- DIFFERENT vocabulary whose values must never satisfy this gate.
227
+ if v_verification_status is distinct from 'verified' then
228
+ raise exception 'signer_not_verified: signed authority write requires a verified gateway decision, got %',
229
+ coalesce(v_verification_status, '(absent)')
230
+ using errcode = '42501';
231
+ end if;
232
+
233
+ -- A signed AUTHORITY write must name the key it was signed with. The message wrappers
234
+ -- deliberately accept an unsigned decision under enforcement_mode='observe'; granting a
235
+ -- role is not an operation with an observe-only meaning, so an absent key is refused here
236
+ -- rather than recorded as 'unsigned'. This is now a SECOND line behind the verification
237
+ -- check above, not the thing standing in for it.
238
+ if v_signing_key_id is null then
239
+ raise exception 'signer_key_absent: signed authority write requires a gateway-verified signing key'
240
+ using errcode = '42501';
241
+ end if;
242
+
243
+ -- agents BEFORE agent_signing_keys (see header). FOR SHARE, not FOR UPDATE.
244
+ select a.status
245
+ into v_agent_status
246
+ from agent_board.agents a
247
+ where a.id = p_actor_agent_id
248
+ for share;
249
+
250
+ if not found then
251
+ raise exception 'signer_not_active: signed authority write signer is not a known agent'
252
+ using errcode = '42501';
253
+ end if;
254
+ if v_agent_status <> 'active' then
255
+ raise exception 'signer_not_active: signed authority write signer is %, not active', v_agent_status
256
+ using errcode = '42501';
257
+ end if;
258
+
259
+ select k.agent_id, k.key_id, k.key_version, k.status, k.expires_at
260
+ into v_key
261
+ from agent_board.agent_signing_keys k
262
+ where k.id = v_signing_key_id
263
+ for share;
264
+
265
+ if not found then
266
+ raise exception 'signer_key_absent: signed authority write names a signing key that does not exist'
267
+ using errcode = '42501';
268
+ end if;
269
+
270
+ -- The ownership half. A key that is perfectly live but belongs to somebody else authorizes
271
+ -- nobody: without this, a decision could pair actor A with agent B's healthy key and pass
272
+ -- the liveness predicate on a key A never held.
273
+ if v_key.agent_id <> p_actor_agent_id then
274
+ raise exception 'signer_key_not_owned: signed authority write signing key belongs to another agent'
275
+ using errcode = '42501';
276
+ end if;
277
+
278
+ if v_key.status <> 'active' then
279
+ raise exception 'signer_key_not_live: signed authority write signing key is %, not active', v_key.status
280
+ using errcode = '42501';
281
+ end if;
282
+
283
+ if v_key.expires_at is not null and v_key.expires_at <= now() then
284
+ raise exception 'signer_key_expired: signed authority write signing key expired at %', v_key.expires_at
285
+ using errcode = '42501';
286
+ end if;
287
+
288
+ -- Evidence integrity: the identifiers copied into signature_evidence must name the row that
289
+ -- was just revalidated.
290
+ if v_decision_key_id is not null and v_decision_key_id <> v_key.key_id then
291
+ raise exception 'signer_key_mismatch: gateway decision key_id does not match the revalidated signing key'
292
+ using errcode = '42501';
293
+ end if;
294
+ if v_decision_key_version is not null and v_decision_key_version <> v_key.key_version then
295
+ raise exception 'signer_key_mismatch: gateway decision key_version does not match the revalidated signing key'
296
+ using errcode = '42501';
297
+ end if;
298
+ end;
299
+ $$;
300
+
301
+ comment on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) is
302
+ '#269 signer-lifecycle revalidation for the signed AUTHORITY wrappers. Requires the gateway decision''s verification_status to be EXACTLY ''verified'' before anything else and before any row lock (''observed'' and ''warn'' label UNSIGNED writes accepted under those enforcement modes, not weaker verifications, so the accepted set has one member; absent/empty/unknown fail closed). Then, inside the wrapper''s own transaction, takes FOR SHARE on the signer''s agents row and then on the agent_signing_keys row named by p_gateway_decision->>''signing_key_id'' (agents first, matching _revoke_agent_principal and revoke_fleet_subject), and requires the agent to be active and the key to exist, be owned by that same agent, be active and unexpired, with key_id/key_version agreeing with the decision. FOR SHARE is chosen deliberately: FOR KEY SHARE would not conflict with the FOR NO KEY UPDATE a status change takes, and FOR UPDATE would block every concurrent FK insert referencing the agent. proof_state=''proven'' and revoked_at is null are implied by the agent_signing_keys_active_requires_proof and agent_signing_keys_revoked_shape CHECKs and are asserted there rather than duplicated here. Raises 42501 with a signer_not_active / signer_key_absent / signer_key_not_owned / signer_key_not_live / signer_key_expired / signer_key_mismatch prefix. Internal: it locks rows for an arbitrary agent id with no authorization of its own.';
303
+
304
+ revoke all on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) from public, anon, authenticated;
305
+ grant execute on function agent_board._assert_signed_authority_signer_live(uuid, jsonb) to service_role;
306
+
307
+ -- 2. signed_grant_board_role — verbatim from 20260907000100 plus the revalidation, placed
308
+ -- after the inner grant and before the evidence row.
309
+ create or replace function agent_board.signed_grant_board_role(
310
+ p_payload jsonb,
311
+ p_gateway_decision jsonb
312
+ )
313
+ returns table(
314
+ board_id uuid,
315
+ agent_id uuid,
316
+ role text,
317
+ signature_evidence_id uuid,
318
+ verification_status text,
319
+ updated_at timestamptz
320
+ )
321
+ language plpgsql
322
+ security definer
323
+ set search_path = agent_board, public
324
+ as $$
325
+ declare
326
+ v_row record;
327
+ v_actor uuid;
328
+ v_evidence_id uuid;
329
+ v_board uuid := (p_payload ->> 'p_board_id')::uuid;
330
+ v_grantee uuid := (p_payload ->> 'p_agent_id')::uuid;
331
+ begin
332
+ -- The grantor is the gateway-verified signer. _signed_write_actor_id raises 42501 when
333
+ -- the gateway names no actor, and raises actor_mismatch when the payload names a
334
+ -- DIFFERENT one -- so a payload p_grantor_agent_id can never become the grantor. This
335
+ -- value is what grant_board_role binds (through its own _resolved_actor_agent_id) and
336
+ -- what the evidence row names; they cannot diverge.
337
+ v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_grantor_agent_id');
338
+
339
+ -- BOARD ADVISORY LOCK FIRST -- this is what makes the wrapper acyclic against
340
+ -- onboard_federated_fleet_agent. Without it the wrapper's order is
341
+ -- board_memberships(grantee) -> agents(signer), while onboarding takes
342
+ -- agents(20260901000110:349) -> board_memberships(:397): a genuine ABBA between two HONEST
343
+ -- callers, reproduced as `deadlock detected` on board_memberships with an owner who both
344
+ -- created a fleet invite and concurrently grants the enrollee a role -- routine operands on
345
+ -- one board. The inner grant_board_role takes no advisory lock (the catalog-pinned set of
346
+ -- ceiling-advisory takers is exactly onboard_federated_fleet_agent and
347
+ -- set_board_authority_ceiling), so the wrapper must take it.
348
+ --
349
+ -- Mutual exclusion, NOT a re-derived ordering. Both authority wrappers now take this as
350
+ -- their FIRST lock; onboarding takes it at :290 holding only board_invite_tokens and
351
+ -- fleet_onboarding_invite_bounds -- relations no authority wrapper ever touches -- so the
352
+ -- two cannot form a cycle whatever either does afterwards. That property survives a new lock
353
+ -- being added downstream, which an ordering argument would not.
354
+ --
355
+ -- Cost, accepted: a signed grant now serialises with onboarding and with ceiling writes on
356
+ -- the SAME board. Boards are the unit of contention and these are all authority mutations.
357
+ perform agent_board._lock_board_authority_ceiling(v_board);
358
+
359
+ select *
360
+ into v_row
361
+ from agent_board.grant_board_role(
362
+ v_board,
363
+ v_grantee,
364
+ p_payload ->> 'p_grantee_role',
365
+ v_actor
366
+ );
367
+
368
+ -- #269. The gateway's decision was made in an EARLIER transaction; a suspension or key
369
+ -- revocation can have committed since. Revalidate the signer under a share lock so a
370
+ -- concurrent revoke serializes against this write instead of racing it. After the inner
371
+ -- call by design (lock order, and it keeps the inner gates' errors first) -- same
372
+ -- transaction, so a raise here rolls the grant back.
373
+ perform agent_board._assert_signed_authority_signer_live(v_actor, p_gateway_decision);
374
+
375
+ v_evidence_id := agent_board._record_signed_write_evidence(
376
+ 'external',
377
+ null,
378
+ 'grant_board_role',
379
+ p_payload,
380
+ p_gateway_decision || jsonb_build_object(
381
+ 'external_evidence_ref',
382
+ 'board-authority:grant_board_role:' || v_board::text || ':' || v_grantee::text
383
+ )
384
+ );
385
+
386
+ return query
387
+ select v_row.board_id, v_row.agent_id, v_row.role, v_evidence_id,
388
+ coalesce(p_gateway_decision ->> 'verification_status', 'unsigned'), v_row.updated_at;
389
+ end;
390
+ $$;
391
+
392
+ comment on function agent_board.signed_grant_board_role(jsonb, jsonb) is
393
+ '#247 signed-write gateway wrapper for grant_board_role. Takes the board-keyed _lock_board_authority_ceiling advisory lock FIRST, before the inner grant: without it the wrapper''s order is board_memberships(grantee) then agents(signer) while onboard_federated_fleet_agent takes agents then board_memberships, a reproduced ABBA deadlock between two honest callers. Both authority wrappers now take that advisory lock first, and onboarding requests it holding only invite-scoped rows no wrapper touches, so they are serialised per board rather than merely ordered. Resolves the grantor with _signed_write_actor_id (gateway actor required; a payload p_grantor_agent_id that differs raises actor_mismatch 42501), passes THAT principal to grant_board_role so the bound grantor and the recorded one cannot diverge, and records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref naming the board and grantee. #269: after the grant and before the evidence row it calls _assert_signed_authority_signer_live, which requires the decision''s verification_status to be exactly ''verified'' (so a FAILED verification can no longer complete the grant and record ''failed'' beside it), then share-locks and revalidates the signer''s agents row and the decision''s signing key inside this transaction, so a suspension or key revocation racing the gateway''s separate verification call either serializes behind this write or fails it closed. Adds no authority: grant_board_role''s membership, ceiling and TOCTOU gates are untouched. service_role only, like every other signed_* wrapper (#118).';
394
+
395
+ -- 3. signed_set_board_authority_ceiling — same change, same position.
396
+ create or replace function agent_board.signed_set_board_authority_ceiling(
397
+ p_payload jsonb,
398
+ p_gateway_decision jsonb
399
+ )
400
+ returns table(
401
+ ceiling_board_id uuid,
402
+ ceiling_max_grantable_role text,
403
+ signature_evidence_id uuid,
404
+ verification_status text
405
+ )
406
+ language plpgsql
407
+ security definer
408
+ set search_path = agent_board, public
409
+ as $$
410
+ declare
411
+ v_row record;
412
+ v_actor uuid;
413
+ v_evidence_id uuid;
414
+ v_board uuid := (p_payload ->> 'p_board_id')::uuid;
415
+ begin
416
+ v_actor := agent_board._signed_write_actor_id(p_payload, p_gateway_decision, 'p_actor_agent_id');
417
+
418
+ select *
419
+ into v_row
420
+ from agent_board.set_board_authority_ceiling(
421
+ v_board,
422
+ p_payload ->> 'p_max_grantable_role',
423
+ v_actor
424
+ );
425
+
426
+ -- #269. See signed_grant_board_role. The position matters most here: the inner setter takes
427
+ -- the board-keyed advisory lock and then reaches boards through the ceiling FK, so
428
+ -- revalidating afterwards gives advisory -> boards -> agents -> keys, which is
429
+ -- onboard_federated_fleet_agent's order. Hoisting this above the inner call would invert
430
+ -- against it on (advisory, agents).
431
+ perform agent_board._assert_signed_authority_signer_live(v_actor, p_gateway_decision);
432
+
433
+ v_evidence_id := agent_board._record_signed_write_evidence(
434
+ 'external',
435
+ null,
436
+ 'set_board_authority_ceiling',
437
+ p_payload,
438
+ p_gateway_decision || jsonb_build_object(
439
+ 'external_evidence_ref',
440
+ 'board-authority:set_board_authority_ceiling:' || v_board::text || ':' || v_actor::text
441
+ )
442
+ );
443
+
444
+ return query
445
+ select v_row.ceiling_board_id, v_row.ceiling_max_grantable_role, v_evidence_id,
446
+ coalesce(p_gateway_decision ->> 'verification_status', 'unsigned');
447
+ end;
448
+ $$;
449
+
450
+ comment on function agent_board.signed_set_board_authority_ceiling(jsonb, jsonb) is
451
+ '#247 signed-write gateway wrapper for set_board_authority_ceiling. Resolves the actor with _signed_write_actor_id (gateway actor required; a differing payload p_actor_agent_id raises actor_mismatch 42501) and passes it as p_actor_agent_id, so the owner gate authorizes the SAME principal the evidence row names. Records a signature_evidence row via _record_signed_write_evidence with source_kind=external and a server-derived external_evidence_ref. #269: between the ceiling upsert and the evidence row it calls _assert_signed_authority_signer_live, which requires the decision''s verification_status to be exactly ''verified'' and then share-locks and revalidates the signer''s agents row and the decision''s signing key inside this transaction; it sits after the inner call so the wrapper''s lock order stays advisory -> boards -> agents -> agent_signing_keys, matching onboard_federated_fleet_agent. Adds no authority: the owner gate still rejects any actor that is not an active board owner. service_role only, like every other signed_* wrapper (#118).';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.25.0",
5
+ "packageVersion": "1.27.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 125,
50
- "aggregateSha256": "0ce03fcc8b0ae0113e9b6de2c8e8bc346cf735a86fcca0221ffc8103d604969f",
49
+ "migrationCount": 126,
50
+ "aggregateSha256": "7fbe4374b98783d1b88a075fd5b6c11723b40fccd6a4b4a7c7a80d3d1666ea2f",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1173,6 +1173,15 @@
1173
1173
  "sourceRef": "message-board",
1174
1174
  "bytes": 12633,
1175
1175
  "sha256": "21111e289e0bb70ac3236f5422476c0c51d0bca5a815c11dd4e48b6932cf7c6b"
1176
+ },
1177
+ {
1178
+ "order": 125,
1179
+ "id": "20260908000100_agent_board_signer_lifecycle_revalidation",
1180
+ "filename": "20260908000100_agent_board_signer_lifecycle_revalidation.sql",
1181
+ "origin": "message-board-forward",
1182
+ "sourceRef": "message-board",
1183
+ "bytes": 29336,
1184
+ "sha256": "bfb1ae60f69dae0a0aaf1007dbf14e171f324cdced1caf9f4d490f41a60c14e5"
1176
1185
  }
1177
1186
  ],
1178
1187
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 125)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 126)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(125);
43
+ expect(manifest.migrationCount).toBe(126);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -581,7 +581,12 @@ select is( -- pgtap-allow-raise-checked: catalog read only; the RPC names below
581
581
  'agent_board.reactivate_fleet_machine(uuid,uuid,text) [definer] => service_role',
582
582
  'agent_board.revoke_fleet_subject(text,uuid,uuid,text,jsonb) [definer] => authenticated,service_role',
583
583
  'agent_board.revoke_human_subtree(uuid,uuid,text) [definer] => service_role',
584
- 'agent_board.set_board_authority_ceiling(uuid,text,uuid) [definer] => authenticated,service_role'
584
+ 'agent_board.set_board_authority_ceiling(uuid,text,uuid) [definer] => authenticated,service_role',
585
+ -- Joined this class in #269 F4: the signed grant wrapper now takes the board advisory lock
586
+ -- FIRST, because its order was board_memberships(grantee) -> agents(signer) against
587
+ -- onboard_federated_fleet_agent's agents -> board_memberships -- a reproduced ABBA between
588
+ -- two honest callers. It is service_role-only like every other signed_* wrapper (#118).
589
+ 'agent_board.signed_grant_board_role(jsonb,jsonb) [definer] => service_role'
585
590
  ]::text[],
586
591
  'every function on this migration set''s surface is declared with its definer flag and exact EXECUTE grantees'
587
592
  );
@@ -26,7 +26,7 @@ set search_path = extensions, public, agent_board;
26
26
 
27
27
  begin;
28
28
 
29
- select plan(38);
29
+ select plan(40);
30
30
 
31
31
  create function pg_temp.claims(p_sub text) returns void language plpgsql as $$
32
32
  begin
@@ -293,6 +293,9 @@ select throws_like(
293
293
  '%exceeds grantor role or board authority ceiling%',
294
294
  'the wrapper grants no authority: a member signer still cannot mint an owner');
295
295
 
296
+ -- This probe named a 'failed' verification but carried NO agent_id, so it was refused for
297
+ -- ACTOR ABSENCE and never once exercised the failed status -- a description claiming what no
298
+ -- assertion backed. It is now two probes, each refused for the reason it names.
296
299
  select throws_like(
297
300
  $$select * from agent_board.signed_grant_board_role(
298
301
  jsonb_build_object(
@@ -303,6 +306,26 @@ select throws_like(
303
306
  '%actor_mismatch%',
304
307
  'a gateway decision naming no actor cannot write evidence at all');
305
308
 
309
+ -- The status probe proper: the SAME decision the allow-side probes use -- verified signer,
310
+ -- live key, an owner granting a role the inner gates permit -- with only verification_status
311
+ -- flipped to 'failed'. Without the #269 verification check this grant COMPLETES and mints an
312
+ -- evidence row recording 'failed' beside a mutation that succeeded.
313
+ select throws_like(
314
+ $$select * from agent_board.signed_grant_board_role(
315
+ jsonb_build_object(
316
+ 'p_board_id', '24720000-0000-4000-8000-000000000001',
317
+ 'p_agent_id', '24710000-0000-4000-8000-000000000002',
318
+ 'p_grantee_role', 'admin'),
319
+ pg_temp.gw('24710000-0000-4000-8000-000000000001')
320
+ || jsonb_build_object('verification_status','failed'))$$,
321
+ '%signer_not_verified%',
322
+ 'a FAILED verification is refused for its STATUS, by a signer who is otherwise fully entitled');
323
+
324
+ select is(
325
+ (select count(*)::int from agent_board.signature_evidence where operation = 'grant_board_role'),
326
+ 1,
327
+ 'and the failed-verification attempt minted no evidence row');
328
+
306
329
  -- ===========================================================================
307
330
  -- 5. signed_set_board_authority_ceiling.
308
331
  -- ===========================================================================