@consiliency/agent-board-schema 1.51.0 → 1.52.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 (39) hide show
  1. package/package.json +1 -1
  2. package/schema-manifest.json +1 -1
  3. package/tests/agent_board_agent_liveness.test.sql +3 -1
  4. package/tests/agent_board_atomic_fleet_onboarding.test.sql +33 -36
  5. package/tests/agent_board_authbound_boundary.test.sql +4 -3
  6. package/tests/agent_board_board_claim_policy.test.sql +17 -6
  7. package/tests/agent_board_claim_subject_gate.test.sql +7 -8
  8. package/tests/agent_board_claimkey.test.sql +9 -6
  9. package/tests/agent_board_claimpolicy_race.test.sql +1466 -342
  10. package/tests/agent_board_consent_emission.test.sql +5 -4
  11. package/tests/agent_board_consent_gating.test.sql +20 -9
  12. package/tests/agent_board_enroll_resumption.test.sql +1 -1
  13. package/tests/agent_board_enrollment_audit_content.test.sql +5 -4
  14. package/tests/agent_board_federated_identity_injective_slug.test.sql +3 -1
  15. package/tests/agent_board_federated_lock_order_class.test.sql +69 -52
  16. package/tests/agent_board_fedsession.test.sql +3 -1
  17. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +9 -4
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +18 -6
  19. package/tests/agent_board_fk_lock_edges.test.sql +64 -5
  20. package/tests/agent_board_fleet_domain_reclaim.test.sql +2 -4
  21. package/tests/agent_board_fleet_machine_hijack.test.sql +2 -4
  22. package/tests/agent_board_fleet_machine_reactivation.test.sql +5 -1
  23. package/tests/agent_board_fleet_machine_reclaim.test.sql +2 -4
  24. package/tests/agent_board_invite_no_downgrade.test.sql +24 -18
  25. package/tests/agent_board_ledger_authority.test.sql +29 -21
  26. package/tests/agent_board_mint_harden.test.sql +5 -5
  27. package/tests/agent_board_outbox_wake_ordering.test.sql +4 -3
  28. package/tests/agent_board_ownerless_renew_quota.test.sql +7 -2
  29. package/tests/agent_board_portal_offline_invariant.test.sql +5 -4
  30. package/tests/agent_board_receipt_subject_gate.test.sql +7 -8
  31. package/tests/agent_board_rolereset.test.sql +5 -3
  32. package/tests/agent_board_rowid.test.sql +34 -15
  33. package/tests/agent_board_signer_lifecycle.test.sql +24 -13
  34. package/tests/agent_board_source_strip_helper.test.sql +2 -1
  35. package/tests/agent_board_task_event_origin_posture.test.sql +21 -9
  36. package/tests/agent_board_wake_recovery.test.sql +53 -26
  37. package/tests/agent_board_write_enforcement_class.test.sql +130 -12
  38. package/tests/catalog/function_execute_privileges.json +1923 -0
  39. package/tests/fixtures/test-prereqs.sql +644 -237
@@ -658,211 +658,12 @@ AS $$
658
658
  END;
659
659
  $$;
660
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
-
861
661
  GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;
862
662
 
863
663
  -- ---------------------------------------------------------------------------
