@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.
Files changed (32) hide show
  1. package/fixtures/already-applied.json +28 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/package.json +1 -1
  10. package/schema-manifest.json +66 -3
  11. package/src/tooling.test.mjs +47 -2
  12. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  13. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  14. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  15. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  16. package/tests/agent_board_fedsession.test.sql +17 -2
  17. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  18. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  19. package/tests/agent_board_ledger_authority.test.sql +142 -71
  20. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  21. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  22. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  23. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  24. package/tests/catalog/p_metadata_parameters.json +12 -0
  25. package/tests/fixtures/test-prereqs.sql +420 -11
  26. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  27. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  28. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  29. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  30. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  31. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  32. 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 (catalog-derived, CLAUDE.md rule 1): the agents/board_memberships cycle 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 that those are the ONLY
653
- -- two functions that lock both — and that claim must come from pg_catalog, not from this
654
- -- file. Any function that row-locks both relations can close the same cycle, so every member
655
- -- must take the board advisory lock that serialises them.
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 BOTH agents and board_memberships takes the board advisory lock (transitive; a new one names itself)');
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(31);
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