@consiliency/agent-board-schema 1.28.0 → 1.35.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 +28 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/package.json +1 -1
- package/schema-manifest.json +66 -3
- package/src/tooling.test.mjs +47 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_ledger_authority.test.sql +142 -71
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +157 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/test-prereqs.sql +420 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
|
@@ -646,13 +646,30 @@ select ok(
|
|
|
646
646
|
'and it takes them agents-BEFORE-keys, matching _revoke_agent_principal [SOLE PIN: no isolation permutation discriminates this ordering]');
|
|
647
647
|
|
|
648
648
|
-- ===========================================================================
|
|
649
|
-
-- 9. CLASS
|
|
649
|
+
-- 9. CLASS: the agents/board_memberships cycle class AS FAR AS A TEXT SCAN CAN SEE IT.
|
|
650
650
|
--
|
|
651
651
|
-- F4 was an ABBA between signed_grant_board_role and onboard_federated_fleet_agent on
|
|
652
|
-
-- (board_memberships, agents). The fix is only as good as the claim
|
|
653
|
-
--
|
|
654
|
-
--
|
|
655
|
-
--
|
|
652
|
+
-- (board_memberships, agents). The fix is only as good as the claim about which functions
|
|
653
|
+
-- lock both relations. Any function that row-locks both can close the same cycle, so every
|
|
654
|
+
-- member here must take the board advisory lock that serialises them.
|
|
655
|
+
--
|
|
656
|
+
-- WHAT THIS ASSERTION ACTUALLY DELIVERS, corrected under message-board#281. It finds locks by
|
|
657
|
+
-- matching `for (update|share)` and `update agent_board.board_memberships` in the body text.
|
|
658
|
+
-- That is a claim about EXPLICIT locks only. A lock PostgreSQL takes on our behalf through a
|
|
659
|
+
-- FOREIGN KEY appears nowhere in a body, so this scan is structurally blind to it, and the
|
|
660
|
+
-- header it carried until #281 — "every function that row-locks BOTH" — claimed a closure it
|
|
661
|
+
-- cannot compute. Measured: this scan finds THREE members; the FK-aware derivation in
|
|
662
|
+
-- agent_board_fk_lock_edges.test.sql finds 28. One of the 25 it cannot see,
|
|
663
|
+
-- `_upsert_board_membership_state` (board_memberships FOR UPDATE, then agents FOR KEY SHARE
|
|
664
|
+
-- via board_membership_events_agent_id_fkey inside `_emit_membership_event`), is one half of a
|
|
665
|
+
-- live ABBA against onboard_federated_fleet_agent.
|
|
666
|
+
--
|
|
667
|
+
-- So this assertion is kept for what it IS — the explicit-lock members must stay
|
|
668
|
+
-- advisory-serialised, and a new explicit both-locker still names itself here — and the
|
|
669
|
+
-- class-completeness claim now lives in agent_board_fk_lock_edges.test.sql (FKL-9), which
|
|
670
|
+
-- derives FK-implied edges from pg_constraint. It is NOT upgraded in place: the FK-aware class
|
|
671
|
+
-- is 25 members short of serialised, and greening THAT is the ordering fix #281 deliberately
|
|
672
|
+
-- separates from the tooling.
|
|
656
673
|
--
|
|
657
674
|
-- THE CALL GRAPH IS CLOSED OVER FIRST, because a body-text scan cannot see a lock taken in a
|
|
658
675
|
-- CALLEE and EVERY lock the wrapper takes is in one: board_memberships in grant_board_role's
|
|
@@ -708,7 +725,7 @@ select is(
|
|
|
708
725
|
'onboard_federated_fleet_agent => serialised by the board advisory lock',
|
|
709
726
|
'signed_grant_board_role => serialised by the board advisory lock'
|
|
710
727
|
]::text[],
|
|
711
|
-
'every function that row-locks
|
|
728
|
+
'every function that EXPLICITLY row-locks both agents and board_memberships takes the board advisory lock (transitive; a new one names itself). FK-implied locks are invisible here and are covered by agent_board_fk_lock_edges.test.sql');
|
|
712
729
|
|
|
713
730
|
select finish();
|
|
714
731
|
|
|
@@ -29,7 +29,7 @@ set search_path = extensions, public, agent_board;
|
|
|
29
29
|
|
|
30
30
|
begin;
|
|
31
31
|
|
|
32
|
-
select plan(
|
|
32
|
+
select plan(47);
|
|
33
33
|
|
|
34
34
|
-- ---------------------------------------------------------------------------
|
|
35
35
|
-- The four demonstrated masking constructs.
|
|
@@ -205,6 +205,162 @@ select ok(
|
|
|
205
205
|
> length(pg_get_functiondef('agent_board.mark_retention_metadata(text,timestamp with time zone,uuid,text)'::regprocedure)) / 4,
|
|
206
206
|
'stripped_def on a body containing a dollar-quoted block keeps the surrounding code rather than collapsing the definition');
|
|
207
207
|
|
|
208
|
+
-- ===========================================================================
|
|
209
|
+
-- SQL-STANDARD BODIES (`BEGIN ATOMIC` / `RETURN <expr>`) -- message-board#293.
|
|
210
|
+
--
|
|
211
|
+
-- Such a function is PARSED at definition time and stored in pg_proc.prosqlbody;
|
|
212
|
+
-- its prosrc is the EMPTY STRING. stripped_body read prosrc only, so it returned
|
|
213
|
+
-- `''` for one -- and `''` satisfies every `!~` and defeats every `~`, so a writer
|
|
214
|
+
-- spelled that way was invisible to the class assertions in 22 test files at once.
|
|
215
|
+
-- The fixtures below are created INSIDE this rolled-back transaction and live in
|
|
216
|
+
-- agent_board on purpose: the CORPUS assertions further down scan that schema, so
|
|
217
|
+
-- they exercise the fix through a real class scan rather than only through a
|
|
218
|
+
-- direct call.
|
|
219
|
+
-- ===========================================================================
|
|
220
|
+
create table agent_board._mb293_sentinel (id int, note text);
|
|
221
|
+
|
|
222
|
+
-- (a) the BEGIN ATOMIC form.
|
|
223
|
+
create function agent_board._mb293_atomic_writer(p int) returns void
|
|
224
|
+
language sql
|
|
225
|
+
begin atomic
|
|
226
|
+
insert into agent_board._mb293_sentinel (id, note) values (p, 'mb293-literal-marker');
|
|
227
|
+
end;
|
|
228
|
+
|
|
229
|
+
-- (b) the single-expression RETURN form -- the OTHER SQL-standard-body syntax,
|
|
230
|
+
-- equally empty in prosrc. Named separately because a fix that special-cased
|
|
231
|
+
-- the literal string `BEGIN ATOMIC` would pass (a) and fail (b).
|
|
232
|
+
create function agent_board._mb293_atomic_reader() returns bigint
|
|
233
|
+
language sql
|
|
234
|
+
return (select count(*) from agent_board._mb293_sentinel);
|
|
235
|
+
|
|
236
|
+
-- (c) the prosrc twin: the SAME write, written the ordinary way. Every assertion
|
|
237
|
+
-- about (a) is asserted about (c) too, so a regression in the untouched
|
|
238
|
+
-- prosrc path -- the one the other 21 consumers use -- cannot hide behind a
|
|
239
|
+
-- green BEGIN ATOMIC assertion.
|
|
240
|
+
create function agent_board._mb293_prosrc_writer(p int) returns void
|
|
241
|
+
language sql as $mb$
|
|
242
|
+
-- mb293-comment-marker: prose naming a thing is not doing it
|
|
243
|
+
insert into agent_board._mb293_sentinel (id, note) values (p, 'mb293-literal-marker');
|
|
244
|
+
$mb$;
|
|
245
|
+
|
|
246
|
+
-- (d) a body that is genuinely UNREADABLE: prosrc empty AND prosqlbody null.
|
|
247
|
+
-- Reachable with plain DDL (`as $$$$`), no catalog surgery -- the runner's
|
|
248
|
+
-- role is not superuser. Lives in pg_temp, NOT agent_board, because
|
|
249
|
+
-- stripped_body now RAISES on it and the CORPUS scans below would abort.
|
|
250
|
+
create function pg_temp._mb293_unreadable() returns void language sql as $$$$;
|
|
251
|
+
|
|
252
|
+
-- PREMISE. Everything below is worthless if the fixtures are not actually the
|
|
253
|
+
-- shape they are named for, so the shape is measured, not assumed.
|
|
254
|
+
select is(
|
|
255
|
+
(select length(p.prosrc) || '/' || (p.prosqlbody is not null)::text
|
|
256
|
+
from pg_proc p where p.oid = 'agent_board._mb293_atomic_writer(int)'::regprocedure),
|
|
257
|
+
'0/true',
|
|
258
|
+
'SQLBODY PREMISE: the BEGIN ATOMIC fixture really does have an EMPTY prosrc and a non-null prosqlbody -- the condition the old stripped_body read as "this function does nothing"');
|
|
259
|
+
|
|
260
|
+
-- THE HEADLINE. Pre-fix this returned '' and the assertion was red.
|
|
261
|
+
select ok(
|
|
262
|
+
agent_board_test.stripped_body('agent_board._mb293_atomic_writer(int)'::regprocedure)
|
|
263
|
+
~* 'insert\s+into\s+agent_board\._mb293_sentinel\M',
|
|
264
|
+
'SQLBODY-1: stripped_body SEES the write in a BEGIN ATOMIC body');
|
|
265
|
+
|
|
266
|
+
select ok(
|
|
267
|
+
agent_board_test.stripped_body('agent_board._mb293_atomic_reader()'::regprocedure)
|
|
268
|
+
~* 'from\s+agent_board\._mb293_sentinel\M',
|
|
269
|
+
'SQLBODY-2: the `RETURN <expr>` form is read too -- it is the same prosqlbody shape under different syntax');
|
|
270
|
+
|
|
271
|
+
-- QUALIFICATION, and why it is a SEPARATE risk rather than a detail of the above.
|
|
272
|
+
-- pg_get_function_sqlbody DEPARSES the stored tree, and the deparser OMITS the
|
|
273
|
+
-- schema for any relation visible on the CURRENT search_path. Every consumer regex
|
|
274
|
+
-- in this suite spells `agent_board\.`, so a fallback deparsed under the session's
|
|
275
|
+
-- search_path would have replaced one fail-open with another. What prevents it is
|
|
276
|
+
-- `SET search_path = pg_catalog` on stripped_body itself. Measured both ways on
|
|
277
|
+
-- PG 17.6: `INSERT INTO agent_board.x` under pg_catalog, `INSERT INTO x` when
|
|
278
|
+
-- agent_board is visible.
|
|
279
|
+
select ok(
|
|
280
|
+
current_setting('search_path') ~ '\magent_board\M',
|
|
281
|
+
'SQLBODY-3 (NON-VACUITY for SQLBODY-1/-2): the SESSION search_path really does make agent_board visible, so an unpinned deparse WOULD drop the schema -- the trap is live, not hypothetical');
|
|
282
|
+
|
|
283
|
+
-- LITERAL SYMMETRY, mirroring MASK 4. A deparsed body carries no author comments
|
|
284
|
+
-- (they are parsed away), but it DOES carry string literals, so the strip must
|
|
285
|
+
-- still apply to it and `p_keep_literals` must still mean what it means.
|
|
286
|
+
select ok(
|
|
287
|
+
agent_board_test.stripped_body('agent_board._mb293_atomic_writer(int)'::regprocedure)
|
|
288
|
+
!~ 'mb293-literal-marker',
|
|
289
|
+
'SQLBODY-4: the default strip removes a string literal from a DEPARSED body too -- strip_sql is applied to it, not bypassed');
|
|
290
|
+
|
|
291
|
+
select ok(
|
|
292
|
+
agent_board_test.stripped_body('agent_board._mb293_atomic_writer(int)'::regprocedure, true)
|
|
293
|
+
~ 'mb293-literal-marker',
|
|
294
|
+
'SQLBODY-5: ...and keep-literals mode keeps it, so the two modes are not silently the same function');
|
|
295
|
+
|
|
296
|
+
-- NON-REGRESSION on the prosrc path, at the same call shape.
|
|
297
|
+
select ok(
|
|
298
|
+
agent_board_test.stripped_body('agent_board._mb293_prosrc_writer(int)'::regprocedure)
|
|
299
|
+
~* 'insert\s+into\s+agent_board\._mb293_sentinel\M',
|
|
300
|
+
'SQLBODY-6: the ordinary prosrc path still sees its write -- the path the other 21 consumers use is unchanged');
|
|
301
|
+
|
|
302
|
+
select ok(
|
|
303
|
+
agent_board_test.stripped_body('agent_board._mb293_prosrc_writer(int)'::regprocedure)
|
|
304
|
+
!~ 'mb293-comment-marker',
|
|
305
|
+
'SQLBODY-7: ...and still strips its comments');
|
|
306
|
+
|
|
307
|
+
-- LOUD, NOT SILENT. There is no value stripped_body can return for a body it
|
|
308
|
+
-- cannot read that a class scan reads correctly: '' satisfies every negative
|
|
309
|
+
-- assertion, and NULL is DROPPED by the `where … ~ …` shape most consumers use.
|
|
310
|
+
-- So it raises. Both raising branches are pinned.
|
|
311
|
+
select is(
|
|
312
|
+
(select (p.prosrc = '' and p.prosqlbody is null)::text
|
|
313
|
+
from pg_proc p where p.oid = 'pg_temp._mb293_unreadable()'::regprocedure),
|
|
314
|
+
'true',
|
|
315
|
+
'SQLBODY-8 (NON-VACUITY for SQLBODY-9): the unreadable fixture really has an empty prosrc AND a null prosqlbody');
|
|
316
|
+
|
|
317
|
+
select throws_ok(
|
|
318
|
+
$$ select agent_board_test.stripped_body('pg_temp._mb293_unreadable()'::regprocedure) $$,
|
|
319
|
+
null, null,
|
|
320
|
+
'SQLBODY-9: LOUD -- an unreadable body RAISES instead of returning '''' (which every `!~` accepts) or NULL (which a `where … ~ …` scan drops silently)');
|
|
321
|
+
|
|
322
|
+
select throws_ok(
|
|
323
|
+
$$ select agent_board_test.stripped_body(0::oid) $$,
|
|
324
|
+
null, null,
|
|
325
|
+
'SQLBODY-10: LOUD -- an oid with no pg_proc row RAISES rather than returning the NULL a zero-row SELECT used to produce');
|
|
326
|
+
|
|
327
|
+
-- CLASS-SCAN INTEGRATION. The nine assertions above call stripped_body directly.
|
|
328
|
+
-- This one is the shape the 22 consumer files actually use -- a catalog-derived
|
|
329
|
+
-- exact-set scan over agent_board -- and it is the one that shows the BEGIN ATOMIC
|
|
330
|
+
-- writer is now NAMED by such a scan rather than omitted from it.
|
|
331
|
+
select is(
|
|
332
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
|
|
333
|
+
from pg_proc p
|
|
334
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
335
|
+
where n.nspname = 'agent_board'
|
|
336
|
+
and p.prolang <> (select oid from pg_language where lanname = 'internal')
|
|
337
|
+
and agent_board_test.stripped_body(p.oid) ~* 'insert\s+into\s+agent_board\._mb293_sentinel\M'),
|
|
338
|
+
'_mb293_atomic_writer, _mb293_prosrc_writer',
|
|
339
|
+
'SQLBODY-11: a catalog-derived exact-set writer scan -- the shape every consumer file uses -- now names the BEGIN ATOMIC writer alongside the ordinary one');
|
|
340
|
+
|
|
341
|
+
-- stripped_def is in the SAME class and is fixed with it: for a BEGIN ATOMIC
|
|
342
|
+
-- function pg_get_functiondef renders the body INLINE, and the old code returned
|
|
343
|
+
-- that definition RAW (literals intact) whenever prosrc was empty.
|
|
344
|
+
select ok(
|
|
345
|
+
agent_board_test.stripped_def('agent_board._mb293_atomic_writer(int)'::regprocedure)
|
|
346
|
+
~* 'insert\s+into\s+agent_board\._mb293_sentinel\M',
|
|
347
|
+
'SQLBODY-12: stripped_def still contains the BEGIN ATOMIC body -- the splice found it, it was not deleted');
|
|
348
|
+
|
|
349
|
+
select ok(
|
|
350
|
+
agent_board_test.stripped_def('agent_board._mb293_atomic_writer(int)'::regprocedure)
|
|
351
|
+
!~ 'mb293-literal-marker',
|
|
352
|
+
'SQLBODY-13: stripped_def STRIPS the literals of a BEGIN ATOMIC body -- previously it returned the definition unstripped');
|
|
353
|
+
|
|
354
|
+
select ok(
|
|
355
|
+
agent_board_test.stripped_def('agent_board._mb293_atomic_writer(int)'::regprocedure, true)
|
|
356
|
+
~ 'mb293-literal-marker',
|
|
357
|
+
'SQLBODY-14: ...and keep-literals mode keeps them, so SQLBODY-13 is not passing because the body vanished');
|
|
358
|
+
|
|
359
|
+
select throws_ok(
|
|
360
|
+
$$ select agent_board_test.stripped_def(0::oid) $$,
|
|
361
|
+
null, null,
|
|
362
|
+
'SQLBODY-15: stripped_def is LOUD on a missing oid too -- it used to return NULL');
|
|
363
|
+
|
|
208
364
|
-- ---------------------------------------------------------------------------
|
|
209
365
|
-- CORPUS (catalog-derived). The vectors above are written by hand and can only
|
|
210
366
|
-- prove what their author thought of. These run the scanner over every live
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
-- Consiliency/message-board#281 (R1) pgTAP: ONE order for (threads, tasks) --
|
|
2
|
+
-- THREADS BEFORE TASKS -- pinned as a CLASS, plus the guard the reorder could have
|
|
3
|
+
-- broken, plus the behaviour the reorder could have silently changed.
|
|
4
|
+
--
|
|
5
|
+
-- WHY A CLASS AND NOT TWO FUNCTION CHECKS. The #281 triage said reordering
|
|
6
|
+
-- append_task_event alone "removes all 40 rows -- no other explicit lock on either
|
|
7
|
+
-- relation participates". That was true of EXPLICIT locks and false of the fix:
|
|
8
|
+
-- `_upsert_delivery_recipient` takes no explicit lock at all (KEY SHARE on tasks through
|
|
9
|
+
-- delivery_recipients_source_task_id_fkey, then an UPDATE on threads), and moving
|
|
10
|
+
-- append_task_event to threads-first promoted it into the cycle set -- 186 -> 150 with four
|
|
11
|
+
-- rows still on the pair. A hand-written list of "the functions that matter" produced
|
|
12
|
+
-- exactly the wrong list. TT-1 therefore enumerates the class from
|
|
13
|
+
-- agent_board_test.lock_edges() (catalog-derived: pg_constraint / pg_attribute / pg_class
|
|
14
|
+
-- by OID, plus stripped_body regexes -- read that function's header before reading a green
|
|
15
|
+
-- run here as a closure claim), so a NEW function that takes tasks before threads fails and
|
|
16
|
+
-- NAMES ITSELF.
|
|
17
|
+
--
|
|
18
|
+
-- TIES ARE RENDERED, NOT ROUNDED. Six of these functions take both relations in ONE
|
|
19
|
+
-- statement (`create_task`, `acquire_claim`, `add_artifact`, `delegate_task`,
|
|
20
|
+
-- `raise_conflict`, `raise_escalation` -- an insert whose FKs reach both parents). FKL-9's
|
|
21
|
+
-- two-branch `case` maps a tie to its else-branch, which would report those six as
|
|
22
|
+
-- "threads-first" -- a claim the derivation does not support. They are rendered as
|
|
23
|
+
-- `same statement` here, so the assertion says only what it knows.
|
|
24
|
+
--
|
|
25
|
+
-- WHAT IS ASSERTED, AND WHAT IS NOT.
|
|
26
|
+
-- * TT-1/TT-2 are the ORDER. They do not claim, and this file does not claim, that a
|
|
27
|
+
-- deadlock was reachable before the fix: `threads.last_message_sequence`/`updated_at`
|
|
28
|
+
-- are not in any unique index on threads (threads_pkey(id) is the only one), so
|
|
29
|
+
-- `_next_thread_message_sequence`'s UPDATE takes FOR NO KEY UPDATE, which does not
|
|
30
|
+
-- conflict with the FOR KEY SHARE append_task_event takes on threads. The 40 rows were
|
|
31
|
+
-- an over-report of the strongest-mode aggregation. The order is fixed because the
|
|
32
|
+
-- cycle goes live the moment any caller strengthens a mode on either relation.
|
|
33
|
+
-- * TT-6 pins the lock the reorder could have been "simplified" into dropping. A fix that
|
|
34
|
+
-- traded a lock-order disagreement for a duplicate-key race would look greener, not
|
|
35
|
+
-- redder, in every other assertion here.
|
|
36
|
+
-- * TT-8..TT-12 are behaviour. A file that only reads the catalog would pass against a
|
|
37
|
+
-- function that no longer works.
|
|
38
|
+
create extension if not exists pgtap with schema extensions;
|
|
39
|
+
set search_path = extensions, public, agent_board;
|
|
40
|
+
|
|
41
|
+
begin;
|
|
42
|
+
|
|
43
|
+
select plan(13);
|
|
44
|
+
|
|
45
|
+
-- ---------------------------------------------------------------------------
|
|
46
|
+
-- The derivation, materialized once. Same shape as agent_board_fk_lock_edges.test.sql:
|
|
47
|
+
-- `first_path` is the position PATH of the first acquisition (caller offset prepended to
|
|
48
|
+
-- the callee's, so ordering survives nesting); `strongest` is the strongest mode taken
|
|
49
|
+
-- anywhere, which over-reports conflicts rather than under-reporting them.
|
|
50
|
+
-- ---------------------------------------------------------------------------
|
|
51
|
+
create temp table _tt_le on commit drop as select * from agent_board_test.lock_edges();
|
|
52
|
+
|
|
53
|
+
create temp table _tt_acq on commit drop as
|
|
54
|
+
select fn_oid, fn_name, fn_sig, rel_oid, rel_name,
|
|
55
|
+
min(path) as first_path,
|
|
56
|
+
(array_agg(lock_mode order by agent_board_test.row_lock_rank(lock_mode) desc))[1] as strongest
|
|
57
|
+
from _tt_le
|
|
58
|
+
group by fn_oid, fn_name, fn_sig, rel_oid, rel_name;
|
|
59
|
+
|
|
60
|
+
create temp table _tt_ord on commit drop as
|
|
61
|
+
select distinct h.fn_name || ': ' ||
|
|
62
|
+
case when h.first_path < t.first_path
|
|
63
|
+
then 'threads(' || h.strongest || ') then tasks(' || t.strongest || ')'
|
|
64
|
+
when t.first_path < h.first_path
|
|
65
|
+
then 'tasks(' || t.strongest || ') then threads(' || h.strongest || ')'
|
|
66
|
+
else 'same statement: threads(' || h.strongest || ') + tasks(' || t.strongest || ')' end as m,
|
|
67
|
+
(t.first_path < h.first_path) as tasks_first
|
|
68
|
+
from _tt_acq h
|
|
69
|
+
join _tt_acq t on t.fn_oid = h.fn_oid and t.fn_sig = h.fn_sig
|
|
70
|
+
where h.rel_name = 'agent_board.threads'
|
|
71
|
+
and t.rel_name = 'agent_board.tasks';
|
|
72
|
+
|
|
73
|
+
-- ===========================================================================
|
|
74
|
+
-- TT-1: THE CLASS. Every function whose lock graph contains BOTH threads and tasks, with
|
|
75
|
+
-- the order it takes them. NOT ONE says `tasks(...) then threads(...)`, and that is the
|
|
76
|
+
-- whole content of the #281 R1 fix. Asserted as an EXACT SET rather than as "none is
|
|
77
|
+
-- tasks-first", because an exact set cannot pass vacuously if the derivation breaks, and a
|
|
78
|
+
-- new member fails here carrying its own name and order.
|
|
79
|
+
-- ===========================================================================
|
|
80
|
+
select is(
|
|
81
|
+
(select array_agg(m order by m) from _tt_ord),
|
|
82
|
+
array[
|
|
83
|
+
'_upsert_delivery_recipient: threads(update) then tasks(key share)',
|
|
84
|
+
'acquire_claim: same statement: threads(key share) + tasks(update)',
|
|
85
|
+
'add_artifact: same statement: threads(key share) + tasks(update)',
|
|
86
|
+
'append_task_event: threads(key share) then tasks(update)',
|
|
87
|
+
'create_task: same statement: threads(key share) + tasks(key share)',
|
|
88
|
+
'create_tasking_delivery: same statement: threads(update) + tasks(key share)',
|
|
89
|
+
'delegate_task: same statement: threads(key share) + tasks(update)',
|
|
90
|
+
'post_board_message: threads(update) then tasks(key share)',
|
|
91
|
+
'post_message: threads(update) then tasks(key share)',
|
|
92
|
+
'raise_conflict: same statement: threads(key share) + tasks(update)',
|
|
93
|
+
'raise_escalation: same statement: threads(key share) + tasks(update)',
|
|
94
|
+
'resolve_conflict: threads(key share) then tasks(update)',
|
|
95
|
+
'resolve_escalation: threads(key share) then tasks(update)',
|
|
96
|
+
'send_direct_message: threads(update) then tasks(key share)',
|
|
97
|
+
'send_multicast_message: threads(update) then tasks(key share)',
|
|
98
|
+
'signed_append_task_event: threads(key share) then tasks(update)',
|
|
99
|
+
'signed_create_tasking_delivery: same statement: threads(update) + tasks(key share)',
|
|
100
|
+
'signed_post_board_message: threads(update) then tasks(key share)',
|
|
101
|
+
'signed_send_direct_message: threads(update) then tasks(key share)',
|
|
102
|
+
'signed_send_multicast_message: threads(update) then tasks(key share)'
|
|
103
|
+
]::text[],
|
|
104
|
+
'TT-1: the threads+tasks lock class, FK edges included -- every member is threads-first or single-statement');
|
|
105
|
+
|
|
106
|
+
-- ===========================================================================
|
|
107
|
+
-- TT-2: the same claim as a scalar, with its DENOMINATOR attached. "0 tasks-first" alone
|
|
108
|
+
-- is an absence and would read green against an empty derivation; the member count makes
|
|
109
|
+
-- the assertion say how many functions it actually examined to get there.
|
|
110
|
+
-- ===========================================================================
|
|
111
|
+
select is(
|
|
112
|
+
(select count(*)::text || ' functions acquire both; '
|
|
113
|
+
|| count(*) filter (where tasks_first)::text || ' take tasks before threads'
|
|
114
|
+
from _tt_ord),
|
|
115
|
+
'20 functions acquire both; 0 take tasks before threads',
|
|
116
|
+
'TT-2: 20 functions acquire both relations and NONE takes tasks before threads');
|
|
117
|
+
|
|
118
|
+
-- ===========================================================================
|
|
119
|
+
-- TT-3: append_task_event's acquisitions on the two relations, in path order, with mode
|
|
120
|
+
-- and provenance. The FIRST is the hoisted `threads ... for key share` (via `textual`, i.e.
|
|
121
|
+
-- schedulable), before the tasks FOR UPDATE. The two `fk-insert` edges are the task_events
|
|
122
|
+
-- insert's own FKs -- they are what made the old order invisible to a text scan.
|
|
123
|
+
-- ===========================================================================
|
|
124
|
+
select is(
|
|
125
|
+
(select string_agg(e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' -> '
|
|
126
|
+
order by e.path, e.rel_name, e.lock_mode, e.via)
|
|
127
|
+
from _tt_le e
|
|
128
|
+
where e.fn_name = 'append_task_event'
|
|
129
|
+
and e.rel_name in ('agent_board.tasks', 'agent_board.threads')),
|
|
130
|
+
'agent_board.threads key share via textual'
|
|
131
|
+
|| ' -> agent_board.tasks update via textual'
|
|
132
|
+
|| ' -> agent_board.tasks key share via fk-insert:task_events_task_id_fkey'
|
|
133
|
+
|| ' -> agent_board.threads key share via fk-insert:task_events_thread_id_fkey'
|
|
134
|
+
|| ' -> agent_board.tasks update via write-update',
|
|
135
|
+
'TT-3: append_task_event takes threads(KEY SHARE, explicit) FIRST, then the tasks FOR UPDATE');
|
|
136
|
+
|
|
137
|
+
-- ===========================================================================
|
|
138
|
+
-- TT-4: THE PREMISE THE PRE-READ RESTS ON. append_task_event now reads tasks.thread_id
|
|
139
|
+
-- WITHOUT a lock in order to know which threads row to take first. That is only sound
|
|
140
|
+
-- because no function in the bundle ever re-points tasks.thread_id -- an FK re-point is
|
|
141
|
+
-- reported by the detector as a `fk-update:` edge (FKL-14), so this is catalog-derived and
|
|
142
|
+
-- names the offender rather than trusting a grep. The constraint's existence is asserted in
|
|
143
|
+
-- the SAME string: if tasks_thread_id_fkey were dropped or renamed, "no fk-update edges"
|
|
144
|
+
-- would go on passing while meaning nothing.
|
|
145
|
+
-- ===========================================================================
|
|
146
|
+
select is(
|
|
147
|
+
(select (select coalesce(string_agg(distinct e.fn_name, ', ' order by e.fn_name), '<none>')
|
|
148
|
+
from _tt_le e where e.via = 'fk-update:tasks_thread_id_fkey')
|
|
149
|
+
|| ' / ' ||
|
|
150
|
+
(select coalesce(string_agg(c.conname, ', '), '<missing>')
|
|
151
|
+
from pg_constraint c
|
|
152
|
+
where c.conrelid = 'agent_board.tasks'::regclass
|
|
153
|
+
and c.contype = 'f'
|
|
154
|
+
and c.confrelid = 'agent_board.threads'::regclass)),
|
|
155
|
+
'<none> / tasks_thread_id_fkey',
|
|
156
|
+
'TT-4: no function re-points tasks.thread_id, and the FK that would report one exists');
|
|
157
|
+
|
|
158
|
+
-- ===========================================================================
|
|
159
|
+
-- TT-5: the constraint the tasks FOR UPDATE exists to serialise against. If this were
|
|
160
|
+
-- dropped, append_task_event's sequence computation would stop being a race and start
|
|
161
|
+
-- being a silent duplicate -- and TT-9 would go on passing, because it is sequential.
|
|
162
|
+
-- ===========================================================================
|
|
163
|
+
select is(
|
|
164
|
+
(select c.conname || ' ' || pg_get_constraintdef(c.oid)
|
|
165
|
+
from pg_constraint c
|
|
166
|
+
where c.conrelid = 'agent_board.task_events'::regclass
|
|
167
|
+
and c.contype = 'u'),
|
|
168
|
+
'task_events_task_id_event_sequence_key UNIQUE (task_id, event_sequence)',
|
|
169
|
+
'TT-5: task_events still carries the (task_id, event_sequence) uniqueness the lock guards');
|
|
170
|
+
|
|
171
|
+
-- ===========================================================================
|
|
172
|
+
-- TT-6: THE LOCK THAT WAS NOT DROPPED. The cheapest way to "fix" this cycle is to delete
|
|
173
|
+
-- append_task_event's `for update` -- which trades an ordering disagreement for a live
|
|
174
|
+
-- duplicate-key race and makes every other assertion in this file greener. Pinned
|
|
175
|
+
-- positively: an EXPLICIT (`textual`) lock on tasks, in `update` mode, is still taken.
|
|
176
|
+
-- `write-update` is excluded on purpose -- the function's own later UPDATE would satisfy a
|
|
177
|
+
-- mode-only check and hide the deletion.
|
|
178
|
+
-- ===========================================================================
|
|
179
|
+
select is(
|
|
180
|
+
(select coalesce(string_agg(distinct e.lock_mode, ', ' order by e.lock_mode), '<none>')
|
|
181
|
+
from _tt_le e
|
|
182
|
+
where e.fn_name = 'append_task_event'
|
|
183
|
+
and e.rel_name = 'agent_board.tasks'
|
|
184
|
+
and e.via = 'textual'),
|
|
185
|
+
'update',
|
|
186
|
+
'TT-6: append_task_event still takes an EXPLICIT FOR UPDATE on tasks -- the sequence guard is intact');
|
|
187
|
+
|
|
188
|
+
-- ===========================================================================
|
|
189
|
+
-- TT-7: the second half of the fix, on its own. `_upsert_delivery_recipient` has no
|
|
190
|
+
-- explicit lock at all, so it is invisible to every textual lock-order guard in this repo;
|
|
191
|
+
-- its order is entirely a statement order. The OBSGATE (#78) threads stamp must precede the
|
|
192
|
+
-- delivery_recipients insert whose FK reaches tasks.
|
|
193
|
+
-- ===========================================================================
|
|
194
|
+
select is(
|
|
195
|
+
(select string_agg(e.rel_name || ' ' || e.lock_mode || ' via ' || e.via, ' -> ' order by e.path)
|
|
196
|
+
from _tt_le e
|
|
197
|
+
where e.fn_name = '_upsert_delivery_recipient'
|
|
198
|
+
and e.rel_name in ('agent_board.tasks', 'agent_board.threads')),
|
|
199
|
+
'agent_board.threads update via write-update'
|
|
200
|
+
|| ' -> agent_board.tasks key share via fk-insert:delivery_recipients_source_task_id_fkey',
|
|
201
|
+
'TT-7: _upsert_delivery_recipient stamps threads BEFORE the insert that FK-locks tasks');
|
|
202
|
+
|
|
203
|
+
-- ===========================================================================
|
|
204
|
+
-- BEHAVIOUR. Everything above reads the catalog and would pass against a function that no
|
|
205
|
+
-- longer runs.
|
|
206
|
+
--
|
|
207
|
+
-- EVERY ALLOW-SIDE ASSERTION IS lives_ok() PLUS A SEPARATE STATE CHECK, never
|
|
208
|
+
-- `is((select ... from rpc(...)), ...)`. The bare form ABORTS the transaction on a raise
|
|
209
|
+
-- instead of failing the assertion, and pgTAP then emits NO `not ok` at all -- the file
|
|
210
|
+
-- simply stops, and a summary-line reader calls that a pass (#113).
|
|
211
|
+
--
|
|
212
|
+
-- The fixture is: a board with a member and an observer; a board-linked thread carrying one
|
|
213
|
+
-- task; and a SECOND board plus a board-LESS thread carrying its own task, which TT-12/13
|
|
214
|
+
-- need in order to observe a stamp actually being written rather than a no-op.
|
|
215
|
+
-- ===========================================================================
|
|
216
|
+
create temp table _tt_fixture (
|
|
217
|
+
board uuid, board2 uuid, actor uuid, observer uuid,
|
|
218
|
+
thread uuid, task uuid, free_thread uuid, free_task uuid,
|
|
219
|
+
actor_user uuid, observer_user uuid, membership uuid, membership2 uuid
|
|
220
|
+
) on commit drop;
|
|
221
|
+
|
|
222
|
+
do $$
|
|
223
|
+
declare
|
|
224
|
+
v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
225
|
+
v_actor_user uuid; v_observer_user uuid;
|
|
226
|
+
v_creator uuid := gen_random_uuid();
|
|
227
|
+
v_actor uuid := gen_random_uuid();
|
|
228
|
+
v_observer uuid := gen_random_uuid();
|
|
229
|
+
v_board uuid; v_board2 uuid; v_membership uuid; v_membership2 uuid;
|
|
230
|
+
v_thread uuid := gen_random_uuid(); v_task uuid;
|
|
231
|
+
v_free_thread uuid := gen_random_uuid(); v_free_task uuid;
|
|
232
|
+
begin
|
|
233
|
+
v_actor_user := public.ensure_github_user('tt-actor-' || v_suffix, 'tt-actor-' || v_suffix || '@example.test', 'tt-actor-' || v_suffix, 'TT Actor');
|
|
234
|
+
v_observer_user := public.ensure_github_user('tt-obs-' || v_suffix, 'tt-obs-' || v_suffix || '@example.test', 'tt-obs-' || v_suffix, 'TT Observer');
|
|
235
|
+
|
|
236
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
|
|
237
|
+
values
|
|
238
|
+
(v_creator, 'agent', 'tt-creator-' || v_suffix, 'TT Creator', 'active', null, null),
|
|
239
|
+
(v_actor, 'agent', 'tt-actor-' || v_suffix, 'TT Actor', 'active', v_actor_user, v_actor_user),
|
|
240
|
+
(v_observer, 'agent', 'tt-obs-' || v_suffix, 'TT Observer', 'active', v_observer_user, v_observer_user);
|
|
241
|
+
|
|
242
|
+
select board_id into v_board
|
|
243
|
+
from agent_board.create_board(v_creator, 'tt-board-' || v_suffix, 'TT Board', 'task/thread lock order', '{}'::jsonb, 'private');
|
|
244
|
+
select board_id into v_board2
|
|
245
|
+
from agent_board.create_board(v_creator, 'tt-board2-' || v_suffix, 'TT Board 2', 'second board', '{}'::jsonb, 'private');
|
|
246
|
+
|
|
247
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
248
|
+
values (v_board, v_actor, 'joined', v_creator, 'member')
|
|
249
|
+
returning id into v_membership;
|
|
250
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
251
|
+
values (v_board, v_observer, 'joined', v_creator, 'observer');
|
|
252
|
+
-- delivery_recipients carries a COMPOSITE fk (board_membership_id, board_id,
|
|
253
|
+
-- recipient_agent_id), so the second-board delivery in TT-12 needs the actor to hold a
|
|
254
|
+
-- membership on THAT board too.
|
|
255
|
+
insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
|
|
256
|
+
values (v_board2, v_actor, 'joined', v_creator, 'member')
|
|
257
|
+
returning id into v_membership2;
|
|
258
|
+
|
|
259
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
|
|
260
|
+
values (v_thread, 'coordination', v_actor, 'TT board thread'),
|
|
261
|
+
(v_free_thread, 'coordination', v_actor, 'TT board-less thread');
|
|
262
|
+
|
|
263
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
264
|
+
perform set_config('request.jwt.claim.sub', v_actor_user::text, true);
|
|
265
|
+
-- Links v_thread to v_board through the delivery fan-out, which is the only writer of
|
|
266
|
+
-- threads.board_id -- i.e. through the very statement this change hoisted.
|
|
267
|
+
perform agent_board.post_board_message(v_thread, v_actor, v_board, 'seed', 'seed body');
|
|
268
|
+
select task_id into v_task from agent_board.create_task(v_thread, v_actor, 'tt task', 'd');
|
|
269
|
+
select task_id into v_free_task from agent_board.create_task(v_free_thread, v_actor, 'tt free task', 'd');
|
|
270
|
+
|
|
271
|
+
insert into _tt_fixture values (v_board, v_board2, v_actor, v_observer, v_thread, v_task,
|
|
272
|
+
v_free_thread, v_free_task, v_actor_user, v_observer_user,
|
|
273
|
+
v_membership, v_membership2);
|
|
274
|
+
end
|
|
275
|
+
$$;
|
|
276
|
+
|
|
277
|
+
-- TT-8: the reordered function still runs. lives_ok, not a value comparison -- a raise here
|
|
278
|
+
-- must fail THIS assertion, not abort the file.
|
|
279
|
+
select lives_ok(
|
|
280
|
+
$$ select * from agent_board.append_task_event(
|
|
281
|
+
(select task from _tt_fixture), (select actor from _tt_fixture), 'commented') $$,
|
|
282
|
+
'TT-8: append_task_event still runs after the reorder (lives_ok, so a raise fails here)');
|
|
283
|
+
|
|
284
|
+
-- TT-9: the state TT-8 wrote. `create_task` inserts task_events seq 1 directly, so the
|
|
285
|
+
-- append is seq 2, and its thread_id is the task's own thread -- the value the unlocked
|
|
286
|
+
-- pre-read supplied and the FOR UPDATE re-read confirmed. Sequential, so this does NOT
|
|
287
|
+
-- prove the concurrency guard; that is the isolation spec's permutation 3.
|
|
288
|
+
select is(
|
|
289
|
+
(select string_agg(te.event_sequence::text || ':' || te.event_type
|
|
290
|
+
|| ':' || (te.thread_id = f.thread)::text, ' ' order by te.event_sequence)
|
|
291
|
+
from agent_board.task_events te, _tt_fixture f
|
|
292
|
+
where te.task_id = f.task),
|
|
293
|
+
'1:created:true 2:commented:true',
|
|
294
|
+
'TT-9: the append landed as seq 2 carrying the task''s own thread_id');
|
|
295
|
+
|
|
296
|
+
-- TT-10: the P0002 the pre-read raises for an unknown task, BEFORE any lock is taken.
|
|
297
|
+
select throws_ok(
|
|
298
|
+
$$ select * from agent_board.append_task_event(
|
|
299
|
+
'00000000-0000-4000-8000-0000000000ff'::uuid,
|
|
300
|
+
(select actor from _tt_fixture), 'commented') $$,
|
|
301
|
+
'P0002',
|
|
302
|
+
'task not found',
|
|
303
|
+
'TT-10: an unknown task still raises P0002 from the pre-read, before any row lock');
|
|
304
|
+
|
|
305
|
+
-- TT-11: the OBSGATE-3 (#125) gate still denies a board observer that is not a party to the
|
|
306
|
+
-- task. The reorder moved two statements ABOVE this gate; if it had moved the gate itself,
|
|
307
|
+
-- this is where that shows.
|
|
308
|
+
select throws_ok(
|
|
309
|
+
$$ select set_config('request.jwt.claim.sub', (select observer_user from _tt_fixture)::text, true);
|
|
310
|
+
select * from agent_board.append_task_event(
|
|
311
|
+
(select task from _tt_fixture), (select observer from _tt_fixture), 'commented') $$,
|
|
312
|
+
'42501',
|
|
313
|
+
'task mutation requires a non-observer board role, or task assignment/creation',
|
|
314
|
+
'TT-11: a board observer that is not a party to the task is still denied');
|
|
315
|
+
|
|
316
|
+
-- TT-12: the hoisted OBSGATE (#78) stamp still runs. service_role -- the helper is
|
|
317
|
+
-- service_role-only by grant. Two calls: one that must STAMP (board-less thread), one that
|
|
318
|
+
-- must NOT overwrite (same thread, different board).
|
|
319
|
+
select lives_ok(
|
|
320
|
+
$$ select set_config('request.jwt.claim.role', 'service_role', true);
|
|
321
|
+
select agent_board._upsert_delivery_recipient('tasking', f.actor, 'task', null,
|
|
322
|
+
f.free_task, null, f.board, f.membership) from _tt_fixture f;
|
|
323
|
+
select agent_board._upsert_delivery_recipient('tasking', f.actor, 'task', null,
|
|
324
|
+
f.free_task, null, f.board2, f.membership2) from _tt_fixture f $$,
|
|
325
|
+
'TT-12: the hoisted stamp runs for both a first and a second board delivery');
|
|
326
|
+
|
|
327
|
+
-- TT-13: the state TT-12 wrote -- first-board-wins survived the hoist. `false` on the
|
|
328
|
+
-- second element would mean the reorder turned the stamp into an overwrite.
|
|
329
|
+
select is(
|
|
330
|
+
(select (t.board_id = f.board)::text || '/' || (t.board_id = f.board2)::text
|
|
331
|
+
from agent_board.threads t, _tt_fixture f where t.id = f.free_thread),
|
|
332
|
+
'true/false',
|
|
333
|
+
'TT-13: the board-less thread was stamped with the FIRST board and not overwritten by the second');
|
|
334
|
+
|
|
335
|
+
select * from finish();
|
|
336
|
+
|
|
337
|
+
rollback;
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"signature": "_addressing_descriptors_array(p_descriptors jsonb)",
|
|
18
18
|
"declares_p_metadata": false
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"signature": "_agent_can_access_thread(p_thread_id uuid, p_agent_id uuid)",
|
|
22
|
+
"declares_p_metadata": false
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"signature": "_agent_has_board_access(p_board_id uuid, p_agent_id uuid)",
|
|
26
|
+
"declares_p_metadata": false
|
|
27
|
+
},
|
|
20
28
|
{
|
|
21
29
|
"signature": "_agent_principal_active(p_agent_id uuid)",
|
|
22
30
|
"declares_p_metadata": false
|
|
@@ -133,6 +141,10 @@
|
|
|
133
141
|
"signature": "_ensure_agent_auth_user(p_slug text)",
|
|
134
142
|
"declares_p_metadata": false
|
|
135
143
|
},
|
|
144
|
+
{
|
|
145
|
+
"signature": "_federated_identity_slug(p_issuer text, p_subject text)",
|
|
146
|
+
"declares_p_metadata": false
|
|
147
|
+
},
|
|
136
148
|
{
|
|
137
149
|
"signature": "_fedsession_autojoin_default_board(p_provider agent_board.identity_providers, p_agent_id uuid)",
|
|
138
150
|
"declares_p_metadata": false
|