864
- -- IDENTIFIER MATCHERS (Consiliency/message-board#405, #417). ONE definition of
865
- -- "does this body reference agent_board.<name>", used by every assertion that asks.
664
+ -- IDENTIFIER MATCHERS (Consiliency/message-board#405, #416, #417, #424). ONE definition of
665
+ -- "does this body reference agent_board.<name>", and of "does this statement WRITE <rel>" and
666
+ -- "does this statement ASSIGN <rel>.<col>", used by every assertion that asks.
866
667
  --
867
668
  -- WHY THESE EXIST. The same lookup was hand-written at ~20 sites and drifted twice in
868
669
  -- opposite directions in consecutive review rounds: first too STRICT (an abutting-paren,
@@ -880,11 +681,48 @@ GRANT USAGE ON SCHEMA agent_board_test TO PUBLIC;
880
681
  -- * a QUOTED identifier is case-SENSITIVE, so `"AGENT_BOARD".boards` is a different object
881
682
  -- (`to_regclass('"AGENT_BOARD".boards')` returns NULL);
882
683
  -- * a reference is ours only if it is UNQUALIFIED or qualified by exactly `agent_board` — any
883
- -- other qualifier names someone else's object.
684
+ -- other qualifier names someone else's object — or by `<database>.agent_board`, the three-part
685
+ -- spelling PostgreSQL accepts for the current database;
686
+ -- * a statement writes a column only where the GRAMMAR puts an assignment target: the SET list
687
+ -- of an UPDATE or a MERGE update action, an INSERT / MERGE-insert column list, a no-column-list
688
+ -- INSERT (every column), a COPY FROM whose column list names it (or has no list), a
689
+ -- SELECT INTO a new table. A column named in a WHERE, RETURNING, ON or a SET value is READ.
690
+ --
691
+ -- WHAT THIS FAMILY CANNOT SEE, stated here because the residue belongs where the matcher lives:
692
+ -- * DOLLAR-QUOTE DESYNC. The subject is a literal-stripped body; a body whose apostrophes are
693
+ -- unbalanced inside a dollar-quoted string desynchronises the stripper and everything after
694
+ -- the desync is read as a literal (memory: a live function survived only on an even apostrophe
695
+ -- count). `same_stmt()` skips the literal forms it knows (`'…'` with `''`, `E'…'` with `\'`);
696
+ -- it does not know a nested `$tag$…$tag$` and treats its contents as code.
697
+ -- * DYNAMIC SQL. `execute format('update agent_board.%I …', v_rel)` writes a relation no pattern
698
+ -- over the body can name. `mark_retention_metadata` is the one such writer today; change 9 of
699
+ -- the #416 plan names it positively, and a second one fails there by name.
700
+ -- * INDIRECTION. A relation reached through a VIEW with an INSTEAD OF trigger or a rule, a
701
+ -- function variable holding a relation name, or a callee in another schema is not a write of
702
+ -- the base relation to these patterns. `lock_edges` closes over `agent_board` callees only.
703
+ -- * STATEMENT SPAN. `same_stmt()` ends at the first `;` outside a string literal. A `;` inside a
704
+ -- nested dollar-quoted string or a comment the stripper did not remove ends the span early —
705
+ -- that is under-matching (a miss), the silent direction, and it is bounded only by the
706
+ -- stripper's own tests.
707
+ -- * NESTING DEPTH. `set_run()` consumes a parenthesised sub-expression whole to TWO levels; a
708
+ -- SET-list terminator keyword at the third level ends the run early (a miss). CPR-0 row
709
+ -- `k1-three-level-paren-KNOWN-MISS` records the bound as a measured value.
710
+ -- * ITSELF. CPR-0b's tokenizer walks the family's own bodies for regex operands; those six
711
+ -- sites carry hand literals by construction and are annotated `loud-literal` for the lint,
712
+ -- because a matcher cannot be the scanner that judges whether it is a matcher.
713
+ -- * THE PARSER'S VIEW. `agent_board_write_enforcement_class` WEC-24 asks plpgsql_check for the
714
+ -- relations each plpgsql function depends on and requires every regex-found (writer, relation)
715
+ -- pair to be among them. That bounds this family's FALSE POSITIVES against the real parser;
716
+ -- it cannot bound the misses, because a dependency is a read or a write alike.
884
717
  --
885
718
  -- CALLERS MUST USE `~` / regexp_instr WITHOUT THE 'i' FLAG. The case rules are inside these
886
719
  -- patterns; adding 'i' would fold the quoted alternative and reintroduce the second defect.
887
720
  -- Keywords that should stay case-insensitive get agent_board_test.ci_re() for the same reason.
721
+ --
722
+ -- COMPOSITION RULE. Only a `*_re` carries the `(?c)` director, and a director is legal ONLY at
723
+ -- position 0 of a regular expression (`b|(?c)a` and `(?:(?c)a)` both raise `invalid regular
724
+ -- expression`). So no site concatenates anything onto, or two of, the `*_re` outputs. A site that
725
+ -- needs a union composes `*_frag`s and wraps ONCE with `agent_board_test.re(...)`.
888
726
 
889
727
  -- A SPACE IN p_text BECOMES `\s+`, because every space this is ever given separates the words of a
890
728
  -- multiword SQL keyword (`insert into`, `no key`, `key share`) and PostgreSQL accepts any whitespace
@@ -914,6 +752,13 @@ $$;
914
752
  create or replace function agent_board_test.ident_chars() returns text
915
753
  language sql immutable set search_path to pg_catalog as $$ select 'A-Za-z0-9_$' $$;
916
754
 
755
+ -- ANY identifier token, unquoted or quoted, with no case rule: the database component of a
756
+ -- three-part name and an INSERT alias are matched by SHAPE, since neither names our object.
757
+ create or replace function agent_board_test.ident_any_frag() returns text
758
+ language sql immutable set search_path to pg_catalog as $$
759
+ select '(?:[A-Za-z_][' || agent_board_test.ident_chars() || ']*|"[^"]*")'
760
+ $$;
761
+
917
762
  -- THE BOUNDARY GUARDS. Three rejections, each a defect that shipped:
918
763
  -- * an ASCII identifier character or a quote immediately before/after — `dashboards`, `x$boards`;
919
764
  -- * a NON-ASCII letter, which PostgreSQL also admits in an unquoted identifier, so
@@ -934,18 +779,94 @@ language sql immutable set search_path to pg_catalog as $$
934
779
  select '(?![' || agent_board_test.ident_chars() || '])(?![^\x00-\x7F])'
935
780
  $$;
936
781
 
782
+ -- THE STATEMENT SPAN. Replaces every hand-rolled `[^;]*` / `[^;]{0,250}?` join: a run of non-`;`
783
+ -- characters in which a STRING LITERAL is consumed whole, so a `;` inside `'a;b'` does not end the
784
+ -- statement and a `where` inside `'where'` is not a keyword. Two literal forms: `E'…'` (with `\'`
785
+ -- and `''` escapes; the `E` must be a standalone token, because `line'x'` lexes as an identifier
786
+ -- followed by a PLAIN literal) and the plain `'…'` form (`''` escapes only; a backslash is an
787
+ -- ordinary character under standard_conforming_strings). No live agent_board body uses an E-string
788
+ -- today, so that arm pins the NEXT writer, not a current miss. Greedy: a caller that needs a
789
+ -- position takes the leftmost match, which is unchanged; only its extent grows.
790
+ create or replace function agent_board_test.same_stmt() returns text
791
+ language sql immutable set search_path to pg_catalog as $q$
792
+ select $s$(?:[^;']|(?<![A-Za-z0-9_$])[Ee]'(?:[^'\\]|\\.|'')*'|'(?:[^']|'')*')*$s$
793
+ $q$;
794
+
795
+ -- THE SET-CLAUSE SPAN: same_stmt()'s token set MINUS the keywords that end THIS statement's SET
796
+ -- list (p_terms, an alternation, refused as a whole word by a lookahead on every step) PLUS a
797
+ -- parenthesised sub-expression consumed whole to two levels of nesting — so a `where` inside
798
+ -- `set slug = (select 1 from t where q = 1), claim_policy = 2` does not end the run while
799
+ -- `set slug = x where claim_policy = y` does. The terminator set is a PARAMETER because it differs
800
+ -- by statement: UPDATE passes `where|from|returning`; MERGE passes `when\s+(?:not\s+)?matched|
801
+ -- returning` — the next WHEN CLAUSE, never a bare `when`, so `case when` survives in both. A shared
802
+ -- set with `when` in it was measured to hide `set slug = case when x then y else z end,
803
+ -- claim_policy = p`. Letters in p_terms fold to either case unless they follow a backslash
804
+ -- (`\s` stays `\s`). A paren group cannot be entered partially: `(` is not in the character class,
805
+ -- so the run either consumes a balanced group or stops before it.
806
+ create or replace function agent_board_test.set_run(p_terms text) returns text
807
+ language sql immutable set search_path to pg_catalog as $q$
808
+ with chars as (
809
+ select c, ord, coalesce(lag(c) over (order by ord), '') as prev
810
+ from regexp_split_to_table(p_terms, '') with ordinality as x(c, ord)
811
+ ),
812
+ t as (
813
+ select string_agg(case when c ~ '[A-Za-z]' and prev <> '\' then '[' || upper(c) || lower(c) || ']'
814
+ else c end, '' order by ord) as folded
815
+ from chars
816
+ ),
817
+ lit as (select $s$(?<![A-Za-z0-9_$])[Ee]'(?:[^'\\]|\\.|'')*'|'(?:[^']|'')*'$s$ as l),
818
+ p0 as (select '\((?:[^();'']|' || l || ')*\)' as p from lit),
819
+ p1 as (select '\((?:[^();'']|' || lit.l || '|' || p0.p || ')*\)' as p from lit, p0)
820
+ select '(?:(?!\m(?:' || t.folded || ')\M)(?:[^;''()]|' || lit.l || '|' || p1.p || '))*'
821
+ from t, lit, p1
822
+ $q$;
823
+
824
+ -- THE ONLY PLACE A DIRECTOR IS ATTACHED TO A COMPOSED PATTERN. Sites that need a union of frags
825
+ -- wrap once here; see the composition rule above.
826
+ create or replace function agent_board_test.re(p_frag text) returns text
827
+ language sql immutable set search_path to pg_catalog as $$
828
+ select '(?c)(?:' || p_frag || ')'
829
+ $$;
830
+
937
831
  -- The un-prefixed fragments. Public matchers add the (?c) director exactly once; these exist so a
938
832
  -- matcher can embed another without producing a mid-pattern director, which PostgreSQL rejects.
833
+ --
834
+ -- p_name is `[schema.]name`; the default schema is agent_board. A dotted name matches ONLY the
835
+ -- schema-qualified spelling of that other schema's object (`lock_edges` asks for `auth.users`).
836
+ -- p_bare_ok = false REQUIRES the schema qualifier: `lock_edges` / `fn_call_closure` derive it from
837
+ -- the function's search_path (a function pinning search_path WITHOUT agent_board cannot reach these
838
+ -- names unqualified), and routing them through an always-optional qualifier would have widened
839
+ -- their acceptance — a consolidation that is not the union of what it replaces (#417).
840
+ -- A three-part name `<db>.agent_board.<name>` is accepted; the database component is admitted only
841
+ -- when the schema component follows it, so `mb_db.other.boards` is still refused.
842
+ create or replace function agent_board_test.qname_frag(p_name text, p_bare_ok boolean) returns text
843
+ language sql immutable set search_path to pg_catalog as $$
844
+ select agent_board_test.lead_guard()
845
+ || '(?:(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*)?'
846
+ || agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 1) else 'agent_board' end)
847
+ || '\s*\.\s*)' || case when p_bare_ok then '?' else '' end
848
+ || agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 2) else p_name end)
849
+ || agent_board_test.trail_guard()
850
+ $$;
851
+
939
852
  create or replace function agent_board_test.qname_frag(p_name text) returns text
940
853
  language sql immutable set search_path to pg_catalog as $$
941
- select agent_board_test.lead_guard() || '(?:' || agent_board_test.ident_re('agent_board')
942
- || '\s*\.\s*)?' || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard()
854
+ select agent_board_test.qname_frag(p_name, true)
855
+ $$;
856
+
857
+ create or replace function agent_board_test.call_frag(p_name text, p_bare_ok boolean) returns text
858
+ language sql immutable set search_path to pg_catalog as $$
859
+ select agent_board_test.lead_guard()
860
+ || '(?:(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*)?'
861
+ || agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 1) else 'agent_board' end)
862
+ || '\s*\.\s*)' || case when p_bare_ok then '?' else '' end
863
+ || agent_board_test.ident_re(case when p_name like '%.%' then split_part(p_name, '.', 2) else p_name end)
864
+ || '\s*\('
943
865
  $$;
