@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.
Files changed (43) hide show
  1. package/fixtures/already-applied.json +40 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
  10. package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
  11. package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
  12. package/package.json +1 -1
  13. package/schema-manifest.json +93 -3
  14. package/src/deployment-drift-script.test.mjs +424 -0
  15. package/src/deployment-drift.test.mjs +301 -0
  16. package/src/tooling.test.mjs +83 -2
  17. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  18. package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
  19. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  20. package/tests/agent_board_consent_substrate.test.sql +8 -1
  21. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  22. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  23. package/tests/agent_board_fedsession.test.sql +17 -2
  24. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  25. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  26. package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
  27. package/tests/agent_board_ledger_authority.test.sql +142 -71
  28. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  29. package/tests/agent_board_signed_write_enforced.test.sql +366 -0
  30. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  31. package/tests/agent_board_source_strip_helper.test.sql +256 -1
  32. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  33. package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
  34. package/tests/catalog/p_metadata_parameters.json +12 -0
  35. package/tests/fixtures/isolation-substrate.sql +1 -1
  36. package/tests/fixtures/test-prereqs.sql +442 -11
  37. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  38. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  39. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  40. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  41. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  42. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  43. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -17,6 +17,14 @@
17
17
  "signature": "_addressing_descriptors_array(p_descriptors jsonb)",
18
18
  "declares_p_metadata": false
19
19
  },
20
+ {
21
+ "signature": "_agent_can_access_thread(p_thread_id uuid, p_agent_id uuid)",
22
+ "declares_p_metadata": false
23
+ },
24
+ {
25
+ "signature": "_agent_has_board_access(p_board_id uuid, p_agent_id uuid)",
26
+ "declares_p_metadata": false
27
+ },
20
28
  {
21
29
  "signature": "_agent_principal_active(p_agent_id uuid)",
22
30
  "declares_p_metadata": false
@@ -133,6 +141,10 @@
133
141
  "signature": "_ensure_agent_auth_user(p_slug text)",
134
142
  "declares_p_metadata": false
135
143
  },
