@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.
Files changed (35) hide show
  1. package/fixtures/already-applied.json +32 -0
  2. package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
  3. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  4. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  5. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  6. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  7. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  8. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  9. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  10. package/package.json +1 -1
  11. package/schema-manifest.json +75 -3
  12. package/src/tooling.test.mjs +47 -2
  13. package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
  14. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  15. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  16. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  17. package/tests/agent_board_fedsession.test.sql +17 -2
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  19. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  20. package/tests/agent_board_ledger_authority.test.sql +1081 -0
  21. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  22. package/tests/agent_board_signer_lifecycle.test.sql +29 -6
  23. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  24. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  25. package/tests/catalog/p_metadata_parameters.json +20 -0
  26. package/tests/fixtures/test-prereqs.sql +420 -11
  27. package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
  28. package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
  29. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  30. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  31. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  32. package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
  33. package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
  34. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  35. 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 sql
403
+ LANGUAGE plpgsql
367
404
  STABLE
368
405
  SET search_path = pg_catalog
369
- AS $$
370
- SELECT agent_board_test.strip_sql(p.prosrc, p_keep_literals)
371
- FROM pg_proc p
372
- WHERE p.oid = p_fn;
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), p.prosrc into v_def, v_src
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
- return null;
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
- return v_def;
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: prosrc not found verbatim inside pg_get_functiondef for oid % -- refusing to guess', p_fn;
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;