944
866
 
945
867
  create or replace function agent_board_test.call_frag(p_name text) returns text
946
868
  language sql immutable set search_path to pg_catalog as $$
947
- select agent_board_test.lead_guard() || '(?:' || agent_board_test.ident_re('agent_board')
948
- || '\s*\.\s*)?' || agent_board_test.ident_re(p_name) || '\s*\('
869
+ select agent_board_test.call_frag(p_name, true)
949
870
  $$;
950
871
 
951
872
  -- THE (?c) DIRECTOR IS THE ENFORCEMENT, not the comment above it. A caller that passes 'i' to
@@ -958,21 +879,26 @@ $$;
958
879
  -- caller who prefixes anything gets `invalid regular expression` rather than a silent fold.
959
880
  --
960
881
  -- EXACTLY WHICH CALLERS THE DIRECTOR PROTECTS, stated narrowly because the first version of this
961
- -- paragraph claimed more: a pattern that BEGINS with qname_re/call_re/stmt_re is protected against a
962
- -- passed 'i' flag AND against the `~*` operator, because the director is inside the pattern. A
963
- -- caller that composes a *_frag directly is NOT protected — the frags carry no director by design,
964
- -- since one mid-pattern is a syntax error. They exist for embedding inside the _re matchers, and
965
- -- EXECUTE on them is revoked below so they are not casually reachable; a test author who calls a
966
- -- frag with `~*` deliberately is outside what any mechanism here can prevent, and that residue is
967
- -- named rather than papered over.
882
+ -- paragraph claimed more: a pattern that BEGINS with a `*_re` is protected against a passed 'i'
883
+ -- flag AND against the `~*` operator, because the director is inside the pattern. A caller that
884
+ -- composes a *_frag directly is NOT protected — the frags carry no director by design, since one
885
+ -- mid-pattern is a syntax error. They exist for embedding inside the _re matchers and inside
886
+ -- agent_board_test.re(), and EXECUTE on them is revoked below so they are not casually reachable;
887
+ -- a test author who calls a frag with `~*` deliberately is outside what any mechanism here can
888
+ -- prevent, and that residue is named rather than papered over.
968
889
  create or replace function agent_board_test.qname_re(p_name text) returns text
969
890
  language sql immutable set search_path to pg_catalog as $$
970
891
  select '(?c)' || agent_board_test.qname_frag(p_name)
971
892
  $$;
972
893
 
894
+ create or replace function agent_board_test.call_re(p_name text, p_bare_ok boolean) returns text
895
+ language sql immutable set search_path to pg_catalog as $$
896
+ select '(?c)' || agent_board_test.call_frag(p_name, p_bare_ok)
897
+ $$;
898
+
973
899
  create or replace function agent_board_test.call_re(p_name text) returns text
974
900
  language sql immutable set search_path to pg_catalog as $$
975
- select '(?c)' || agent_board_test.call_frag(p_name)
901
+ select agent_board_test.call_re(p_name, true)
976
902
  $$;
977
903
 
978
904
  -- A COLUMN token. Unquoted identifiers fold, so `SET CLAIM_POLICY = …` names the same column as
@@ -983,22 +909,52 @@ language sql immutable set search_path to pg_catalog as $$
983
909
  select agent_board_test.lead_guard() || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard()
984
910
  $$;
985
911
 