144
+ {
145
+ "signature": "_federated_identity_slug(p_issuer text, p_subject text)",
146
+ "declares_p_metadata": false
147
+ },
136
148
  {
137
149
  "signature": "_fedsession_autojoin_default_board(p_provider agent_board.identity_providers, p_agent_id uuid)",
138
150
  "declares_p_metadata": false
@@ -1,7 +1,7 @@
1
1
  -- Minimal Supabase substrate for the two-session ISOLATION harness only.
2
2
  --
3
3
  -- The pgTAP runner applies the agent_board bundle onto the DB that `supabase
4
- -- start` provisions (54322/postgres), which already carries GoTrue's `auth`
4
+ -- start` provisions (24322/postgres), which already carries GoTrue's `auth`
5
5
  -- schema, the `extensions` schema, pgcrypto, and the base roles. The isolation
6
6
  -- harness instead builds a THROWAWAY template database (specs COMMIT across
7
7
  -- sessions and cannot share the live DB), so it must self-provision the same
@@ -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
@@ -393,19 +470,45 @@ declare
393
470
  v_def text;
394
471
  v_src text;
395
472
  v_at int;
473
+ v_hits int;
396
474
  begin
397
- select pg_get_functiondef(p.oid), p.prosrc into v_def, v_src
475
+ select pg_get_functiondef(p.oid),
476
+ coalesce(nullif(p.prosrc, ''), pg_catalog.pg_get_function_sqlbody(p.oid))
477
+ into v_def, v_src
398
478
  from pg_proc p where p.oid = p_fn;
399
- if v_def is null then
400
- return null;
479
+ if not found or v_def is null then
480
+ raise exception
481
+ 'agent_board_test.stripped_def: no readable definition for oid % -- refusing to return NULL, which a `where … !~ …` class scan drops SILENTLY', p_fn;
401
482
  end if;
402
483
  if v_src is null or v_src = '' then
403
- return v_def;
484
+ raise exception
485
+ 'agent_board_test.stripped_def: oid % has neither a prosrc body nor a prosqlbody body -- refusing to return an unstripped definition', p_fn;
404
486
  end if;
405
487
  v_at := position(v_src in v_def);
406
488
  if v_at = 0 then
407
489
  raise exception
408
- 'agent_board_test.stripped_def: prosrc not found verbatim inside pg_get_functiondef for oid % -- refusing to guess', p_fn;
490
+ 'agent_board_test.stripped_def: body not found verbatim inside pg_get_functiondef for oid % -- refusing to guess', p_fn;
491
+ end if;
492
+ -- AMBIGUITY (message-board#304). `position()` returns the FIRST match, and
493
+ -- pg_get_functiondef emits AUTHOR-CONTROLLED text -- the function name, every
494
+ -- argument name, the SET clauses -- BEFORE the body. When a short body also
495
+ -- occurs in that header, the splice lands on the HEADER occurrence: the header
496
+ -- is corrupted and the real body is returned UNSTRIPPED, so a positive or
497
+ -- negative assertion over the result reads text that was never stripped. Not
498
+ -- hypothetical: a `mint_harden` assertion was already defeated once by an
499
+ -- argument named `p_raw_key_fingerprint` (the #196 lint's rule 3), so header
500
+ -- text is a live surface. The BEGIN ATOMIC arm above makes this MORE likely,
501
+ -- not less: a deparsed body is short and normalized (`RETURN (p + 1)`).
502
+ --
503
+ -- Anchoring on the last occurrence instead would still be a GUESS about which
504
+ -- match is the body. The `v_at = 0` arm already refuses to guess when the body
505
+ -- is not found; ambiguity gets the same answer, matching strip_sql's
506
+ -- fail-closed behaviour on an unterminated construct. `replace` counts
507
+ -- non-overlapping occurrences, which is the count that matters for a splice.
508
+ v_hits := (length(v_def) - length(replace(v_def, v_src, ''))) / length(v_src);
509
+ if v_hits > 1 then
510
+ raise exception
511
+ 'agent_board_test.stripped_def: body occurs % times inside pg_get_functiondef for oid % — ambiguous splice, refusing to guess which occurrence is the body (a match in the author-controlled header would corrupt it and return the body unstripped)', v_hits, p_fn;
409
512
  end if;
410
513
  return substr(v_def, 1, v_at - 1)
411
514
  || agent_board_test.strip_sql(v_src, p_keep_literals)
@@ -427,4 +530,332 @@ AS $$
427
530
  SELECT agent_board_test.strip_sql(pg_get_viewdef(p_rel), p_keep_literals);
428
531
  $$;
429
532
 
533
+ -- ===========================================================================
534
+ -- FK-AWARE LOCK-EDGE DETECTOR (message-board#281)
535
+ --
536
+ -- WHY THIS EXISTS. Every lock-order guard in this repo up to now found locks by
537
+ -- scanning a function body for the literal text `for update` / `for share`. A
538
+ -- lock that PostgreSQL takes on our behalf through a FOREIGN KEY never appears
539
+ -- in that text, so those guards are structurally blind to it -- and the blind
540
+ -- spot already contains an ABBA. `_upsert_board_membership_state` takes
541
+ -- `board_memberships FOR UPDATE` and then, via `_emit_membership_event`'s INSERT
542
+ -- into `board_membership_events`, `agents FOR KEY SHARE` through
543
+ -- `board_membership_events_agent_id_fkey`. It never NAMES `agents`.
544
+ -- `onboard_federated_fleet_agent` takes those two relations in the opposite
545
+ -- order with `FOR UPDATE`, which KEY SHARE conflicts with.
546
+ --
547
+ -- WHAT IS CATALOG-DERIVED, AND WHAT IS NOT. Being exact about this is the point;
548
+ -- a detector that silently under-reports is worse than none, because it will be
549
+ -- trusted.
550
+ -- CATALOG (pg_constraint / pg_attribute / pg_class, resolved by OID, never by
551
+ -- a rendered name -- `regclass::text` drops the schema for anything on
552
+ -- search_path): which tables have foreign keys, to what, on which columns,
553
+ -- with what ON DELETE action. So a NEW foreign key is covered the day it is
554
+ -- added, with nothing here to update.
555
+ -- REGEX over agent_board_test.stripped_body(): which tables a function WRITES,
556
+ -- which explicit row locks it takes, and which functions it calls. plpgsql
557
+ -- bodies carry no catalog-level table dependencies, so nothing stronger exists
558
+ -- without a parser. Reading the STRIPPED body (not prosrc) is what stops a
559
+ -- comment or a string literal from manufacturing an edge.
560
+ --
561
+ -- THE SIX ACQUISITION SHAPES. Each has a named mutation pin in
562
+ -- agent_board_fk_lock_edges.test.sql; a shape with no pin is not in this list.
563
+ -- (i) `textual` -- an explicit `for update|no key update|share|key share`
564
+ -- in any statement shape, mode captured (NOT anchored on
565
+ -- `where`: `from boards b join ... for update of b` is a
566
+ -- real acquisition);
567
+ -- (ii) `write-update` -- `update T ... set` row-locks T itself. Recorded as
568
+ -- `update`; PostgreSQL takes the weaker FOR NO KEY UPDATE
569
+ -- when no key column changes, so this is fail-CLOSED;
570
+ -- (iii) `write-delete` -- `delete from T` row-locks the T rows it removes;
571
+ -- (iv) `fk-insert` -- an INSERT into T takes FOR KEY SHARE on the referenced
572
+ -- row of EVERY foreign key of T. This is the shape that
573
+ -- no body scan can see, and the reason for this file;
574
+ -- (v) `fk-update` -- an `update T ... set <col> = ...` where <col> is in some
575
+ -- FK's conkey re-checks that key and locks the NEW parent.
576
+ -- Gated on the SET naming a conkey column derived from
577
+ -- pg_attribute, NOT on "T has FKs" -- crediting every
578
+ -- UPDATE with every parent lock fabricates cycle members;
579
+ -- (vi) `fk-delete` -- deleting a REFERENCED row locks the referencing child
580
+ -- rows; the mode follows confdeltype (cascade => the child
581
+ -- row is deleted, set null/default => it is updated,
582
+ -- no action/restrict => FOR KEY SHARE).
583
+ --
584
+ -- CALL GRAPH. Closed over transitively, because every lock a wrapper takes is
585
+ -- usually in a callee. Each edge carries a POSITION PATH (`path int[]`): the
586
+ -- caller's call-site offset prepended to the callee's own offset, so ordering
587
+ -- survives nesting. Comparing bare offsets across frames is wrong -- two
588
+ -- acquisitions reached through the SAME call site would tie, and the ordering that
589
+ -- decides the cycle lives one frame down. Recursion in the (name-resolved,
590
+ -- over-approximated) call graph is cut by the SQL:2023 CYCLE clause.
591
+ --
592
+ -- WHAT IT DOES NOT COVER, stated so a green run is read for what it is:
593
+ -- * DYNAMIC SQL. An EXECUTE of a computed string is unreadable here. Pinned
594
+ -- fail-closed: FKL-11 fails the suite the day any function with a lock edge
595
+ -- acquires one.
596
+ -- * LOCKS TAKEN BY TRIGGERS on a written table. Not folded in. Pinned instead
597
+ -- by FKL-12, which asserts that no trigger function on an agent_board table
598
+ -- has ANY lock edge -- true today, and loud the day it stops being.
599
+ -- * INSERT/INSERT contention on a UNIQUE index. A second inserter of the same
600
+ -- key blocks on the first and can be a deadlock participant, but it is not a
601
+ -- row lock and has no mode.
602
+ -- * an UPDATE of a PARENT's referenced key columns, which fires the same child
603
+ -- RI checks as (vi). Our referenced keys are immutable uuid surrogates.
604
+ -- * NULL FK columns. A null takes no parent lock; every fk-* edge is credited
605
+ -- unconditionally, which over-reports rather than under-reports.
606
+ -- * RUNTIME vs POSITIONAL order. Positions are read over statements AS WRITTEN;
607
+ -- two acquisitions in mutually exclusive IF branches are treated as sequential.
608
+ -- * OVERLOAD RESOLUTION. Call edges are name-resolved, so every overload sharing
609
+ -- a proname is one callee -- over-connecting, which is safe for membership and
610
+ -- fail-open for a positional claim.
611
+ -- * `BEGIN ATOMIC` bodies. These are now READ (message-board#293:
612
+ -- stripped_body falls back to the deparsed prosqlbody), but what is read is a
613
+ -- DEPARSED tree, and the positional/`path` semantics this detector builds on
614
+ -- are only exercised against author-written text. So the FKL-6 tripwire --
615
+ -- `no agent_board function has a prosqlbody` -- is deliberately KEPT: it is no
616
+ -- longer "unreadable", it is "readable but unexercised". (An earlier version of
617
+ -- this line cited FKL-10, which is the ABBA-pair assertion; the tripwire is
618
+ -- FKL-6.)
619
+ -- ===========================================================================
620
+
621
+ -- PostgreSQL row-level lock conflict matrix (Concurrency Control -> Row-Level
622
+ -- Locks). The cycle enumeration is USELESS without it: over a schema this
623
+ -- FK-dense, a mode-blind ABBA search reports every pair of functions that insert
624
+ -- into two tables in opposite order, and two FOR KEY SHARE locks never conflict.
625
+ CREATE OR REPLACE FUNCTION agent_board_test.row_locks_conflict(p_a text, p_b text)
626
+ RETURNS boolean
627
+ LANGUAGE sql IMMUTABLE
628
+ SET search_path = pg_catalog
629
+ AS $$
630
+ -- KEY SHARE SHARE NO KEY UPDATE UPDATE
631
+ -- KEY SHARE . . . X
632
+ -- SHARE . . X X
633
+ -- NO KEY UPD . X X X
634
+ -- UPDATE X X X X
635
+ SELECT CASE
636
+ WHEN p_a IS NULL OR p_b IS NULL THEN true -- unknown: fail closed
637
+ WHEN p_a = 'update' OR p_b = 'update' THEN true
638
+ WHEN p_a = 'no key update' THEN p_b IN ('no key update', 'share')
639
+ WHEN p_a = 'share' THEN p_b = 'no key update'
640
+ WHEN p_a = 'key share' THEN false
641
+ ELSE true -- unrecognised: fail closed
642
+ END;
643
+ $$;
644
+
645
+ -- Strength order, used to pick the strongest mode a function takes on a relation.
646
+ -- An unrecognised mode ranks HIGHEST so it cannot be discarded silently.
647
+ CREATE OR REPLACE FUNCTION agent_board_test.row_lock_rank(p_mode text)
648
+ RETURNS int
649
+ LANGUAGE sql IMMUTABLE
650
+ SET search_path = pg_catalog
651
+ AS $$
652
+ SELECT CASE p_mode
653
+ WHEN 'key share' THEN 1
654
+ WHEN 'share' THEN 2
655
+ WHEN 'no key update' THEN 3
656
+ WHEN 'update' THEN 4
657
+ ELSE 5
658
+ END;
659
+ $$;
660
+
661
+ -- One row per (function, relation, acquisition). `own` distinguishes an
662
+ -- acquisition written in the function's own body from one reached through a
663
+ -- callee; `origin_name` names the function the acquisition actually lives in.
664
+ CREATE OR REPLACE FUNCTION agent_board_test.lock_edges()
665
+ RETURNS TABLE (
666
+ fn_oid oid,
667
+ fn_name text,
668
+ fn_sig text,
669
+ rel_oid oid,
670
+ rel_name text,
671
+ lock_mode text,
672
+ via text,
673
+ path int[],
674
+ own boolean,
675
+ origin_oid oid,
676
+ origin_name text
677
+ )
678
+ LANGUAGE sql STABLE
679
+ SET search_path = pg_catalog
680
+ AS $lockedges$
681
+ WITH RECURSIVE
682
+ fns AS (
683
+ SELECT p.oid,
684
+ p.proname,
685
+ p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS sig,
686
+ agent_board_test.stripped_body(p.oid) AS body,
687
+ p.proconfig
688
+ FROM pg_proc p
689
+ JOIN pg_namespace n ON n.oid = p.pronamespace
690
+ WHERE n.nspname = 'agent_board'
691
+ AND p.prokind IN ('f', 'p')
692
+ AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
693
+ ),
694
+ -- Resolve BARE names the way PostgreSQL would. A function pinning search_path
695
+ -- WITHOUT agent_board cannot reach these tables unqualified, so bare matches are
696
+ -- not attributed to it. No search_path setting at all is treated as bare-resolving,
697
+ -- which over-approximates membership rather than under-approximating it.
698
+ q AS (
699
+ SELECT f.oid, f.proname, f.sig, f.body,
700
+ CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig, '{}')) c
701
+ WHERE c LIKE 'search\_path=%')
702
+ OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
703
+ WHERE c LIKE 'search\_path=%'
704
+ AND ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
705
+ LIKE '%,agent\_board,%')
706
+ THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
707
+ FROM fns f
708
+ ),
709
+ rels AS (
710
+ SELECT c.oid AS rel_oid, c.relname, n.nspname
711
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
712
+ WHERE c.relkind IN ('r', 'p')
713
+ AND (n.nspname = 'agent_board' OR (n.nspname = 'auth' AND c.relname = 'users'))
714
+ ),
715
+ -- Prefilter: a function can only touch a relation whose name its body contains.
716
+ cand AS (
717
+ SELECT f.oid AS fn_oid, f.body, f.q, r.rel_oid, r.relname,
718
+ CASE WHEN r.nspname = 'agent_board' THEN f.q ELSE r.nspname || '\.' END AS rq
719
+ FROM q f CROSS JOIN rels r
720
+ WHERE f.body ~ ('\m' || r.relname || '\M')
721
+ ),
722
+ lit AS (
723
+ SELECT c.fn_oid, c.body, c.q, c.rel_oid, c.relname, c.rq,
724
+ substring(c.body FROM
725
+ '(?:insert\s+into\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS ins_hit,
726
+ substring(c.body FROM
727
+ '(?:update\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M[^;]{0,250}?\mset\M)') AS upd_hit,
728
+ substring(c.body FROM
729
+ '(?:delete\s+from\s+(?:only\s+)?' || c.rq || '\m' || c.relname || '\M)') AS del_hit,
730
+ substring(c.body FROM
731
+ '(?:' || c.rq || '\m' || c.relname
732
+ || '\M[^;]{0,250}?for\s+(?:update|no\s+key\s+update|share|key\s+share))') AS lock_hit
733
+ FROM cand c
734
+ ),
735
+ -- FK columns by NAME, from pg_attribute over conkey. Shape (v) is gated on the
736
+ -- SET naming one of these; hand-listing them, or skipping the gate, is what
737
+ -- turns this detector into a cycle-fabricator.
738
+ fkcols AS (
739
+ SELECT k.conname, k.conrelid, k.confrelid, a.attname
740
+ FROM pg_constraint k
741
+ CROSS JOIN LATERAL unnest(k.conkey) AS ck(attnum)
742
+ JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = ck.attnum
743
+ WHERE k.contype = 'f'
744
+ ),
745
+ upd AS (
746
+ SELECT l.fn_oid, fc.conname, fc.confrelid,
747
+ min(strpos(l.body, substring(l.body FROM
748
+ '(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
749
+ || '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)'))) AS pos
750
+ FROM lit l
751
+ JOIN fkcols fc ON fc.conrelid = l.rel_oid
752
+ WHERE l.body ~ ('(?:update\s+(?:only\s+)?' || l.rq || '\m' || l.relname
753
+ || '\M[^;]{0,250}?\mset\M[^;]{0,250}?\m' || fc.attname || '\M\s*=)')
754
+ GROUP BY l.fn_oid, fc.conname, fc.confrelid
755
+ ),
756
+ direct AS (
757
+ -- (i) an explicit row lock written in the body
758
+ SELECT l.fn_oid, l.rel_oid,
759
+ regexp_replace(substring(l.lock_hit FROM
760
+ 'for\s+(update|no\s+key\s+update|share|key\s+share)'), '\s+', ' ', 'g') AS lock_mode,
761
+ 'textual' AS via,
762
+ strpos(l.body, l.lock_hit) AS pos
763
+ FROM lit l WHERE l.lock_hit IS NOT NULL
764
+ UNION ALL
765
+ -- (ii) UPDATE row-locks the updated rows
766
+ SELECT l.fn_oid, l.rel_oid, 'update', 'write-update', strpos(l.body, l.upd_hit)
767
+ FROM lit l WHERE l.upd_hit IS NOT NULL
768
+ UNION ALL
769
+ -- (iii) DELETE row-locks the deleted rows
770
+ SELECT l.fn_oid, l.rel_oid, 'update', 'write-delete', strpos(l.body, l.del_hit)
771
+ FROM lit l WHERE l.del_hit IS NOT NULL
772
+ UNION ALL
773
+ -- (iv) INSERT takes FOR KEY SHARE on every referenced parent row
774
+ SELECT l.fn_oid, k.confrelid, 'key share', 'fk-insert:' || k.conname, strpos(l.body, l.ins_hit)
775
+ FROM lit l
776
+ JOIN pg_constraint k ON k.conrelid = l.rel_oid AND k.contype = 'f'
777
+ WHERE l.ins_hit IS NOT NULL
778
+ UNION ALL
779
+ -- (v) an UPDATE that re-points an FK column locks the NEW parent row
780
+ SELECT u.fn_oid, u.confrelid, 'key share', 'fk-update:' || u.conname, u.pos
781
+ FROM upd u
782
+ UNION ALL
783
+ -- (vi) DELETE of a referenced row locks the referencing child rows
784
+ SELECT l.fn_oid, k.conrelid,
785
+ CASE k.confdeltype WHEN 'c' THEN 'update'
786
+ WHEN 'n' THEN 'no key update'
787
+ WHEN 'd' THEN 'no key update'
788
+ ELSE 'key share' END,
789
+ 'fk-delete:' || k.conname, strpos(l.body, l.del_hit)
790
+ FROM lit l
791
+ JOIN pg_constraint k ON k.confrelid = l.rel_oid AND k.contype = 'f'
792
+ WHERE l.del_hit IS NOT NULL
793
+ ),
794
+ calls AS (
795
+ SELECT c.oid AS caller, e.oid AS callee, e.proname AS callee_name,
796
+ strpos(c.body, substring(c.body FROM c.q || '\m' || e.proname || '\M\s*\(')) AS pos
797
+ FROM q c JOIN q e ON e.oid <> c.oid
798
+ WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
799
+ ),
800
+ reach AS (
801
+ SELECT d.fn_oid, d.rel_oid, d.lock_mode, d.via, array[d.pos] AS path,
802
+ true AS own, d.fn_oid AS origin_oid
803
+ FROM direct d
804
+ UNION ALL
805
+ SELECT c.caller, r.rel_oid, r.lock_mode, 'callee:' || c.callee_name,
806
+ c.pos || r.path, false, r.origin_oid
807
+ FROM reach r JOIN calls c ON c.callee = r.fn_oid
808
+ ) CYCLE fn_oid SET is_cycle USING cycle_path
809
+ SELECT r.fn_oid, f.proname, f.sig, r.rel_oid,
810
+ (SELECT n.nspname || '.' || cl.relname FROM pg_class cl
811
+ JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE cl.oid = r.rel_oid),
812
+ r.lock_mode, r.via, r.path, r.own, r.origin_oid,
813
+ (SELECT proname FROM q WHERE oid = r.origin_oid)
814
+ FROM reach r JOIN q f ON f.oid = r.fn_oid
815
+ WHERE NOT r.is_cycle;
816
+ $lockedges$;
817
+
818
+ -- The name-resolved call closure (reflexive), so a test can ask "does this route
819
+ -- reach <serialising helper>" without re-deriving the graph.
820
+ CREATE OR REPLACE FUNCTION agent_board_test.fn_call_closure()
821
+ RETURNS TABLE (root_oid oid, root_name text, reached_oid oid, reached_name text)
822
+ LANGUAGE sql STABLE
823
+ SET search_path = pg_catalog
824
+ AS $closure$
825
+ WITH RECURSIVE
826
+ fns AS (
827
+ SELECT p.oid, p.proname, agent_board_test.stripped_body(p.oid) AS body, p.proconfig
828
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
829
+ WHERE n.nspname = 'agent_board' AND p.prokind IN ('f','p')
830
+ AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
831
+ ),
832
+ q AS (
833
+ SELECT f.oid, f.proname, f.body,
834
+ CASE WHEN NOT EXISTS (SELECT 1 FROM unnest(coalesce(f.proconfig,'{}')) c
835
+ WHERE c LIKE 'search\_path=%')
836
+ OR EXISTS (SELECT 1 FROM unnest(f.proconfig) c
837
+ WHERE c LIKE 'search\_path=%'
838
+ AND ','||replace(replace(split_part(c,'=',2),' ',''),'"','')||','
839
+ LIKE '%,agent\_board,%')
840
+ THEN '(?:agent_board\.)?' ELSE 'agent_board\.' END AS q
841
+ FROM fns f
842
+ ),
843
+ calls AS (
844
+ SELECT c.oid AS caller, e.oid AS callee
845
+ FROM q c JOIN q e ON e.oid <> c.oid
846
+ WHERE c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
847
+ ),
848
+ closure AS (
849
+ SELECT oid AS root, oid AS reached FROM q
850
+ UNION
851
+ SELECT c.root, e.callee FROM closure c JOIN calls e ON e.caller = c.reached
852
+ )
853
+ SELECT c.root, (SELECT proname FROM q WHERE oid = c.root),
854
+ c.reached, (SELECT proname FROM q WHERE oid = c.reached)
855
+ FROM closure c;
856
+ $closure$;
857
+
858
+ COMMENT ON FUNCTION agent_board_test.lock_edges() IS
859
+ '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.';
860
+
430
861
  GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;