@consiliency/agent-board-schema 1.28.0 → 1.38.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 +40 -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/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +83 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- 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_invite_no_downgrade.test.sql +689 -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_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +256 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +442 -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
|
@@ -29,7 +29,7 @@ set search_path = extensions, public, agent_board;
|
|
|
29
29
|
|
|
30
30
|
begin;
|
|
31
31
|
|
|
32
|
-
select plan(
|
|
32
|
+
select plan(57);
|
|
33
33
|
|
|
34
34
|
-- ---------------------------------------------------------------------------
|
|
35
35
|
-- The four demonstrated masking constructs.
|
|
@@ -205,6 +205,261 @@ 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
|
+
|
|
364
|
+
-- ===========================================================================
|
|
365
|
+
-- AMBIGUOUS SPLICE (message-board#304)
|
|
366
|
+
--
|
|
367
|
+
-- stripped_def locates the body inside pg_get_functiondef by FIRST textual
|
|
368
|
+
-- occurrence. The generated definition carries author-controlled text before the
|
|
369
|
+
-- body — the function name, every argument name, the SET clauses — so a body
|
|
370
|
+
-- that also occurs there sends the splice into the HEADER: the header is
|
|
371
|
+
-- corrupted and the REAL body comes back unstripped, which satisfies every
|
|
372
|
+
-- negative assertion and defeats every positive one over the result.
|
|
373
|
+
--
|
|
374
|
+
-- The trigger is built the way a real one arrives: a quoted ARGUMENT NAME that
|
|
375
|
+
-- happens to spell the body. That is the same surface a mint_harden assertion
|
|
376
|
+
-- was already defeated through (an argument named p_raw_key_fingerprint, the
|
|
377
|
+
-- #196 lint's rule 3), so these fixtures are a shrunk copy of a defect that has
|
|
378
|
+
-- happened, not an invented one.
|
|
379
|
+
--
|
|
380
|
+
-- Both arms are covered. The BEGIN ATOMIC arm added in #303 is the MORE exposed
|
|
381
|
+
-- one: a deparsed body is short and normalized, so coincidental collision is
|
|
382
|
+
-- likelier there than in a hand-written prosrc body.
|
|
383
|
+
--
|
|
384
|
+
-- The fixtures live in pg_temp, NOT agent_board: stripped_def now RAISES on
|
|
385
|
+
-- them, and the catalog-derived corpus scans below sweep agent_board.
|
|
386
|
+
-- ===========================================================================
|
|
387
|
+
create function pg_temp._mb304_prosrc_collision("select 'mb304-collision-marker'" int)
|
|
388
|
+
returns text language sql as $mb$select 'mb304-collision-marker'$mb$;
|
|
389
|
+
|
|
390
|
+
create function pg_temp._mb304_atomic_collision(p int, "RETURN 'mb304-collision-marker'::text" int)
|
|
391
|
+
returns text language sql return 'mb304-collision-marker';
|
|
392
|
+
|
|
393
|
+
-- The non-colliding twins. Same bodies, ordinary argument names. They prove the
|
|
394
|
+
-- raises below are caused by the AMBIGUITY and not by the fixture shape, and
|
|
395
|
+
-- that the fix did not turn stripped_def into a function that refuses everything.
|
|
396
|
+
create function pg_temp._mb304_prosrc_control(p int)
|
|
397
|
+
returns text language sql as $mb$select 'mb304-collision-marker'$mb$;
|
|
398
|
+
|
|
399
|
+
create function pg_temp._mb304_atomic_control(p int)
|
|
400
|
+
returns text language sql return 'mb304-collision-marker';
|
|
401
|
+
|
|
402
|
+
-- PREMISE. Everything below is worthless if the fixtures are not actually
|
|
403
|
+
-- ambiguous, so the ambiguity is MEASURED with the same occurrence count the
|
|
404
|
+
-- fixed helper uses — not assumed from how the argument was named.
|
|
405
|
+
select is(
|
|
406
|
+
(select ((length(pg_get_functiondef(p.oid)) - length(replace(pg_get_functiondef(p.oid), p.prosrc, '')))
|
|
407
|
+
/ length(p.prosrc))::int
|
|
408
|
+
from pg_proc p where p.oid = 'pg_temp._mb304_prosrc_collision(int)'::regprocedure),
|
|
409
|
+
2,
|
|
410
|
+
'MB304 PREMISE-A: the prosrc fixture really does have its body occurring TWICE in its own generated definition');
|
|
411
|
+
|
|
412
|
+
select is(
|
|
413
|
+
(select ((length(pg_get_functiondef(p.oid))
|
|
414
|
+
- length(replace(pg_get_functiondef(p.oid), pg_get_function_sqlbody(p.oid), '')))
|
|
415
|
+
/ length(pg_get_function_sqlbody(p.oid)))::int
|
|
416
|
+
from pg_proc p where p.oid = 'pg_temp._mb304_atomic_collision(int,int)'::regprocedure),
|
|
417
|
+
2,
|
|
418
|
+
'MB304 PREMISE-B: the BEGIN ATOMIC fixture really does have its DEPARSED body occurring TWICE in its own generated definition');
|
|
419
|
+
|
|
420
|
+
select throws_ok(
|
|
421
|
+
$$ select agent_board_test.stripped_def('pg_temp._mb304_prosrc_collision(int)'::regprocedure) $$,
|
|
422
|
+
null, null,
|
|
423
|
+
'MB304-1: an ambiguous PROSRC body RAISES rather than splicing into the header and returning the body unstripped');
|
|
424
|
+
|
|
425
|
+
select throws_ok(
|
|
426
|
+
$$ select agent_board_test.stripped_def('pg_temp._mb304_atomic_collision(int,int)'::regprocedure) $$,
|
|
427
|
+
null, null,
|
|
428
|
+
'MB304-2: the BEGIN ATOMIC arm added in #303 refuses the same way — the deparsed body is where a coincidental collision is MOST likely');
|
|
429
|
+
|
|
430
|
+
-- The controls. Unambiguous bodies must still splice, so the refusals above are
|
|
431
|
+
-- about AMBIGUITY and stripped_def has not become uniformly loud. lives_ok first
|
|
432
|
+
-- and the state check separately: a bare `ok(stripped_def(...) !~ …)` on a
|
|
433
|
+
-- raising call ABORTS the transaction, which pgTAP reports as no `not ok` at all
|
|
434
|
+
-- and silently drops every assertion after it.
|
|
435
|
+
select lives_ok(
|
|
436
|
+
$$ select agent_board_test.stripped_def('pg_temp._mb304_prosrc_control(int)'::regprocedure) $$,
|
|
437
|
+
'MB304-3: CONTROL — the same prosrc body with an ordinary argument name is UNambiguous and still splices');
|
|
438
|
+
|
|
439
|
+
select ok(
|
|
440
|
+
agent_board_test.stripped_def('pg_temp._mb304_prosrc_control(int)'::regprocedure)
|
|
441
|
+
!~ 'mb304-collision-marker',
|
|
442
|
+
'MB304-4: ...and that spliced result really is stripped');
|
|
443
|
+
|
|
444
|
+
select ok(
|
|
445
|
+
agent_board_test.stripped_def('pg_temp._mb304_prosrc_control(int)'::regprocedure, true)
|
|
446
|
+
~ 'mb304-collision-marker',
|
|
447
|
+
'MB304-5: ...and keep-literals mode keeps the marker, so MB304-4 is not passing because the definition collapsed');
|
|
448
|
+
|
|
449
|
+
select lives_ok(
|
|
450
|
+
$$ select agent_board_test.stripped_def('pg_temp._mb304_atomic_control(int)'::regprocedure) $$,
|
|
451
|
+
'MB304-6: CONTROL — the same deparsed body with an ordinary argument name is UNambiguous and still splices');
|
|
452
|
+
|
|
453
|
+
select ok(
|
|
454
|
+
agent_board_test.stripped_def('pg_temp._mb304_atomic_control(int)'::regprocedure)
|
|
455
|
+
!~ 'mb304-collision-marker',
|
|
456
|
+
'MB304-7: ...and that spliced result really is stripped');
|
|
457
|
+
|
|
458
|
+
select ok(
|
|
459
|
+
agent_board_test.stripped_def('pg_temp._mb304_atomic_control(int)'::regprocedure, true)
|
|
460
|
+
~ 'mb304-collision-marker',
|
|
461
|
+
'MB304-8: ...and keep-literals mode keeps the marker, so MB304-7 is not passing because the definition collapsed');
|
|
462
|
+
|
|
208
463
|
-- ---------------------------------------------------------------------------
|
|
209
464
|
-- CORPUS (catalog-derived). The vectors above are written by hand and can only
|
|
210
465
|
-- 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;
|