986
- create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text) returns text
912
+ -- A COLUMN REFERENCE, qualified or bare: `bm.membership_state` as well as `membership_state`.
913
+ -- col_frag is the SET-list form and its lead guard rejects a preceding dot ON PURPOSE (a dotted
914
+ -- name in call or relation position is another schema's object). A column READ — a predicate, an
915
+ -- ORDER BY operand, a SELECT item — is qualified as often as not, and `col_frag` alone dropped
916
+ -- `c.resource_key` writers out of a class set (measured: two of them). The qualified arm accepts
917
+ -- any identifier before the dot, since the qualifier is an alias that names nothing of ours; a
918
+ -- non-column token that happens to sit after a dot (`x.effective_at(`) is admitted the way the
919
+ -- hand-rolled `\meffective_at\M` admitted it. Director-free (Consiliency/message-board#417 PR 3).
920
+ create or replace function agent_board_test.col_ref_frag(p_name text) returns text
987
921
  language sql immutable set search_path to pg_catalog as $$
988
- -- "a statement whose verb is one of p_verbs and whose target is agent_board.p_rel".
989
- -- SPACES INSIDE A MULTIWORD VERB BECOME `\s+`: `INSERT\nINTO` and `DELETE FROM` are the same
990
- -- statement to PostgreSQL. The hand-rolled expressions this consolidated used `\s+` and got that
991
- -- right; the first shared version made the space literal and LOST a property the scattered
992
- -- versions had — a consolidation is only safe if it is at least the union of what it replaces.
993
- select '(?c)(?:' || (select string_agg(agent_board_test.ci_re(v), '|')
994
- from regexp_split_to_table(p_verbs, '\|') as v)
922
+ select '(?:' || agent_board_test.ident_any_frag() || '\s*\.\s*' || agent_board_test.ident_re(p_name) || agent_board_test.trail_guard()
923
+ || '|' || agent_board_test.col_frag(p_name) || ')'
924
+ $$;
925
+
926
+ -- "a statement whose verb is one of p_verbs and whose target is agent_board.p_rel".
927
+ -- SPACES INSIDE A MULTIWORD VERB BECOME `\s+`: `INSERT\nINTO` and `DELETE FROM` are the same
928
+ -- statement to PostgreSQL. The hand-rolled expressions this consolidated used `\s+` and got that
929
+ -- right; the first shared version made the space literal and LOST a property the scattered
930
+ -- versions had — a consolidation is only safe if it is at least the union of what it replaces.
931
+ -- Director-free, so it can sit inside an alternation; stmt_re is `'(?c)' || stmt_frag`.
932
+ -- THE VERB STARTS A WORD (`\m`): without it `xupdate agent_board.boards set claim_policy`
933
+ -- matched col_write_re — an identifier ending in a verb read as the verb. Found by the codex seat
934
+ -- on Consiliency/message-board#435 round 1; pinned by CPR-0 `r65-verb-prefix-word` and the
935
+ -- CPR-0a rule 'stmt_frag(core): the verb starts a word'.
936
+ create or replace function agent_board_test.stmt_frag(p_verbs text, p_rel text, p_bare_ok boolean) returns text
937
+ language sql immutable set search_path to pg_catalog as $$
938
+ select '\m(?:' || (select string_agg(agent_board_test.ci_re(v), '|')
939
+ from regexp_split_to_table(p_verbs, '\|') as v)
995
940
  || ')\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
996
- || agent_board_test.qname_frag(p_rel)
941
+ || agent_board_test.qname_frag(p_rel, p_bare_ok)
997
942
  $$;
998
943
 
999
- revoke all on function agent_board_test.qname_frag(text) from public;
1000
- revoke all on function agent_board_test.call_frag(text) from public;
1001
- revoke all on function agent_board_test.col_frag(text) from public;
944
+ create or replace function agent_board_test.stmt_frag(p_verbs text, p_rel text) returns text
945
+ language sql immutable set search_path to pg_catalog as $$
946
+ select agent_board_test.stmt_frag(p_verbs, p_rel, true)
947
+ $$;
948
+
949
+ create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text, p_bare_ok boolean) returns text
950
+ language sql immutable set search_path to pg_catalog as $$
951
+ select '(?c)' || agent_board_test.stmt_frag(p_verbs, p_rel, p_bare_ok)
952
+ $$;
953
+
954
+ create or replace function agent_board_test.stmt_re(p_verbs text, p_rel text) returns text
955
+ language sql immutable set search_path to pg_catalog as $$
956
+ select agent_board_test.stmt_re(p_verbs, p_rel, true)
957
+ $$;
1002
958
 
1003
959
  -- THE INSERT-WITHOUT-COLUMN-LIST RULE, AS A NAMED OBJECT. It began as an expression inlined in
1004
960
  -- CPR-6, and CPR-0's rows for it RECONSTRUCTED that expression instead of calling it — so the rows
@@ -1018,16 +974,11 @@ revoke all on function agent_board_test.col_frag(text) from public;
1018
974
  -- SPLIT INTO frag + re, matching qname/call/col, because CPR-6 embeds this rule INSIDE a larger
1019
975
  -- alternation and a director is only legal at position 0. It used to embed
1020
976
  -- `replace(insert_all_columns_re(...), '(?c)', '')` — so CPR-0's rows compiled X while production
1021
- -- compiled a TEXTUAL TRANSFORMATION of X. Behaviourally identical today (CPR-6 matches with `~`,
1022
- -- which is case-sensitive anyway, so the director is belt-and-braces there), but the rows would have
1023
- -- stayed green if the rule ever became director-dependent, or if a `(?c)` appeared anywhere but
1024
- -- position 0 and the replace stripped it silently. Two strings are not one object. Now CPR-6 embeds
1025
- -- the frag and CPR-0's rows exercise the frag: the same object, no transformation at any call site.
1026
- create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
977
+ -- compiled a TEXTUAL TRANSFORMATION of X. Two strings are not one object. Now the frag is embedded
978
+ -- and CPR-0's rows exercise the frag: the same object, no transformation at any call site.
979
+ create or replace function agent_board_test.insert_all_columns_frag(p_rel text, p_bare_ok boolean) returns text
1027
980
  language sql immutable set search_path to pg_catalog as $$
1028
- select agent_board_test.ci_re('insert') || '\s+' || agent_board_test.ci_re('into')
1029
- || '\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
1030
- || agent_board_test.qname_frag(p_rel)
981
+ select agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok)
1031
982
  || '(?!(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?(?!'
1032
983
  -- ONLY VALUES AND SELECT. `default` and `overriding` were excluded here too and were DEAD
1033
984
  -- CODE: an exclusion only matters if the keyword can be followed by `\s*\(` and so be mistaken
@@ -1036,7 +987,12 @@ language sql immutable set search_path to pg_catalog as $$
1036
987
  -- not. Removed rather than kept with rows that cannot gate them.
1037
988
  || agent_board_test.ci_re('values') || '(?![A-Za-z0-9_$])|'
1038
989
  || agent_board_test.ci_re('select') || '(?![A-Za-z0-9_$]))'
1039
- || '(?:"[^"]*"|[A-Za-z_][A-Za-z0-9_$]*))?\s*\()'
990
+ || agent_board_test.ident_any_frag() || ')?\s*\()'
991
+ $$;
992
+
993
+ create or replace function agent_board_test.insert_all_columns_frag(p_rel text) returns text
994
+ language sql immutable set search_path to pg_catalog as $$
995
+ select agent_board_test.insert_all_columns_frag(p_rel, true)
1040
996
  $$;
1041
997
 
1042
998
  create or replace function agent_board_test.insert_all_columns_re(p_rel text) returns text
@@ -1044,4 +1000,455 @@ language sql immutable set search_path to pg_catalog as $$
1044
1000
  select '(?c)' || agent_board_test.insert_all_columns_frag(p_rel)
1045
1001
  $$;
1046
1002
 
