@consiliency/agent-board-schema 1.27.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 +32 -0
- package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -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 +75 -3
- package/src/tooling.test.mjs +47 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
- 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 +1081 -0
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signer_lifecycle.test.sql +29 -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 +20 -0
- package/tests/fixtures/test-prereqs.sql +420 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
- package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -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 +405 -0
- package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -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
|
@@ -359,18 +359,74 @@ COMMENT ON FUNCTION agent_board_test.strip_sql(text, boolean) IS
|
|
|
359
359
|
|
|
360
360
|
-- The stripped BODY of a function. This is what a positional or negative
|
|
361
361
|
-- assertion should read.
|
|
362
|
+
--
|
|
363
|
+
-- TWO BODY COLUMNS, NOT ONE (message-board#293). A function written with an
|
|
364
|
+
-- SQL-standard body -- `LANGUAGE sql BEGIN ATOMIC … END`, or the single-expression
|
|
365
|
+
-- `LANGUAGE sql RETURN …` form -- is PARSED at definition time and stored in
|
|
366
|
+
-- pg_proc.prosqlbody; its prosrc is the EMPTY STRING (measured on PG 17.6, not
|
|
367
|
+
-- inferred: `select length(prosrc), prosqlbody is null` over such a function
|
|
368
|
+
-- returns `0, false`). Reading prosrc alone therefore handed back `''` for it, and
|
|
369
|
+
-- `''` satisfies every `!~` in this suite and defeats every `~` -- so a writer
|
|
370
|
+
-- spelled that way was invisible to ~22 test files' class assertions at once,
|
|
371
|
+
-- INCLUDING the catalog-derived ones whose whole job is to name a new member of a
|
|
372
|
+
-- class. That is the fail-OPEN direction, and it is the exact shape CLAUDE.md
|
|
373
|
+
-- rule 1 bans.
|
|
374
|
+
--
|
|
375
|
+
-- WHY IT RAISES INSTEAD OF FALLING BACK TO ''. message-board#293 suggested
|
|
376
|
+
-- `coalesce(nullif(prosrc,''), pg_get_function_sqlbody(oid), '')`. The third arm
|
|
377
|
+
-- REBUILDS the defect one level down: an unreadable body would again come back as
|
|
378
|
+
-- `''` and again satisfy every negative assertion. NULL is no better -- an `is()`
|
|
379
|
+
-- would surface it, but the dominant consumer shape in this suite is
|
|
380
|
+
-- `where … stripped_body(p.oid) ~ '<pattern>'`, and a NULL there silently DROPS
|
|
381
|
+
-- the row from the scan, which is the same fail-open wearing a different hat.
|
|
382
|
+
-- There is no value this function can return for a body it cannot read that a
|
|
383
|
+
-- class scan reads correctly, so it raises. The population is empty today by
|
|
384
|
+
-- construction (`select count(*) from pg_proc where prosrc = '' and prosqlbody is
|
|
385
|
+
-- null` is 0 across the WHOLE catalog on this cluster, internal/C functions
|
|
386
|
+
-- included -- their prosrc is the symbol name), so the raise is a tripwire, not a
|
|
387
|
+
-- hazard. A missing pg_proc row raises for the same reason: the old form returned
|
|
388
|
+
-- NULL from a zero-row SELECT.
|
|
389
|
+
--
|
|
390
|
+
-- WHY `SET search_path = pg_catalog` IS LOAD-BEARING HERE, not decoration.
|
|
391
|
+
-- pg_get_function_sqlbody DEPARSES the stored parse tree, and the deparser omits
|
|
392
|
+
-- the schema for any relation that is visible on the CURRENT search_path. Measured:
|
|
393
|
+
-- the same function deparses as `INSERT INTO agent_board.x` under
|
|
394
|
+
-- `search_path = pg_catalog` and as `INSERT INTO x` under a search_path containing
|
|
395
|
+
-- agent_board -- and every consumer regex in this suite spells `agent_board\.`.
|
|
396
|
+
-- Without this SET the fix would have been a NEW fail-open. Pinned positively by
|
|
397
|
+
-- the QUAL assertion in agent_board_source_strip_helper.test.sql, whose fixture
|
|
398
|
+
-- runs with agent_board ON the session search_path so the trap is live.
|
|
362
399
|
CREATE OR REPLACE FUNCTION agent_board_test.stripped_body(
|
|
363
400
|
p_fn oid,
|
|
364
401
|
p_keep_literals boolean DEFAULT false
|
|
365
402
|
) RETURNS text
|
|
366
|
-
LANGUAGE
|
|
403
|
+
LANGUAGE plpgsql
|
|
367
404
|
STABLE
|
|
368
405
|
SET search_path = pg_catalog
|
|
369
|
-
AS
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
406
|
+
AS $strippedbody$
|
|
407
|
+
declare
|
|
408
|
+
v_src text;
|
|
409
|
+
begin
|
|
410
|
+
-- prosrc for a SQL-standard body is '' (not NULL), so nullif() -- not
|
|
411
|
+
-- coalesce() alone -- is what selects the prosqlbody arm.
|
|
412
|
+
select coalesce(nullif(p.prosrc, ''), pg_catalog.pg_get_function_sqlbody(p.oid))
|
|
413
|
+
into v_src
|
|
414
|
+
from pg_proc p
|
|
415
|
+
where p.oid = p_fn;
|
|
416
|
+
|
|
417
|
+
if not found then
|
|
418
|
+
raise exception
|
|
419
|
+
'agent_board_test.stripped_body: no pg_proc row for oid % -- refusing to return NULL, which a `where … ~ …` class scan drops SILENTLY', p_fn;
|
|
420
|
+
end if;
|
|
421
|
+
|
|
422
|
+
if v_src is null or v_src = '' then
|
|
423
|
+
raise exception
|
|
424
|
+
'agent_board_test.stripped_body: oid % has neither a prosrc body nor a prosqlbody body -- refusing to return an unreadable body that would satisfy every negative assertion', p_fn;
|
|
425
|
+
end if;
|
|
426
|
+
|
|
427
|
+
return agent_board_test.strip_sql(v_src, p_keep_literals);
|
|
428
|
+
end;
|
|
429
|
+
$strippedbody$;
|
|
374
430
|
|
|
375
431
|
-- The stripped FULL DEFINITION. The header (`SECURITY DEFINER`, `SET
|
|
376
432
|
-- search_path = …`, the argument list) is regenerated by Postgres from catalog
|
|
@@ -381,6 +437,27 @@ $$;
|
|
|
381
437
|
-- the body is wrapped in a dollar quote (`AS $function$ … $function$`), so a
|
|
382
438
|
-- dollar-quote-aware strip would delete THE ENTIRE BODY and every `!~` over the
|
|
383
439
|
-- result would pass. Splicing avoids that trap by construction.
|
|
440
|
+
--
|
|
441
|
+
-- IN THE SAME CLASS AS stripped_body (message-board#293), and fixed here for that
|
|
442
|
+
-- reason rather than left as "the other one". For a `BEGIN ATOMIC` function prosrc
|
|
443
|
+
-- is '', so the old `if v_src is null or v_src = '' then return v_def` arm handed
|
|
444
|
+
-- back the RAW generated definition with its string literals INTACT -- and
|
|
445
|
+
-- pg_get_functiondef renders such a body INLINE (`… LANGUAGE sql BEGIN ATOMIC …
|
|
446
|
+
-- END`, measured), so the literals really are in the returned text. A positive
|
|
447
|
+
-- claim in default mode could then be satisfied by a literal instead of by code.
|
|
448
|
+
-- Narrower than the stripped_body hole, same direction, so it splices the
|
|
449
|
+
-- DEPARSED body instead; the substring is verbatim inside the definition because
|
|
450
|
+
-- both come from the same deparse under the same pinned search_path (measured:
|
|
451
|
+
-- `position(pg_get_function_sqlbody(o) in pg_get_functiondef(o))` is non-zero for
|
|
452
|
+
-- both the BEGIN ATOMIC and the `RETURN <expr>` form, and the pre-existing
|
|
453
|
+
-- `v_at = 0` arm still refuses to guess if that ever stops holding).
|
|
454
|
+
--
|
|
455
|
+
-- The `return null` arm for a missing oid is gone for the same reason
|
|
456
|
+
-- stripped_body raises: a NULL is dropped silently by a `where … !~ …` scan.
|
|
457
|
+
-- This is a deliberate contract change, stated rather than slipped in; the only
|
|
458
|
+
-- live callers are in agent_board_source_strip_helper.test.sql (the #196 lint
|
|
459
|
+
-- forbids stripped_def in any other test file's assertions without an explicit
|
|
460
|
+
-- `-- header-claim`, and that set is empty).
|
|
384
461
|
CREATE OR REPLACE FUNCTION agent_board_test.stripped_def(
|
|
385
462
|
p_fn oid,
|
|
386
463
|
p_keep_literals boolean DEFAULT false
|
|
@@ -394,18 +471,22 @@ declare
|
|
|
394
471
|
v_src text;
|
|
395
472
|
v_at int;
|
|
396
473
|
begin
|
|
397
|
-
select pg_get_functiondef(p.oid),
|
|
474
|
+
select pg_get_functiondef(p.oid),
|
|
475
|
+
coalesce(nullif(p.prosrc, ''), pg_catalog.pg_get_function_sqlbody(p.oid))
|
|
476
|
+
into v_def, v_src
|
|
398
477
|
from pg_proc p where p.oid = p_fn;
|
|
399
|
-
if v_def is null then
|
|
400
|
-
|
|
478
|
+
if not found or v_def is null then
|
|
479
|
+
raise exception
|
|
480
|
+
'agent_board_test.stripped_def: no readable definition for oid % -- refusing to return NULL, which a `where … !~ …` class scan drops SILENTLY', p_fn;
|
|
401
481
|
end if;
|
|
402
482
|
if v_src is null or v_src = '' then
|
|
403
|
-
|
|
483
|
+
raise exception
|
|
484
|
+
'agent_board_test.stripped_def: oid % has neither a prosrc body nor a prosqlbody body -- refusing to return an unstripped definition', p_fn;
|
|
404
485
|
end if;
|
|
405
486
|
v_at := position(v_src in v_def);
|
|
406
487
|
if v_at = 0 then
|
|
407
488
|
raise exception
|
|
408
|
-
'agent_board_test.stripped_def:
|
|
489
|
+
'agent_board_test.stripped_def: body not found verbatim inside pg_get_functiondef for oid % -- refusing to guess', p_fn;
|
|
409
490
|
end if;
|
|
410
491
|
return substr(v_def, 1, v_at - 1)
|
|
411
492
|
|| agent_board_test.strip_sql(v_src, p_keep_literals)
|
|
@@ -427,4 +508,332 @@ AS $$
|
|
|
427
508
|
SELECT agent_board_test.strip_sql(pg_get_viewdef(p_rel), p_keep_literals);
|
|
428
509
|
$$;
|
|
429
510
|
|
|
511
|
+
-- ===========================================================================
|
|
512
|
+
-- FK-AWARE LOCK-EDGE DETECTOR (message-board#281)
|
|
513
|
+
--
|
|
514
|
+
-- WHY THIS EXISTS. Every lock-order guard in this repo up to now found locks by
|
|
515
|
+
-- scanning a function body for the literal text `for update` / `for share`. A
|
|
516
|
+
-- lock that PostgreSQL takes on our behalf through a FOREIGN KEY never appears
|
|
517
|
+
-- in that text, so those guards are structurally blind to it -- and the blind
|
|
518
|
+
-- spot already contains an ABBA. `_upsert_board_membership_state` takes
|
|
519
|
+
-- `board_memberships FOR UPDATE` and then, via `_emit_membership_event`'s INSERT
|
|
520
|
+
-- into `board_membership_events`, `agents FOR KEY SHARE` through
|
|
521
|
+
-- `board_membership_events_agent_id_fkey`. It never NAMES `agents`.
|
|
522
|
+
-- `onboard_federated_fleet_agent` takes those two relations in the opposite
|
|
523
|
+
-- order with `FOR UPDATE`, which KEY SHARE conflicts with.
|
|
524
|
+
--
|
|
525
|
+
-- WHAT IS CATALOG-DERIVED, AND WHAT IS NOT. Being exact about this is the point;
|
|
526
|
+
-- a detector that silently under-reports is worse than none, because it will be
|
|
527
|
+
-- trusted.
|
|
528
|
+
-- CATALOG (pg_constraint / pg_attribute / pg_class, resolved by OID, never by
|
|
529
|
+
-- a rendered name -- `regclass::text` drops the schema for anything on
|
|
530
|
+
-- search_path): which tables have foreign keys, to what, on which columns,
|
|
531
|
+
-- with what ON DELETE action. So a NEW foreign key is covered the day it is
|
|
532
|
+
-- added, with nothing here to update.
|
|
533
|
+
-- REGEX over agent_board_test.stripped_body(): which tables a function WRITES,
|
|
534
|
+
-- which explicit row locks it takes, and which functions it calls. plpgsql
|
|
535
|
+
-- bodies carry no catalog-level table dependencies, so nothing stronger exists
|
|
536
|
+
-- without a parser. Reading the STRIPPED body (not prosrc) is what stops a
|
|
537
|
+
-- comment or a string literal from manufacturing an edge.
|
|
538
|
+
--
|
|
539
|
+
-- THE SIX ACQUISITION SHAPES. Each has a named mutation pin in
|
|
540
|
+
-- agent_board_fk_lock_edges.test.sql; a shape with no pin is not in this list.
|
|
541
|
+
-- (i) `textual` -- an explicit `for update|no key update|share|key share`
|
|
542
|
+
-- in any statement shape, mode captured (NOT anchored on
|
|
543
|
+
-- `where`: `from boards b join ... for update of b` is a
|
|
544
|
+
-- real acquisition);
|
|
545
|
+
-- (ii) `write-update` -- `update T ... set` row-locks T itself. Recorded as
|
|
546
|
+
-- `update`; PostgreSQL takes the weaker FOR NO KEY UPDATE
|
|
547
|
+
-- when no key column changes, so this is fail-CLOSED;
|
|
548
|
+
-- (iii) `write-delete` -- `delete from T` row-locks the T rows it removes;
|
|
549
|
+
-- (iv) `fk-insert` -- an INSERT into T takes FOR KEY SHARE on the referenced
|
|
550
|
+
-- row of EVERY foreign key of T. This is the shape that
|
|
551
|
+
-- no body scan can see, and the reason for this file;
|
|
552
|
+
-- (v) `fk-update` -- an `update T ... set <col> = ...` where <col> is in some
|
|
553
|
+
-- FK's conkey re-checks that key and locks the NEW parent.
|
|
554
|
+
-- Gated on the SET naming a conkey column derived from
|
|
555
|
+
-- pg_attribute, NOT on "T has FKs" -- crediting every
|
|
556
|
+
-- UPDATE with every parent lock fabricates cycle members;
|
|
557
|
+
-- (vi) `fk-delete` -- deleting a REFERENCED row locks the referencing child
|
|
558
|
+
-- rows; the mode follows confdeltype (cascade => the child
|
|
559
|
+
-- row is deleted, set null/default => it is updated,
|
|
560
|
+
-- no action/restrict => FOR KEY SHARE).
|
|
561
|
+
--
|
|
562
|
+
-- CALL GRAPH. Closed over transitively, because every lock a wrapper takes is
|
|
563
|
+
-- usually in a callee. Each edge carries a POSITION PATH (`path int[]`): the
|
|
564
|
+
-- caller's call-site offset prepended to the callee's own offset, so ordering
|
|
565
|
+
-- survives nesting. Comparing bare offsets across frames is wrong -- two
|
|
566
|
+
-- acquisitions reached through the SAME call site would tie, and the ordering that
|
|
567
|
+
-- decides the cycle lives one frame down. Recursion in the (name-resolved,
|
|
568
|
+
-- over-approximated) call graph is cut by the SQL:2023 CYCLE clause.
|
|
569
|
+
--
|
|
570
|
+
-- WHAT IT DOES NOT COVER, stated so a green run is read for what it is:
|
|
571
|
+
-- * DYNAMIC SQL. An EXECUTE of a computed string is unreadable here. Pinned
|
|
572
|
+
-- fail-closed: FKL-11 fails the suite the day any function with a lock edge
|
|
573
|
+
-- acquires one.
|
|
574
|
+
-- * LOCKS TAKEN BY TRIGGERS on a written table. Not folded in. Pinned instead
|
|
575
|
+
-- by FKL-12, which asserts that no trigger function on an agent_board table
|
|
576
|
+
-- has ANY lock edge -- true today, and loud the day it stops being.
|
|
577
|
+
-- * INSERT/INSERT contention on a UNIQUE index. A second inserter of the same
|
|
578
|
+
-- key blocks on the first and can be a deadlock participant, but it is not a
|
|
579
|
+
-- row lock and has no mode.
|
|
580
|
+
-- * an UPDATE of a PARENT's referenced key columns, which fires the same child
|
|
581
|
+
-- RI checks as (vi). Our referenced keys are immutable uuid surrogates.
|
|
582
|
+
-- * NULL FK columns. A null takes no parent lock; every fk-* edge is credited
|
|
583
|
+
-- unconditionally, which over-reports rather than under-reports.
|
|
584
|
+
-- * RUNTIME vs POSITIONAL order. Positions are read over statements AS WRITTEN;
|
|
585
|
+
-- two acquisitions in mutually exclusive IF branches are treated as sequential.
|
|
586
|
+
-- * OVERLOAD RESOLUTION. Call edges are name-resolved, so every overload sharing
|
|
587
|
+
-- a proname is one callee -- over-connecting, which is safe for membership and
|
|
588
|
+
-- fail-open for a positional claim.
|
|
589
|
+
-- * `BEGIN ATOMIC` bodies. These are now READ (message-board#293:
|
|
590
|
+
-- stripped_body falls back to the deparsed prosqlbody), but what is read is a
|
|
591
|
+
-- DEPARSED tree, and the positional/`path` semantics this detector builds on
|
|
592
|
+
-- are only exercised against author-written text. So the FKL-6 tripwire --
|
|
593
|
+
-- `no agent_board function has a prosqlbody` -- is deliberately KEPT: it is no
|
|
594
|
+
-- longer "unreadable", it is "readable but unexercised". (An earlier version of
|
|
595
|
+
-- this line cited FKL-10, which is the ABBA-pair assertion; the tripwire is
|
|
596
|
+
-- FKL-6.)
|
|
597
|
+
-- ===========================================================================
|
|
598
|
+
|
|
599
|
+
-- PostgreSQL row-level lock conflict matrix (Concurrency Control -> Row-Level
|
|
600
|
+
-- Locks). The cycle enumeration is USELESS without it: over a schema this
|
|
601
|
+
-- FK-dense, a mode-blind ABBA search reports every pair of functions that insert
|
|
602
|
+
-- into two tables in opposite order, and two FOR KEY SHARE locks never conflict.
|
|
603
|
+
CREATE OR REPLACE FUNCTION agent_board_test.row_locks_conflict(p_a text, p_b text)
|
|
604
|
+
RETURNS boolean
|
|
605
|
+
LANGUAGE sql IMMUTABLE
|
|
606
|
+
SET search_path = pg_catalog
|
|
607
|
+
AS $$
|
|
608
|
+
-- KEY SHARE SHARE NO KEY UPDATE UPDATE
|
|
609
|
+
-- KEY SHARE . . . X
|
|
610
|
+
-- SHARE . . X X
|
|
611
|
+
-- NO KEY UPD . X X X
|
|
612
|
+
-- UPDATE X X X X
|
|
613
|
+
SELECT CASE
|
|
614
|
+
WHEN p_a IS NULL OR p_b IS NULL THEN true -- unknown: fail closed
|
|
615
|
+
WHEN p_a = 'update' OR p_b = 'update' THEN true
|
|
616
|
+
WHEN p_a = 'no key update' THEN p_b IN ('no key update', 'share')
|
|
617
|
+
WHEN p_a = 'share' THEN p_b = 'no key update'
|
|
618
|
+
WHEN p_a = 'key share' THEN false
|
|
619
|
+
ELSE true -- unrecognised: fail closed
|
|
620
|
+
END;
|
|
621
|
+
$$;
|
|
622
|
+
|
|
623
|
+
-- Strength order, used to pick the strongest mode a function takes on a relation.
|
|
624
|
+
-- An unrecognised mode ranks HIGHEST so it cannot be discarded silently.
|
|
625
|
+
CREATE OR REPLACE FUNCTION agent_board_test.row_lock_rank(p_mode text)
|
|
626
|
+
RETURNS int
|
|
627
|
+
LANGUAGE sql IMMUTABLE
|
|
628
|
+
SET search_path = pg_catalog
|
|
629
|
+
AS $$
|
|
630
|
+
SELECT CASE p_mode
|
|
631
|
+
WHEN 'key share' THEN 1
|
|
632
|
+
WHEN 'share' THEN 2
|
|
633
|
+
WHEN 'no key update' THEN 3
|
|
634
|
+
WHEN 'update' THEN 4
|
|
635
|
+
ELSE 5
|
|
636
|
+
END;
|
|
637
|
+
$$;
|
|
638
|
+
|
|
639
|
+
-- One row per (function, relation, acquisition). `own` distinguishes an
|
|
640
|
+
-- acquisition written in the function's own body from one reached through a
|
|
641
|
+
-- callee; `origin_name` names the function the acquisition actually lives in.
|
|
642
|
+
CREATE OR REPLACE FUNCTION agent_board_test.lock_edges()
|
|
643
|
+
RETURNS TABLE (
|
|
644
|
+
fn_oid oid,
|
|
645
|
+
fn_name text,
|
|
646
|
+
fn_sig text,
|
|
647
|
+
rel_oid oid,
|
|
648
|
+
rel_name text,
|
|
649
|
+
lock_mode text,
|
|
650
|
+
via text,
|
|
651
|
+
path int[],
|
|
652
|
+
own boolean,
|
|
653
|
+
origin_oid oid,
|
|
654
|
+
origin_name text
|
|
655
|
+
)
|
|
656
|
+
LANGUAGE sql STABLE
|
|
657
|
+
SET search_path = pg_catalog
|
|
658
|
+
AS $lockedges$
|
|
659
|
+
WITH RECURSIVE
|
|
660
|
+
fns AS (
|
|
661
|
+
SELECT p.oid,
|
|
662
|
+
p.proname,
|
|
663
|
+
p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS sig,
|
|
664
|
+
agent_board_test.stripped_body(p.oid) AS body,
|
|
665
|
+
p.proconfig
|
|
666
|
+
FROM pg_proc p
|
|
667
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
668
|
+
WHERE n.nspname = 'agent_board'
|
|
669
|
+
AND p.prokind IN ('f', 'p')
|
|
670
|
+
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
671
|
+
),
|
|
672
|
+
-- Resolve BARE names the way PostgreSQL would. A function pinning search_path
|
|
673
|
+
-- WITHOUT agent_board cannot reach these tables unqualified, so bare matches are
|
|
674
|
+
-- not attributed to it. No search_path setting at all is treated as bare-resolving,
|
|
675
|
+
-- which over-approximates membership rather than under-approximating it.
|
|
676
|
+
q AS (
|
|
677
|
+
SELECT f.oid, f.proname, f.sig, f.body,
|
|
678
|
+
CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig, '{}')) c
|
|
679
|
+
WHERE c LIKE 'search\_path=%')
|
|
680
|
+
OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
|
|
681
|
+
WHERE c LIKE 'search\_path=%'
|
|
682
|
+
AND ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
|
|
683
|
+
LIKE '%,agent\_board,%')
|
|
684
|
+
THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
|
|
685
|
+
FROM fns f
|
|
686
|
+
),
|
|
687
|
+
rels AS (
|
|
688
|
+
SELECT c.oid AS rel_oid, c.relname, n.nspname
|
|
689
|
+
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
690
|
+
WHERE c.relkind IN ('r', 'p')
|
|
691
|
+
AND (n.nspname = 'agent_board' OR (n.nspname = 'auth' AND c.relname = 'users'))
|
|
692
|
+
),
|
|
693
|
+
-- Prefilter: a function can only touch a relation whose name its body contains.
|
|
694
|
+
cand AS (
|
|
695
|
+
SELECT f.oid AS fn_oid, f.body, f.q, r.rel_oid, r.relname,
|
|
696
|
+
CASE WHEN r.nspname = 'agent_board' THEN f.q ELSE r.nspname || '\.' END AS rq
|
|
697
|
+
FROM q f CROSS JOIN rels r
|
|
698
|
+
WHERE f.body ~ ('\m' || r.relname || '\M')
|
|
699
|
+
),
|
|
700
|
+
lit AS (
|
|
701
|
+
SELECT c.fn_oid, c.body, c.q, c.rel_oid, c.relname, c.rq,
|
|
702
|
+
substring(c.body FROM
|
|
703
|
+
'(?:insert\s+into\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS ins_hit,
|
|
704
|
+
substring(c.body FROM
|
|
705
|
+
'(?:update\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M[^;]{0,250}?\mset\M)') AS upd_hit,
|
|
706
|
+
substring(c.body FROM
|
|
707
|
+
'(?:delete\s+from\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS del_hit,
|
|
708
|
+
substring(c.body FROM
|
|
709
|
+
'(?:' || c.rq || '\m' || c.relname
|
|
710
|
+
|| '\M[^;]{0,250}?for\s+(?:update|no\s+key\s+update|share|key\s+share))') AS lock_hit
|
|
711
|
+
FROM cand c
|
|
712
|
+
),
|
|
713
|
+
-- FK columns by NAME, from pg_attribute over conkey. Shape (v) is gated on the
|
|
714
|
+
-- SET naming one of these; hand-listing them, or skipping the gate, is what
|
|
715
|
+
-- turns this detector into a cycle-fabricator.
|
|
716
|
+
fkcols AS (
|
|
717
|
+
SELECT k.conname, k.conrelid, k.confrelid, a.attname
|
|
718
|
+
FROM pg_constraint k
|
|
719
|
+
CROSS JOIN LATERAL unnest(k.conkey) AS ck(attnum)
|
|
720
|
+
JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = ck.attnum
|
|
721
|
+
WHERE k.contype = 'f'
|
|
722
|
+
),
|
|
723
|
+
upd AS (
|
|
724
|
+
SELECT l.fn_oid, fc.conname, fc.confrelid,
|
|
725
|
+
min(strpos(l.body, substring(l.body FROM
|
|
726
|
+
'(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
|
|
727
|
+
|| '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)'))) AS pos
|
|
728
|
+
FROM lit l
|
|
729
|
+
JOIN fkcols fc ON fc.conrelid = l.rel_oid
|
|
730
|
+
WHERE l.body ~ ('(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
|
|
731
|
+
|| '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)')
|
|
732
|
+
GROUP BY l.fn_oid, fc.conname, fc.confrelid
|
|
733
|
+
),
|
|
734
|
+
direct AS (
|
|
735
|
+
-- (i) an explicit row lock written in the body
|
|
736
|
+
SELECT l.fn_oid, l.rel_oid,
|
|
737
|
+
regexp_replace(substring(l.lock_hit FROM
|
|
738
|
+
'for\s+(update|no\s+key\s+update|share|key\s+share)'), '\s+', ' ', 'g') AS lock_mode,
|
|
739
|
+
'textual' AS via,
|
|
740
|
+
strpos(l.body, l.lock_hit) AS pos
|
|
741
|
+
FROM lit l WHERE l.lock_hit IS NOT NULL
|
|
742
|
+
UNION ALL
|
|
743
|
+
-- (ii) UPDATE row-locks the updated rows
|
|
744
|
+
SELECT l.fn_oid, l.rel_oid, 'update', 'write-update', strpos(l.body, l.upd_hit)
|
|
745
|
+
FROM lit l WHERE l.upd_hit IS NOT NULL
|
|
746
|
+
UNION ALL
|
|
747
|
+
-- (iii) DELETE row-locks the deleted rows
|
|
748
|
+
SELECT l.fn_oid, l.rel_oid, 'update', 'write-delete', strpos(l.body, l.del_hit)
|
|
749
|
+
FROM lit l WHERE l.del_hit IS NOT NULL
|
|
750
|
+
UNION ALL
|
|
751
|
+
-- (iv) INSERT takes FOR KEY SHARE on every referenced parent row
|
|
752
|
+
SELECT l.fn_oid, k.confrelid, 'key share', 'fk-insert:' || k.conname, strpos(l.body, l.ins_hit)
|
|
753
|
+
FROM lit l
|
|
754
|
+
JOIN pg_constraint k ON k.conrelid = l.rel_oid AND k.contype = 'f'
|
|
755
|
+
WHERE l.ins_hit IS NOT NULL
|
|
756
|
+
UNION ALL
|
|
757
|
+
-- (v) an UPDATE that re-points an FK column locks the NEW parent row
|
|
758
|
+
SELECT u.fn_oid, u.confrelid, 'key share', 'fk-update:' || u.conname, u.pos
|
|
759
|
+
FROM upd u
|
|
760
|
+
UNION ALL
|
|
761
|
+
-- (vi) DELETE of a referenced row locks the referencing child rows
|
|
762
|
+
SELECT l.fn_oid, k.conrelid,
|
|
763
|
+
CASE k.confdeltype WHEN 'c' THEN 'update'
|
|
764
|
+
WHEN 'n' THEN 'no key update'
|
|
765
|
+
WHEN 'd' THEN 'no key update'
|
|
766
|
+
ELSE 'key share' END,
|
|
767
|
+
'fk-delete:' || k.conname, strpos(l.body, l.del_hit)
|
|
768
|
+
FROM lit l
|
|
769
|
+
JOIN pg_constraint k ON k.confrelid = l.rel_oid AND k.contype = 'f'
|
|
770
|
+
WHERE l.del_hit IS NOT NULL
|
|
771
|
+
),
|
|
772
|
+
calls AS (
|
|
773
|
+
SELECT c.oid AS caller, e.oid AS callee, e.proname AS callee_name,
|
|
774
|
+
strpos(c.body, substring(c.body FROM c.q || '\m' || e.proname || '\M\s*\(')) AS pos
|
|
775
|
+
FROM q c JOIN q e ON e.oid <> c.oid
|
|
776
|
+
WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
|
|
777
|
+
),
|
|
778
|
+
reach AS (
|
|
779
|
+
SELECT d.fn_oid, d.rel_oid, d.lock_mode, d.via, array[d.pos] AS path,
|
|
780
|
+
true AS own, d.fn_oid AS origin_oid
|
|
781
|
+
FROM direct d
|
|
782
|
+
UNION ALL
|
|
783
|
+
SELECT c.caller, r.rel_oid, r.lock_mode, 'callee:' || c.callee_name,
|
|
784
|
+
c.pos || r.path, false, r.origin_oid
|
|
785
|
+
FROM reach r JOIN calls c ON c.callee = r.fn_oid
|
|
786
|
+
) CYCLE fn_oid SET is_cycle USING cycle_path
|
|
787
|
+
SELECT r.fn_oid, f.proname, f.sig, r.rel_oid,
|
|
788
|
+
(SELECT n.nspname || '.' || cl.relname FROM pg_class cl
|
|
789
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE cl.oid = r.rel_oid),
|
|
790
|
+
r.lock_mode, r.via, r.path, r.own, r.origin_oid,
|
|
791
|
+
(SELECT proname FROM q WHERE oid = r.origin_oid)
|
|
792
|
+
FROM reach r JOIN q f ON f.oid = r.fn_oid
|
|
793
|
+
WHERE NOT r.is_cycle;
|
|
794
|
+
$lockedges$;
|
|
795
|
+
|
|
796
|
+
-- The name-resolved call closure (reflexive), so a test can ask "does this route
|
|
797
|
+
-- reach <serialising helper>" without re-deriving the graph.
|
|
798
|
+
CREATE OR REPLACE FUNCTION agent_board_test.fn_call_closure()
|
|
799
|
+
RETURNS TABLE (root_oid oid, root_name text, reached_oid oid, reached_name text)
|
|
800
|
+
LANGUAGE sql STABLE
|
|
801
|
+
SET search_path = pg_catalog
|
|
802
|
+
AS $closure$
|
|
803
|
+
WITH RECURSIVE
|
|
804
|
+
fns AS (
|
|
805
|
+
SELECT p.oid, p.proname, agent_board_test.stripped_body(p.oid) AS body, p.proconfig
|
|
806
|
+
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
807
|
+
WHERE n.nspname = 'agent_board' AND p.prokind IN ('f','p')
|
|
808
|
+
AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
|
|
809
|
+
),
|
|
810
|
+
q AS (
|
|
811
|
+
SELECT f.oid, f.proname, f.body,
|
|
812
|
+
CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig,'{}')) c
|
|
813
|
+
WHERE c LIKE 'search\_path=%')
|
|
814
|
+
OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
|
|
815
|
+
WHERE c LIKE 'search\_path=%'
|
|
816
|
+
AND ','||replace(replace(split_part(c,'=',2),' ',''),'"','')||','
|
|
817
|
+
LIKE '%,agent\_board,%')
|
|
818
|
+
THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
|
|
819
|
+
FROM fns f
|
|
820
|
+
),
|
|
821
|
+
calls AS (
|
|
822
|
+
SELECT c.oid AS caller, e.oid AS callee
|
|
823
|
+
FROM q c JOIN q e ON e.oid <> c.oid
|
|
824
|
+
WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
|
|
825
|
+
),
|
|
826
|
+
closure AS (
|
|
827
|
+
SELECT oid AS root, oid AS reached FROM q
|
|
828
|
+
UNION
|
|
829
|
+
SELECT c.root, e.callee FROM closure c JOIN calls e ON e.caller = c.reached
|
|
830
|
+
)
|
|
831
|
+
SELECT c.root, (SELECT proname FROM q WHERE oid = c.root),
|
|
832
|
+
c.reached, (SELECT proname FROM q WHERE oid = c.reached)
|
|
833
|
+
FROM closure c;
|
|
834
|
+
$closure$;
|
|
835
|
+
|
|
836
|
+
COMMENT ON FUNCTION agent_board_test.lock_edges() IS
|
|
837
|
+
'FK-aware per-function row-lock edge set: explicit locks, write locks, and the FOR KEY SHARE / child locks PostgreSQL takes through foreign keys. Test-only; see message-board#281.';
|
|
838
|
+
|
|
430
839
|
GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;
|