1003
+ -- THE MERGE TWIN: `merge into <rel> … then insert values|default values|overriding …` with no
1004
+ -- column list writes every column. A `when not matched [by target]` prefix is not required — a
1005
+ -- valid MERGE can carry `then insert` under no other arm, so requiring it could only under-match.
1006
+ -- The join after `insert` is `\s*`, not a statement span: with a span, a column list would be
1007
+ -- skipped and `then insert (slug) values (1)` credited as an all-column write.
1008
+ create or replace function agent_board_test.merge_insert_all_columns_frag(p_rel text, p_bare_ok boolean) returns text
1009
+ language sql immutable set search_path to pg_catalog as $$
1010
+ select agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
1011
+ || '\m' || agent_board_test.ci_re('then') || '\s+' || agent_board_test.ci_re('insert')
1012
+ || '\s*(?:' || agent_board_test.ci_re('default values') || '|' || agent_board_test.ci_re('values')
1013
+ || '|' || agent_board_test.ci_re('overriding') || ')(?![A-Za-z0-9_$])'
1014
+ $$;
1015
+
1016
+ create or replace function agent_board_test.merge_insert_all_columns_frag(p_rel text) returns text
1017
+ language sql immutable set search_path to pg_catalog as $$
1018
+ select agent_board_test.merge_insert_all_columns_frag(p_rel, true)
1019
+ $$;
1020
+
1021
+ -- EVERY STATEMENT SHAPE THAT WRITES p_rel, as one group of per-verb arms:
1022
+ -- * `insert into | merge into | update | delete from <rel>`;
1023
+ -- * TRUNCATE with a relation LIST (`truncate agent_board.claims, agent_board.boards` writes
1024
+ -- boards) and the optional `table` / `only` words;
1025
+ -- * COPY … FROM, with the optional column list consumed BEFORE the direction word (`copy rel
1026
+ -- (cols) from` continues with a column list, not `\s+from`); `copy … to` is a read;
1027
+ -- * SELECT … INTO [temp|unlogged] [table] <rel> — a table created by the statement. The relation
1028
+ -- must sit at the INTO target: `select … into v_var from <rel>` is a read of <rel>.
1029
+ -- This is the union. A caller that must keep verb KINDS apart (`lock_edges`, whose edges are named
1030
+ -- by kind) calls stmt_frag(<one verb>, rel, bare_ok) per kind, never this.
1031
+ create or replace function agent_board_test.write_frag(p_rel text, p_bare_ok boolean) returns text
1032
+ language sql immutable set search_path to pg_catalog as $$
1033
+ select '(?:' || agent_board_test.stmt_frag('insert into|merge into|update|delete from', p_rel, p_bare_ok)
1034
+ || '|' || agent_board_test.lead_guard() || agent_board_test.ci_re('truncate')
1035
+ || '(?:\s+' || agent_board_test.ci_re('table') || ')?\s+(?:' || agent_board_test.ci_re('only') || '\s+)?'
1036
+ || '(?:[A-Za-z_"][A-Za-z0-9_."]*\s*\*?\s*,\s*(?:' || agent_board_test.ci_re('only') || '\s+)?)*'
1037
+ || agent_board_test.qname_frag(p_rel, p_bare_ok)
1038
+ || '|' || agent_board_test.stmt_frag('copy', p_rel, p_bare_ok)
1039
+ || '\s*(?:\([^)]*\))?\s*' || agent_board_test.ci_re('from') || agent_board_test.trail_guard()
1040
+ || '|\m' || agent_board_test.ci_re('select') || '\M' || agent_board_test.same_stmt()
1041
+ || '\m' || agent_board_test.ci_re('into') || '\s+(?:(?:' || agent_board_test.ci_re('temporary')
1042
+ || '|' || agent_board_test.ci_re('temp') || '|' || agent_board_test.ci_re('unlogged') || ')\s+)?'
1043
+ || '(?:' || agent_board_test.ci_re('table') || '\s+)?' || agent_board_test.qname_frag(p_rel, p_bare_ok)
1044
+ || ')'
1045
+ $$;
1046
+
1047
+ create or replace function agent_board_test.write_frag(p_rel text) returns text
1048
+ language sql immutable set search_path to pg_catalog as $$
1049
+ select agent_board_test.write_frag(p_rel, true)
1050
+ $$;
1051
+
1052
+ create or replace function agent_board_test.write_re(p_rel text) returns text
1053
+ language sql immutable set search_path to pg_catalog as $$
1054
+ select '(?c)' || agent_board_test.write_frag(p_rel)
1055
+ $$;
1056
+
1057
+ -- The union of write_frag over a relation list — one helper call at a site, never a string_agg of
1058
+ -- `*_re` outputs there. An EMPTY list is refused rather than returned as a pattern that matches
1059
+ -- nothing: a class assertion handed an empty class would otherwise read "no writer" as clean.
1060
+ create or replace function agent_board_test.writes_any_frag(p_rels text[], p_bare_ok boolean) returns text
1061
+ language plpgsql immutable set search_path to pg_catalog as $$
1062
+ begin
1063
+ if p_rels is null or cardinality(p_rels) = 0 then
1064
+ raise exception 'writes_any_frag: the relation list is empty';
1065
+ end if;
1066
+ return '(?:' || (select string_agg(agent_board_test.write_frag(r, p_bare_ok), '|') from unnest(p_rels) as r) || ')';
1067
+ end $$;
1068
+
1069
+ create or replace function agent_board_test.writes_any_frag(p_rels text[]) returns text
1070
+ language sql immutable set search_path to pg_catalog as $$
1071
+ select agent_board_test.writes_any_frag(p_rels, true)
1072
+ $$;
1073
+
1074
+ create or replace function agent_board_test.writes_any_re(p_rels text[]) returns text
1075
+ language sql immutable set search_path to pg_catalog as $$
1076
+ select '(?c)' || agent_board_test.writes_any_frag(p_rels)
1077
+ $$;
1078
+
1079
+ -- An EXPLICIT locking clause — `for update`, `for no key update`, `for share`, `for key share` —
1080
+ -- as a fragment, and `row_lock_re(rel)`: that clause in the SAME STATEMENT as a reference to
1081
+ -- <rel>, stepping over literals (`same_stmt()`), so a `'for update'` inside a string is not a lock
1082
+ -- and a lock in the NEXT statement is not this relation's. Five sites carried five hand copies of
1083
+ -- the clause before this existed (CPR-14, CPR-14a, ledger_authority:963, signer_lifecycle:702);
1084
+ -- one copy here is the object each of them now names.
1085
+ create or replace function agent_board_test.row_lock_frag() returns text
1086
+ language sql immutable set search_path to pg_catalog as $$
1087
+ select '\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('no key') || '\s+)?(?:'
1088
+ || agent_board_test.ci_re('update') || '|' || agent_board_test.ci_re('share') || '|' || agent_board_test.ci_re('key share') || ')'
1089
+ || agent_board_test.trail_guard()
1090
+ $$;
1091
+
1092
+ create or replace function agent_board_test.row_lock_re(p_rel text) returns text
1093
+ language sql immutable set search_path to pg_catalog as $$
1094
+ select '(?c)' || agent_board_test.qname_frag(p_rel) || agent_board_test.same_stmt() || agent_board_test.row_lock_frag()
1095
+ $$;
1096
+
1097
+ -- A statement that ASSIGNS <rel>.<col>, POSITIONALLY — the column sits where the grammar puts an
1098
+ -- assignment target. The first shared draft was `stmt_re || '[^;]*' || col_frag`, which made
1099
+ -- `update boards set slug = x where claim_policy = y` a writer of claim_policy; CPR-6 read exactly
1100
+ -- that way, so consolidating onto it would have consolidated onto an over-match. Three arms:
1101
+ -- (1) `update <rel> … set <run> col =` — a scalar SET target;
1102
+ -- (2) `update <rel> … set <run> (…, col, …) =` — the row form, reachable after scalar targets
1103
+ -- (`set updated_at = now(), (slug, claim_policy)
1104
+ -- = (…)`);
1105
+ -- (3) `merge into <rel> … then update set <run> col =` — the phrase only a MERGE WHEN clause can
1106
+ -- contain, so `when matched AND <pred> then update set` and PG17's `when not matched by
1107
+ -- source then update set` are both accepted;
1108
+ -- (4) `insert into <rel> … do update set <run> col =` — the ON CONFLICT upsert arm. Missing from
1109
+ -- the first three-arm version; found by the codex seat on Consiliency/message-board#435
1110
+ -- round 2 (`insert into boards(id) values (1) on conflict (id) do update set claim_policy =
1111
+ -- 'advisory'` matched no arm). Thirteen live functions upsert, three of them onto FK columns
1112
+ -- (`_upsert_inbox_item.board_id`, `register_fleet_machine.trust_domain_id`,
1113
+ -- `heartbeat.thread_id`), so this arm changes lock_edges' fk-update set — see FKL-9.
1114
+ -- <run> is set_run() with that statement's terminators. This is the arm set lock_edges uses for
1115
+ -- "an UPDATE re-points an FK column", which is why it is a named object of its own: an INSERT
1116
+ -- naming the column is a different edge kind there (fk-insert), already derived from the INSERT.
1117
+ create or replace function agent_board_test.col_assign_frag(p_rel text, p_col text, p_bare_ok boolean) returns text
1118
+ language sql immutable set search_path to pg_catalog as $$
1119
+ select '(?:' || agent_board_test.stmt_frag('update', p_rel, p_bare_ok) || agent_board_test.same_stmt()
1120
+ || '\m' || agent_board_test.ci_re('set') || '\M' || agent_board_test.set_run('where|from|returning')
1121
+ || '(?:' || agent_board_test.col_frag(p_col) || '|\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\))\s*='
1122
+ || '|' || agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
1123
+ || '\m' || agent_board_test.ci_re('then update set') || '\M'
1124
+ || agent_board_test.set_run('when\s+(?:not\s+)?matched|returning')
1125
+ || agent_board_test.col_frag(p_col) || '\s*='
1126
+ || '|' || agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
1127
+ || '\m' || agent_board_test.ci_re('do update set') || '\M' || agent_board_test.set_run('where|returning')
1128
+ || '(?:' || agent_board_test.col_frag(p_col) || '|\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\))\s*='
1129
+ || ')'
1130
+ $$;
1131
+
1132
+ -- Every statement shape that writes <rel>.<col>: the assignment arms above plus
1133
+ -- (4) `insert into <rel> [alias] (…, col, …)` — an INSERT column list;
1134
+ -- (5) `merge into <rel> … then insert (…, col, …)` — a MERGE-insert column list;
1135
+ -- (6) an INSERT with no column list (every column) — insert_all_columns_frag;
1136
+ -- (7) a MERGE-insert with no column list — merge_insert_all_columns_frag;
1137
+ -- (8) `copy <rel> [(…, col, …)] from` — a COPY FROM whose list names col or is absent;
1138
+ -- (9) `select … into [temp] [table] <rel>` — a table the statement creates, every column.
1139
+ -- Arm (4)'s alias is any identifier token, without the VALUES/SELECT keyword exclusion that
1140
+ -- insert_all_columns_frag needs: the only sentences the exclusion would change here (`insert into
1141
+ -- rel values (…col…)`) are all-column writes that arm (6) already accepts, so an exclusion here
1142
+ -- could flip no row and would be an unpinned rule.
1143
+ create or replace function agent_board_test.col_write_frag(p_rel text, p_col text, p_bare_ok boolean) returns text
1144
+ language sql immutable set search_path to pg_catalog as $$
1145
+ select '(?:' || agent_board_test.col_assign_frag(p_rel, p_col, p_bare_ok)
1146
+ || '|' || agent_board_test.stmt_frag('insert into', p_rel, p_bare_ok)
1147
+ || '(?:\s+(?:' || agent_board_test.ci_re('as') || '\s+)?' || agent_board_test.ident_any_frag() || ')?'
1148
+ || '\s*\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\)'
1149
+ || '|' || agent_board_test.stmt_frag('merge into', p_rel, p_bare_ok) || agent_board_test.same_stmt()
1150
+ || '\m' || agent_board_test.ci_re('then insert') || '\s*\([^)]*' || agent_board_test.col_frag(p_col) || '[^)]*\)'
1151
+ || '|' || agent_board_test.insert_all_columns_frag(p_rel, p_bare_ok)
1152
+ || '|' || agent_board_test.merge_insert_all_columns_frag(p_rel, p_bare_ok)
1153
+ || '|' || agent_board_test.stmt_frag('copy', p_rel, p_bare_ok)
1154
+ || '\s*(?:\((?:[^)]*' || agent_board_test.col_frag(p_col) || '[^)]*)\))?\s*'
1155
+ || agent_board_test.ci_re('from') || agent_board_test.trail_guard()
1156
+ || '|\m' || agent_board_test.ci_re('select') || '\M' || agent_board_test.same_stmt()
1157
+ || '\m' || agent_board_test.ci_re('into') || '\s+(?:(?:' || agent_board_test.ci_re('temporary')
1158
+ || '|' || agent_board_test.ci_re('temp') || '|' || agent_board_test.ci_re('unlogged') || ')\s+)?'
1159
+ || '(?:' || agent_board_test.ci_re('table') || '\s+)?' || agent_board_test.qname_frag(p_rel, p_bare_ok)
1160
+ || ')'
1161
+ $$;
1162
+
1163
+ create or replace function agent_board_test.col_write_frag(p_rel text, p_col text) returns text
1164
+ language sql immutable set search_path to pg_catalog as $$
1165
+ select agent_board_test.col_write_frag(p_rel, p_col, true)
1166
+ $$;
1167
+
1168
+ create or replace function agent_board_test.col_write_re(p_rel text, p_col text) returns text
1169
+ language sql immutable set search_path to pg_catalog as $$
1170
+ select '(?c)' || agent_board_test.col_write_frag(p_rel, p_col)
1171
+ $$;
1172
+
1173
+ -- Resolve BARE names the way PostgreSQL would for THIS function. A function pinning search_path
1174
+ -- WITHOUT agent_board cannot reach our objects unqualified, so bare matches are not attributed to
1175
+ -- it. No search_path setting at all is treated as bare-resolving, which over-approximates
1176
+ -- membership rather than under-approximating it. One definition, read by lock_edges AND
1177
+ -- fn_call_closure — it used to be two copies. Returns boolean, so it is outside the text-returning
1178
+ -- family CPR-0b gates, by construction.
1179
+ create or replace function agent_board_test.bare_ok(p_fn oid) returns boolean
1180
+ language sql stable set search_path to pg_catalog as $$
1181
+ select not exists (select 1 from pg_proc p, unnest(coalesce(p.proconfig, '{}')) c
1182
+ where p.oid = p_fn and c like 'search\_path=%')
1183
+ or exists (select 1 from pg_proc p, unnest(p.proconfig) c
1184
+ where p.oid = p_fn and c like 'search\_path=%'
1185
+ and ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
1186
+ like '%,agent\_board,%')
1187
+ $$;
1188
+
1189
+ revoke all on function agent_board_test.ident_any_frag() from public;
1190
+ revoke all on function agent_board_test.same_stmt() from public;
1191
+ revoke all on function agent_board_test.set_run(text) from public;
1192
+ revoke all on function agent_board_test.qname_frag(text) from public;
1193
+ revoke all on function agent_board_test.qname_frag(text, boolean) from public;
1194
+ revoke all on function agent_board_test.call_frag(text) from public;
1195
+ revoke all on function agent_board_test.call_frag(text, boolean) from public;
1196
+ revoke all on function agent_board_test.col_frag(text) from public;
1197
+ revoke all on function agent_board_test.col_ref_frag(text) from public;
1198
+ revoke all on function agent_board_test.stmt_frag(text, text) from public;
1199
+ revoke all on function agent_board_test.stmt_frag(text, text, boolean) from public;
1047
1200
  revoke all on function agent_board_test.insert_all_columns_frag(text) from public;
1201
+ revoke all on function agent_board_test.insert_all_columns_frag(text, boolean) from public;
1202
+ revoke all on function agent_board_test.merge_insert_all_columns_frag(text) from public;
1203
+ revoke all on function agent_board_test.merge_insert_all_columns_frag(text, boolean) from public;
1204
+ revoke all on function agent_board_test.write_frag(text) from public;
1205
+ revoke all on function agent_board_test.write_frag(text, boolean) from public;
1206
+ revoke all on function agent_board_test.writes_any_frag(text[]) from public;
1207
+ revoke all on function agent_board_test.writes_any_frag(text[], boolean) from public;
1208
+ revoke all on function agent_board_test.col_assign_frag(text, text, boolean) from public;
1209
+ revoke all on function agent_board_test.col_write_frag(text, text) from public;
1210
+ revoke all on function agent_board_test.col_write_frag(text, text, boolean) from public;
1211
+ revoke all on function agent_board_test.row_lock_frag() from public;
1212
+
1213
+ -- One row per (function, relation, acquisition). `own` distinguishes an
1214
+ -- acquisition written in the function's own body from one reached through a
1215
+ -- callee; `origin_name` names the function the acquisition actually lives in.
1216
+ --
1217
+ -- Every textual arm is a matcher from the family above, PER KIND (the expected strings in
1218
+ -- fk_lock_edges / task_thread_lock_order pin the kind of every edge by name, so the kinds must
1219
+ -- stay distinguishable): `stmt_frag('insert into'|'update'|'delete from', rel, bare_ok)`,
1220
+ -- `col_assign_frag(rel, fk_col, bare_ok)` for the FK re-point, `qname_frag || same_stmt() ||
1221
+ -- <lock clause>` for an explicit lock, `call_frag(proname, bare_ok)` for a call edge. The bounded
1222
+ -- `[^;]{0,250}?` spans became same_stmt(). MERGE is not modelled here (no agent_board body uses
1223
+ -- it; a MERGE update/insert would be an unrecorded write edge — stated, not closed).
1224
+ CREATE OR REPLACE FUNCTION agent_board_test.lock_edges()
1225
+ RETURNS TABLE (
1226
+ fn_oid oid,
1227
+ fn_name text,
1228
+ fn_sig text,
1229
+ rel_oid oid,
1230
+ rel_name text,
1231
+ lock_mode text,
1232
+ via text,
1233
+ path int[],
1234
+ own boolean,
1235
+ origin_oid oid,
1236
+ origin_name text
1237
+ )
1238
+ LANGUAGE sql STABLE
1239
+ SET search_path = pg_catalog
1240
+ AS $lockedges$
1241
+ WITH RECURSIVE
1242
+ fns AS (
1243
+ SELECT p.oid,
1244
+ p.proname,
1245
+ p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' AS sig,
1246
+ agent_board_test.stripped_body(p.oid) AS body,
1247
+ agent_board_test.bare_ok(p.oid) AS bare
1248
+ FROM pg_proc p
1249
+ JOIN pg_namespace n ON n.oid = p.pronamespace
1250
+ WHERE n.nspname = 'agent_board'
1251
+ AND p.prokind IN ('f', 'p')
1252
+ AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
1253
+ ),
1254
+ rels AS (
1255
+ SELECT c.oid AS rel_oid, c.relname, n.nspname,
1256
+ -- the matcher's `[schema.]name`: agent_board is the default schema, anything else is
1257
+ -- named explicitly and can only be reached qualified
1258
+ CASE WHEN n.nspname = 'agent_board' THEN c.relname ELSE n.nspname || '.' || c.relname END AS mname
1259
+ FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
1260
+ WHERE c.relkind IN ('r', 'p')
1261
+ AND (n.nspname = 'agent_board' OR (n.nspname = 'auth' AND c.relname = 'users'))
1262
+ ),
1263
+ -- Prefilter: a function can only touch a relation whose name its body contains. A case-folded
1264
+ -- substring test is looser than every arm it guards (each arm's match contains the name in some
1265
+ -- case), so it can only save work, never a match. MATERIALIZED so the planner cannot hoist the
1266
+ -- expensive matchers above it — without that, every (function, relation) pair ran the full
1267
+ -- pattern and lock_edges() took minutes instead of seconds.
1268
+ cand AS MATERIALIZED (
1269
+ SELECT f.oid AS fn_oid, f.body, r.rel_oid, r.relname, r.mname,
1270
+ (f.bare AND r.nspname = 'agent_board') AS rb
1271
+ FROM fns f CROSS JOIN rels r
1272
+ WHERE strpos(lower(f.body), lower(r.relname)) > 0
1273
+ ),
1274
+ lit AS MATERIALIZED (
1275
+ SELECT c.fn_oid, c.body, c.rel_oid, c.relname, c.mname, c.rb,
1276
+ substring(c.body FROM agent_board_test.re(
1277
+ agent_board_test.stmt_frag('insert into', c.mname, c.rb))) AS ins_hit,
1278
+ substring(c.body FROM agent_board_test.re(
1279
+ agent_board_test.stmt_frag('update', c.mname, c.rb) || agent_board_test.same_stmt()
1280
+ || '\m' || agent_board_test.ci_re('set') || '\M')) AS upd_hit,
1281
+ substring(c.body FROM agent_board_test.re(
1282
+ agent_board_test.stmt_frag('delete from', c.mname, c.rb))) AS del_hit,
1283
+ substring(c.body FROM agent_board_test.re(
1284
+ agent_board_test.qname_frag(c.mname, c.rb) || agent_board_test.same_stmt()
1285
+ || '\m' || agent_board_test.ci_re('for') || '\s+(?:' || agent_board_test.ci_re('update')
1286
+ || '|' || agent_board_test.ci_re('no key update') || '|' || agent_board_test.ci_re('share')
1287
+ || '|' || agent_board_test.ci_re('key share') || ')' || agent_board_test.trail_guard())) AS lock_hit
1288
+ FROM cand c
1289
+ ),
1290
+ -- FOR UPDATE OF <alias, ...> locks only the listed relations. The lock arm above matches every
1291
+ -- relation named earlier in the statement, so an OF list has to be read back: the relation's
1292
+ -- alias (the identifier after its name, or the bare name when it has none) must appear in it.
1293
+ -- Without this, `from a join b ... for update of a` fabricated a lock on b — and the fabricated
1294
+ -- edge reached FKL-10 as an opposite-order pair that no transaction can hold.
1295
+ lock_scope AS (
1296
+ SELECT l.*,
1297
+ lower(substring(l.lock_hit FROM '(?i)^' || agent_board_test.qname_frag(l.mname, l.rb)
1298
+ || '\s+(?:as\s+)?([A-Za-z_][A-Za-z0-9_$]*)')) AS rel_alias,
1299
+ (SELECT array_agg(lower(trim(both '"' from x)))
1300
+ FROM regexp_split_to_table(
1301
+ substring(substr(l.body, strpos(l.body, l.lock_hit) + length(l.lock_hit))
1302
+ FROM '(?i)^\s+of\s+((?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]*")(?:\s*,\s*(?:[A-Za-z_][A-Za-z0-9_$]*|"[^"]*"))*)'),
1303
+ '\s*,\s*') AS x) AS of_list
1304
+ FROM lit l
1305
+ WHERE l.lock_hit IS NOT NULL
1306
+ ),
1307
+ -- FK columns by NAME, from pg_attribute over conkey. Shape (v) is gated on the
1308
+ -- SET naming one of these; hand-listing them, or skipping the gate, is what
1309
+ -- turns this detector into a cycle-fabricator.
1310
+ fkcols AS (
1311
+ SELECT k.conname, k.conrelid, k.confrelid, a.attname
1312
+ FROM pg_constraint k
1313
+ CROSS JOIN LATERAL unnest(k.conkey) AS ck(attnum)
1314
+ JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = ck.attnum
1315
+ WHERE k.contype = 'f'
1316
+ ),
1317
+ -- Same prefilter shape as `cand`: the column name must appear before the assignment matcher runs.
1318
+ updcand AS MATERIALIZED (
1319
+ SELECT l.fn_oid, l.body, l.mname, l.rb, fc.conname, fc.confrelid, fc.attname
1320
+ FROM lit l
1321
+ JOIN fkcols fc ON fc.conrelid = l.rel_oid
1322
+ WHERE strpos(lower(l.body), lower(fc.attname)) > 0
1323
+ ),
1324
+ upd AS (
1325
+ SELECT u.fn_oid, u.conname, u.confrelid, min(strpos(u.body, h.hit)) AS pos
1326
+ FROM updcand u
1327
+ CROSS JOIN LATERAL (SELECT substring(u.body FROM agent_board_test.re(
1328
+ agent_board_test.col_assign_frag(u.mname, u.attname, u.rb))) AS hit) h
1329
+ WHERE h.hit IS NOT NULL
1330
+ GROUP BY u.fn_oid, u.conname, u.confrelid
1331
+ ),
1332
+ direct AS (
1333
+ -- (i) an explicit row lock written in the body
1334
+ SELECT l.fn_oid, l.rel_oid,
1335
+ lower(regexp_replace(substring(l.lock_hit FROM
1336
+ '(?i)\mfor\s+(update|no\s+key\s+update|share|key\s+share)\M'), '\s+', ' ', 'g')) AS lock_mode,
1337
+ 'textual' AS via,
1338
+ strpos(l.body, l.lock_hit) AS pos
1339
+ FROM lock_scope l
1340
+ WHERE l.of_list IS NULL
1341
+ OR l.rel_alias = ANY (l.of_list)
1342
+ OR lower(l.relname) = ANY (l.of_list)
1343
+ UNION ALL
1344
+ -- (ii) UPDATE row-locks the updated rows
1345
+ SELECT l.fn_oid, l.rel_oid, 'update', 'write-update', strpos(l.body, l.upd_hit)
1346
+ FROM lit l WHERE l.upd_hit IS NOT NULL
1347
+ UNION ALL
1348
+ -- (iii) DELETE row-locks the deleted rows
1349
+ SELECT l.fn_oid, l.rel_oid, 'update', 'write-delete', strpos(l.body, l.del_hit)
1350
+ FROM lit l WHERE l.del_hit IS NOT NULL
1351
+ UNION ALL
1352
+ -- (iv) INSERT takes FOR KEY SHARE on every referenced parent row
1353
+ SELECT l.fn_oid, k.confrelid, 'key share', 'fk-insert:' || k.conname, strpos(l.body, l.ins_hit)
1354
+ FROM lit l
1355
+ JOIN pg_constraint k ON k.conrelid = l.rel_oid AND k.contype = 'f'
1356
+ WHERE l.ins_hit IS NOT NULL
1357
+ UNION ALL
1358
+ -- (v) an UPDATE that re-points an FK column locks the NEW parent row
1359
+ SELECT u.fn_oid, u.confrelid, 'key share', 'fk-update:' || u.conname, u.pos
1360
+ FROM upd u
1361
+ UNION ALL
1362
+ -- (vi) DELETE of a referenced row locks the referencing child rows
1363
+ SELECT l.fn_oid, k.conrelid,
1364
+ CASE k.confdeltype WHEN 'c' THEN 'update'
1365
+ WHEN 'n' THEN 'no key update'
1366
+ WHEN 'd' THEN 'no key update'
1367
+ ELSE 'key share' END,
1368
+ 'fk-delete:' || k.conname, strpos(l.body, l.del_hit)
1369
+ FROM lit l
1370
+ JOIN pg_constraint k ON k.confrelid = l.rel_oid AND k.contype = 'f'
1371
+ WHERE l.del_hit IS NOT NULL
1372
+ ),
1373
+ -- One pattern per (callee, qualification) rather than one per pair, and the same name-substring
1374
+ -- prefilter before the matcher runs.
1375
+ pats AS MATERIALIZED (
1376
+ SELECT oid, proname,
1377
+ agent_board_test.call_re(proname, true) AS pat_bare,
1378
+ agent_board_test.call_re(proname, false) AS pat_qualified
1379
+ FROM fns
1380
+ ),
1381
+ callcand AS MATERIALIZED (
1382
+ SELECT c.oid AS caller, c.body, e.oid AS callee, e.proname AS callee_name,
1383
+ CASE WHEN c.bare THEN e.pat_bare ELSE e.pat_qualified END AS pat
1384
+ FROM fns c JOIN pats e ON e.oid <> c.oid
1385
+ WHERE strpos(lower(c.body), lower(e.proname)) > 0
1386
+ ),
1387
+ calls AS (
1388
+ SELECT x.caller, x.callee, x.callee_name, strpos(x.body, h.hit) AS pos
1389
+ FROM callcand x
1390
+ CROSS JOIN LATERAL (SELECT substring(x.body FROM x.pat) AS hit) h
1391
+ WHERE h.hit IS NOT NULL
1392
+ ),
1393
+ reach AS (
1394
+ SELECT d.fn_oid, d.rel_oid, d.lock_mode, d.via, array[d.pos] AS path,
1395
+ true AS own, d.fn_oid AS origin_oid
1396
+ FROM direct d
1397
+ UNION ALL
1398
+ SELECT c.caller, r.rel_oid, r.lock_mode, 'callee:' || c.callee_name,
1399
+ c.pos || r.path, false, r.origin_oid
1400
+ FROM reach r JOIN calls c ON c.callee = r.fn_oid
1401
+ ) CYCLE fn_oid SET is_cycle USING cycle_path
1402
+ SELECT r.fn_oid, f.proname, f.sig, r.rel_oid,
1403
+ (SELECT n.nspname || '.' || cl.relname FROM pg_class cl
1404
+ JOIN pg_namespace n ON n.oid = cl.relnamespace WHERE cl.oid = r.rel_oid),
1405
+ r.lock_mode, r.via, r.path, r.own, r.origin_oid,
1406
+ (SELECT proname FROM fns WHERE oid = r.origin_oid)
1407
+ FROM reach r JOIN fns f ON f.oid = r.fn_oid
1408
+ WHERE NOT r.is_cycle;
1409
+ $lockedges$;
1410
+
1411
+ -- The name-resolved call closure (reflexive), so a test can ask "does this route
1412
+ -- reach <serialising helper>" without re-deriving the graph.
1413
+ CREATE OR REPLACE FUNCTION agent_board_test.fn_call_closure()
1414
+ RETURNS TABLE (root_oid oid, root_name text, reached_oid oid, reached_name text)
1415
+ LANGUAGE sql STABLE
1416
+ SET search_path = pg_catalog
1417
+ AS $closure$
1418
+ WITH RECURSIVE
1419
+ fns AS (
1420
+ SELECT p.oid, p.proname, agent_board_test.stripped_body(p.oid) AS body,
1421
+ agent_board_test.bare_ok(p.oid) AS bare
1422
+ FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
1423
+ WHERE n.nspname = 'agent_board' AND p.prokind IN ('f','p')
1424
+ AND p.prolang <> (SELECT oid FROM pg_language WHERE lanname = 'internal')
1425
+ ),
1426
+ pats AS MATERIALIZED (
1427
+ SELECT oid, proname,
1428
+ agent_board_test.call_re(proname, true) AS pat_bare,
1429
+ agent_board_test.call_re(proname, false) AS pat_qualified
1430
+ FROM fns
1431
+ ),
1432
+ -- Same prefilter-then-match shape as lock_edges(): the substring test is looser than the matcher
1433
+ -- and MATERIALIZED keeps the planner from running the matcher on every pair.
1434
+ callcand AS MATERIALIZED (
1435
+ SELECT c.oid AS caller, c.body, e.oid AS callee,
1436
+ CASE WHEN c.bare THEN e.pat_bare ELSE e.pat_qualified END AS pat
1437
+ FROM fns c JOIN pats e ON e.oid <> c.oid
1438
+ WHERE strpos(lower(c.body), lower(e.proname)) > 0
1439
+ ),
1440
+ calls AS (
1441
+ SELECT x.caller, x.callee FROM callcand x WHERE x.body ~ x.pat
1442
+ ),
1443
+ closure AS (
1444
+ SELECT oid AS root, oid AS reached FROM fns
1445
+ UNION
1446
+ SELECT c.root, e.callee FROM closure c JOIN calls e ON e.caller = c.reached
1447
+ )
1448
+ SELECT c.root, (SELECT proname FROM fns WHERE oid = c.root),
1449
+ c.reached, (SELECT proname FROM fns WHERE oid = c.reached)
1450
+ FROM closure c;
1451
+ $closure$;
1452
+
1453
+ COMMENT ON FUNCTION agent_board_test.lock_edges() IS
1454
+ '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.';