@consiliency/agent-board-schema 1.22.0 → 1.23.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 (60) hide show
  1. package/fixtures/already-applied.json +36 -0
  2. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  3. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  4. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  5. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  6. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  7. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  8. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  9. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  10. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  11. package/package.json +1 -1
  12. package/schema-manifest.json +84 -3
  13. package/src/tooling.test.mjs +2 -2
  14. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  15. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  16. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  17. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  18. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  19. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  20. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  21. package/tests/agent_board_fedsession.test.sql +46 -2
  22. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  23. package/tests/agent_board_inbox_reads.test.sql +16 -19
  24. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  25. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  26. package/tests/agent_board_roomlink.test.sql +2 -2
  27. package/tests/agent_board_wake_recovery.test.sql +756 -0
  28. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  29. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  30. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  31. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  32. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  33. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  34. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  35. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  36. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  37. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  38. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  39. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  40. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  41. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  42. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  43. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  44. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  45. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  46. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  47. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  48. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  49. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  50. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  51. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  52. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  53. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  54. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  55. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  56. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  57. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  58. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  59. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  60. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -47,7 +47,7 @@ begin
47
47
 
48
48
  insert into _p values (
49
49
  'https://fed-ok-'||s||'.test', 'message-board', 'portal|'||s, 'fed-agent-'||s,
50
- jsonb_build_object('sub', 'portal|'||s, 'act', jsonb_build_object('sub', 'act|'||s), 'name', 'Fed '||s),
50
+ jsonb_build_object('iss','https://fed-ok-'||s||'.test','aud','message-board','sub', 'portal|'||s, 'act', jsonb_build_object('sub', 'act|'||s), 'name', 'Fed '||s),
51
51
  'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0',
52
52
  'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
53
53
  'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk',
@@ -162,7 +162,7 @@ select is(
162
162
  -- Provider / scope / kind rejection matrix still raises ------------------------------
163
163
  select throws_ok(
164
164
  (select format($q$select agent_board.enroll_federated_identity('https://nope.test',%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
165
- aud, sub, claim::text, 'x-'||slug, pub1, fp1) from _p),
165
+ aud, sub, (claim || jsonb_build_object('iss','https://nope.test'))::text, 'x-'||slug, pub1, fp1) from _p),
166
166
  '42501', null, 'unknown (issuer,audience) is rejected');
167
167
  select throws_ok(
168
168
  (select format($q$select agent_board.enroll_federated_identity(%L,%L,NULL,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
@@ -198,7 +198,7 @@ begin
198
198
  into v_agent, v_kid, v_kver, v_handle
199
199
  from agent_board.enroll_federated_identity(
200
200
  'https://fed-b1-'||s||'.test','message-board','portal|'||s,
201
- jsonb_build_object('sub','portal|'||s), 'b1-'||s, v_pub,'raw_ed25519_base64url', v_fp,
201
+ jsonb_build_object('iss','https://fed-b1-'||s||'.test','aud','message-board','sub','portal|'||s), 'b1-'||s, v_pub,'raw_ed25519_base64url', v_fp,
202
202
  true, null, 'agent', '{board:send}', '{}') e;
203
203
  insert into _b1 values (v_agent, v_kid, v_kver, v_handle);
204
204
  end $$;
@@ -15,7 +15,7 @@ set search_path = extensions, public, agent_board;
15
15
 
16
16
  begin;
17
17
 
18
- select plan(18);
18
+ select plan(20);
19
19
 
20
20
  -- Client<->DB fingerprint round-trip (B2 raw-byte scheme): the DB derivation over the
21
21
  -- DECODED 32 key bytes must equal the constant an independent tool (Node crypto,
@@ -41,7 +41,7 @@ begin
41
41
  values ('https://fed-pop-'||s||'.test', 'message-board', true, true, '{board:send}', '{}', '{agent,human}');
42
42
  insert into _p values (
43
43
  'https://fed-pop-'||s||'.test', 'message-board', 'portal|'||s,
44
- jsonb_build_object('sub','portal|'||s,'act',jsonb_build_object('sub','act|'||s)),
44
+ jsonb_build_object('iss','https://fed-pop-'||s||'.test','aud','message-board','sub','portal|'||s,'act',jsonb_build_object('sub','act|'||s)),
45
45
  'eRHZmllEy_TZHAG2zB-Qe_a5BFLkaavMTazWWfaE3f0', 'sha256:z8d5LQPfBMYnfZfvu8wVz684_jkp578-SPDU2OMObS8',
46
46
  'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk', 'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA');
47
47
  end
@@ -58,7 +58,17 @@ select is(
58
58
  (select claim->'act'->>'sub' from _p),
59
59
  'the act.sub is captured (audit-only) in the enrollment claim snapshot');
60
60
 
61
- -- Duplicate fingerprint: re-enrolling key 1 raises (dedup on the UNIQUE index).
61
+ -- Exact replay: rotating presentation claims do not create a second identity.
62
+ select lives_ok(
63
+ (select format($q$create temp table _exact_replay as select * from agent_board.enroll_federated_identity(%L,%L,%L,(%L::jsonb || '{"iat":200,"exp":9999999999,"jti":"rotated"}'::jsonb),%L,%L,'raw_ed25519_base64url',%L,true,%L,'agent','{board:send}','{}')$q$,
64
+ iss, aud, sub, claim::text, 'pop-ok', pub1, fp1, 'act|present') from _p),
65
+ 'an exact stable-input replay with rotating JWT presentation claims succeeds');
66
+ select is(
67
+ (select agent_id from _exact_replay),
68
+ (select a.id from agent_board.agents a where a.handle='pop-ok'),
69
+ 'exact replay returns the original server-assigned identity');
70
+
71
+ -- A replay mutation (slug and independent actor_sub) fails closed with 23505.
62
72
  select throws_ok(
63
73
  (select format($q$select agent_board.enroll_federated_identity(%L,%L,%L,%L::jsonb,%L,%L,'raw_ed25519_base64url',%L,true,null,'agent','{board:send}','{}')$q$,
64
74
  iss, aud, sub, claim::text, 'pop-dup', pub1, fp1) from _p),
@@ -18,7 +18,7 @@ do $$
18
18
  declare
19
19
  s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
20
20
  v_agent uuid;
21
- v_claim jsonb := jsonb_build_object('sub','portal|'||s,'name','Fed '||s,'groups',jsonb_build_array('eng','fed'));
21
+ v_claim jsonb := jsonb_build_object('iss','https://mock-portal-'||s||'.test','aud','message-board','sub','portal|'||s,'name','Fed '||s,'groups',jsonb_build_array('eng','fed'));
22
22
  -- A valid unique 43-char base64url key + its matching (RPC-recomputed) fingerprint.
23
23
  v_pub text := rtrim(translate(encode(extensions.digest(convert_to('k-acfx-'||s,'UTF8'),'sha256'),'base64'),'+/','-_'),'=');
24
24
  v_fp text;
@@ -65,8 +65,8 @@ select is(
65
65
  select has_column('agent_board', 'enrollment_federation_audit', 'human_identity_claim', 'audit stores human_identity_claim');
66
66
  select has_column('agent_board', 'enrollment_federation_audit', 'enrolled_at', 'audit stores enrolled_at (enrollment-time)');
67
67
 
68
- -- No write-path RPC reads enrollment_federation_audit. Only the enroll RPC (which
69
- -- WRITES it) may reference the table; nothing on the send/session path reads it. ----
68
+ -- No send/session write-path RPC reads enrollment_federation_audit. Enrollment
69
+ -- writes it and total human revocation reads stable principal evidence from it. ----
70
70
  select is(
71
71
  (select count(*)::int from (
72
72
  select pg_get_functiondef(p.oid) as def, p.proname
@@ -79,10 +79,14 @@ select is(
79
79
  -- The federation enroll RPCs WRITE the audit (enroll_federated_human added by
80
80
  -- FEDSESSION, F-6); the invariant is that nothing on the send/session read path
81
81
  -- touches it (asserted specifically in the next case).
82
- and p.proname not in ('enroll_federated_identity', 'enroll_federated_human')
82
+ and p.proname not in (
83
+ 'enroll_federated_identity', 'enroll_federated_human',
84
+ '_enroll_federated_identity_once', '_enroll_federated_human_after_revocation_gate',
85
+ 'revoke_human_subtree'
86
+ )
83
87
  ) d
84
88
  where lower(d.def) like '%enrollment_federation_audit%'),
85
- 0, 'no agent_board RPC other than the federation enroll RPCs references enrollment_federation_audit');
89
+ 0, 'no agent_board RPC other than federation enrollment and total human revocation references enrollment_federation_audit');
86
90
  select is(
87
91
  (select count(*)::int from (
88
92
  select pg_get_functiondef(p.oid) as def
@@ -115,10 +119,10 @@ begin
115
119
  values ('https://rls-portal-'||s||'.test','message-board',true,true,'{board:send}','{board:x}','{agent,human}');
116
120
  select e.agent_id, e.auth_user_id into v_a_agent, v_a_user
117
121
  from agent_board.enroll_federated_identity('https://rls-portal-'||s||'.test','message-board','portal|a-'||s,
118
- jsonb_build_object('sub','portal|a-'||s,'name','A'), 'rls-a-'||s, v_pub_a,'raw_ed25519_base64url',v_fp_a,true,null,'human','{board:send}','{board:x}') e;
122
+ jsonb_build_object('iss','https://rls-portal-'||s||'.test','aud','message-board','sub','portal|a-'||s,'name','A'), 'rls-a-'||s, v_pub_a,'raw_ed25519_base64url',v_fp_a,true,null,'human','{board:send}','{board:x}') e;
119
123
  select e.agent_id into v_b_agent
120
124
  from agent_board.enroll_federated_identity('https://rls-portal-'||s||'.test','message-board','portal|b-'||s,
121
- jsonb_build_object('sub','portal|b-'||s,'name','B'), 'rls-b-'||s, v_pub_b,'raw_ed25519_base64url',v_fp_b,true,null,'human','{board:send}','{board:x}') e;
125
+ jsonb_build_object('iss','https://rls-portal-'||s||'.test','aud','message-board','sub','portal|b-'||s,'name','B'), 'rls-b-'||s, v_pub_b,'raw_ed25519_base64url',v_fp_b,true,null,'human','{board:send}','{board:x}') e;
122
126
  insert into _rls values (v_a_agent, v_a_user, v_b_agent);
123
127
  end
124
128
  $$;
@@ -0,0 +1,390 @@
1
+ -- Consiliency/message-board#194 pgTAP: the board/owner lock-order CLASS, derived
2
+ -- from pg_constraint FK edges + the pg_proc call graph, and the class-40
3
+ -- propagation contract of the invite-redemption subtransaction.
4
+ --
5
+ -- WHAT IS CATALOG-DERIVED HERE, AND WHAT IS NOT. Being exact about this matters
6
+ -- more than the green: an earlier header of this file claimed the assertion was
7
+ -- "catalog-derived and not textual", and that was false. Only the TABLE SET is
8
+ -- catalog-derived -- the direct FK children of agent_board.boards and auth.users,
9
+ -- read from pg_constraint, which is the part #194 needed because
10
+ -- enroll_federated_identity never NAMES boards (its lock arrives as FOR KEY SHARE
11
+ -- through board_memberships_board_id_fkey, inside a callee). ACQUISITION DETECTION
12
+ -- AND THE CALL GRAPH ARE REGEXES OVER prosrc. plpgsql bodies carry no
13
+ -- catalog-level table dependencies, so nothing stronger is available without a
14
+ -- parser, and the claim this file makes is scoped accordingly.
15
+ --
16
+ -- WHAT THE ANALYZER COVERS:
17
+ -- * dollar-quoted blocks (tag-matched with a backreference), single-quoted
18
+ -- literals with doubled quotes, line comments and block comments are all removed
19
+ -- in ONE alternation pass, left to right, which is the correct tokenization --
20
+ -- two passes are wrong in BOTH orders (comments-first eats a literal containing
21
+ -- '--'; literals-first eats a comment containing an apostrophe). Getting this
22
+ -- exactly right is load-bearing, not hygiene: an earlier version of this file
23
+ -- could not match a block comment whose body ended in '*', and one
24
+ -- `/* ... for update **/` line -- the kind of lock-order note every migration in
25
+ -- this repo carries -- made an INVERTED enroll_federated_human pass EC-3 with
26
+ -- 15/15 green. Membership did not change, so EC-2's exact-set backstop could not
27
+ -- fire either. The same version desynced on an apostrophe inside a dollar-quoted
28
+ -- string (agent_board.mark_retention_metadata has a $retention$ block with 64 of
29
+ -- them, and it re-synced only because that count is even -- working by luck).
30
+ -- Both shapes are now mutation-pinned by name;
31
+ -- * table and function names matched BOTH qualified and BARE, with each
32
+ -- function's own search_path (pg_proc.proconfig) deciding whether bare names
33
+ -- resolve into agent_board -- a route written `insert into board_memberships`
34
+ -- is visible, and so is `perform _redeem_invite_for_agent(...)`;
35
+ -- * three acquisition shapes: INSERT into a direct FK child, an UPDATE that
36
+ -- re-points the FK column, and an explicit row lock in any statement shape
37
+ -- (including `from boards b join ... for update of b`, which an earlier
38
+ -- `where`-anchored pattern could not see);
39
+ -- * a function pinning search_path WITHOUT agent_board (e.g.
40
+ -- _ensure_agent_auth_user's search_path='') is not credited with bare matches;
41
+ -- no proconfig at all is treated as bare-resolving, over-approximating
42
+ -- membership rather than under-approximating it.
43
+ --
44
+ -- WHAT IT DOES NOT COVER, stated so a green run is read for what it is:
45
+ -- * DYNAMIC SQL -- an EXECUTE of a computed string is unreadable here. EC-6 fails
46
+ -- the suite for any route that acquires EITHER a board or an owner lock visibly
47
+ -- and uses dynamic SQL, so the common shape (one side statically, the other
48
+ -- hidden in an EXECUTE) is fail-closed. Gating EC-6 on BOTH sides, as an earlier
49
+ -- version did, made it vacuous exactly where it was needed: a route whose board
50
+ -- acquisition lived inside the EXECUTE never became a class member, so EC-6 never
51
+ -- looked at it. A route that hides BOTH sides in dynamic SQL is still invisible;
52
+ -- that is the residual, and it has no visible acquisition to enumerate from;
53
+ -- * RUNTIME vs POSITIONAL order -- EC-3 is a first-acquisition claim over
54
+ -- statements as written, not a per-branch path analysis;
55
+ -- * locks taken by TRIGGERS on these tables, and overload resolution: call edges
56
+ -- are name-resolved, so every overload sharing a proname is one callee. That
57
+ -- over-approximates edges, which is safe for EC-2 (a real member cannot vanish)
58
+ -- but fail-open for EC-3 (a spurious early edge could lower board_pos). The
59
+ -- same direction applies to `for update of <other table>` in a statement that
60
+ -- also names boards: counted as an acquisition when it is not one.
61
+ -- * the ABSENCE class (#193 -- a route that skips its board lock because the row
62
+ -- it would lock does not exist yet) is positionally invisible by construction
63
+ -- and is pinned by tests/isolation/specs/fedsession_provider_absent_owner_lock.spec.
64
+ --
65
+ -- Every shape in the covered list has a mutant that makes a NAMED assertion fail;
66
+ -- three of them (bare INSERT, join-form lock, FK re-point) were invisible to the
67
+ -- first version of this file and are the reason it was rewritten.
68
+ create extension if not exists pgtap with schema extensions;
69
+ set search_path = extensions, public, agent_board;
70
+
71
+ begin;
72
+
73
+ select plan(15);
74
+
75
+ -- ---------------------------------------------------------------------------
76
+ -- The catalog derivation, materialized once.
77
+ -- ---------------------------------------------------------------------------
78
+ create temp table _lockclass on commit drop as
79
+ with recursive
80
+ -- prosrc (the BODY), with string literals and both comment forms removed in ONE
81
+ -- pass. Two passes are wrong in both orders: comments-first eats a literal
82
+ -- containing '--', literals-first eats a comment containing an apostrophe. The
83
+ -- alternation below consumes whichever token opens first, which is the correct
84
+ -- tokenization.
85
+ fns as (
86
+ select p.oid,
87
+ p.proname,
88
+ p.proname || '(' || pg_get_function_identity_arguments(p.oid) || ')' as sig,
89
+ regexp_replace(p.prosrc,
90
+ $re$(\$[A-Za-z_0-9]*\$).*?\1|('([^']|'')*')|(--[^\n]*)|(/\*([^*]|\*+[^*/])*\*+/)$re$,
91
+ ' ', 'g') as body,
92
+ p.proconfig
93
+ from pg_proc p
94
+ join pg_namespace n on n.oid = p.pronamespace
95
+ where n.nspname = 'agent_board'
96
+ and p.prokind = 'f'
97
+ and p.prolang <> (select oid from pg_language where lanname = 'internal')
98
+ ),
99
+ -- Resolve BARE names the way Postgres would: a function that pins search_path
100
+ -- WITHOUT agent_board (e.g. _ensure_agent_auth_user's search_path='') cannot reach
101
+ -- these tables unqualified, so bare matches are not attributed to it. A function
102
+ -- with no search_path setting at all is treated as if bare names DO resolve, which
103
+ -- over-approximates membership rather than under-approximating it.
104
+ sp as (
105
+ select f.oid,
106
+ case
107
+ when not exists (select 1 from unnest(coalesce(f.proconfig, '{}')) c
108
+ where c like 'search\_path=%') then true
109
+ else exists (select 1 from unnest(f.proconfig) c
110
+ where c like 'search\_path=%'
111
+ and ',' || replace(replace(split_part(c, '=', 2), ' ', ''), '"', '') || ','
112
+ like '%,agent\_board,%')
113
+ end as bare_ok
114
+ from fns f
115
+ ),
116
+ -- DIRECT FK children only: inserting into a grandchild locks the child row, not
117
+ -- the grandparent, so transitive FK depth would manufacture false positives.
118
+ -- NOTE: regclass::text drops the schema for anything on search_path, and this file
119
+ -- puts agent_board on it -- so the parent/child identity is resolved by OID and
120
+ -- pg_namespace here, never by a rendered name.
121
+ kids as (
122
+ select c.confrelid as parent_oid, cl.relname as tbl
123
+ from pg_constraint c
124
+ join pg_class cl on cl.oid = c.conrelid
125
+ join pg_namespace ns on ns.oid = cl.relnamespace
126
+ where c.contype = 'f'
127
+ and c.confrelid in ('agent_board.boards'::regclass, 'auth.users'::regclass)
128
+ and ns.nspname = 'agent_board'
129
+ ),
130
+ alt as (
131
+ select (select string_agg(distinct tbl, '|') from kids
132
+ where parent_oid = 'agent_board.boards'::regclass) as board_kids,
133
+ (select string_agg(distinct tbl, '|') from kids
134
+ where parent_oid = 'auth.users'::regclass) as owner_kids
135
+ ),
136
+ pat as (
137
+ select f.oid, s.bare_ok,
138
+ case when s.bare_ok then '(?:agent_board\.)?' else 'agent_board\.' end as q
139
+ from fns f join sp s on s.oid = f.oid
140
+ ),
141
+ own as (
142
+ select f.oid, f.proname, f.sig, f.body, p.bare_ok, p.q,
143
+ substring(f.body from
144
+ -- (i) INSERT into a direct FK child takes FOR KEY SHARE on the parent row
145
+ '(?:insert\s+into\s+(?:only\s+)?' || p.q || '\m(?:' || a.board_kids || ')\M)'
146
+ -- (ii) UPDATE that re-points the FK column re-checks it and locks the new parent
147
+ || '|(?:update\s+(?:only\s+)?' || p.q || '\m(?:' || a.board_kids || ')\M[^;]{0,250}?\mset\M[^;]{0,250}?\mboard_id\M\s*=)'
148
+ -- (iii) an explicit row lock in ANY statement shape. Deliberately not
149
+ -- anchored on `where`: `from boards b join ... for update of b` is a real
150
+ -- acquisition and the anchored form could not see it.
151
+ || '|(?:' || p.q || '\mboards\M[^;]{0,250}?for\s+(?:update|no\s+key\s+update|share|key\s+share))'
152
+ ) as board_hit,
153
+ substring(f.body from
154
+ '(?:insert\s+into\s+(?:only\s+)?' || p.q || '\m(?:' || a.owner_kids || ')\M)'
155
+ || '|(?:update\s+(?:only\s+)?' || p.q || '\m(?:' || a.owner_kids || ')\M[^;]{0,250}?\mset\M[^;]{0,250}?\m(?:owner_user_id|auth_user_id)\M\s*=)'
156
+ || '|(?:auth\.users\M[^;]{0,250}?for\s+update)'
157
+ ) as owner_hit
158
+ from fns f join pat p on p.oid = f.oid cross join alt a
159
+ ),
160
+ edges as (
161
+ select c.oid as caller, e.oid as callee
162
+ from own c
163
+ join fns e on e.oid <> c.oid
164
+ where c.body ~ (c.q || '\m' || e.proname || '\M\s*\(')
165
+ ),
166
+ acq_board(oid) as (
167
+ select oid from own where board_hit is not null
168
+ union
169
+ select e.caller from edges e join acq_board a on a.oid = e.callee
170
+ ),
171
+ acq_owner(oid) as (
172
+ select oid from own where owner_hit is not null
173
+ union
174
+ select e.caller from edges e join acq_owner a on a.oid = e.callee
175
+ )
176
+ select o.sig,
177
+ (o.oid in (select oid from acq_board)) as reaches_board,
178
+ (o.oid in (select oid from acq_owner)) as reaches_owner,
179
+ (o.board_hit is not null) as own_board_lock,
180
+ (o.owner_hit is not null) as own_owner_lock,
181
+ (o.body ~* '\mexecute\M') as uses_dynamic_sql,
182
+ least(
183
+ case when o.board_hit is null then 2147483647 else strpos(o.body, o.board_hit) end,
184
+ coalesce((select min(strpos(o.body, substring(o.body from o.q || '\m' || f2.proname || '\M\s*\(')))
185
+ from fns f2
186
+ where f2.oid <> o.oid
187
+ and f2.oid in (select oid from acq_board)
188
+ and o.body ~ (o.q || '\m' || f2.proname || '\M\s*\(')), 2147483647)
189
+ ) as board_pos,
190
+ least(
191
+ case when o.owner_hit is null then 2147483647 else strpos(o.body, o.owner_hit) end,
192
+ coalesce((select min(strpos(o.body, substring(o.body from o.q || '\m' || f2.proname || '\M\s*\(')))
193
+ from fns f2
194
+ where f2.oid <> o.oid
195
+ and f2.oid in (select oid from acq_owner)
196
+ and o.body ~ (o.q || '\m' || f2.proname || '\M\s*\(')), 2147483647)
197
+ ) as owner_pos
198
+ from own o;
199
+
200
+ -- EC-1: the FK edges the derivation rests on. If either disappears (renamed table,
201
+ -- dropped constraint) the class silently empties and every other assertion here
202
+ -- passes vacuously, so assert the two load-bearing edges POSITIVELY.
203
+ select is(
204
+ (select string_agg(c.conname, ', ' order by c.conname)
205
+ from pg_constraint c
206
+ where c.contype = 'f'
207
+ and ((c.confrelid = 'agent_board.boards'::regclass
208
+ and c.conrelid = 'agent_board.board_memberships'::regclass)
209
+ or (c.confrelid = 'auth.users'::regclass
210
+ and c.conrelid = 'agent_board.agents'::regclass))),
211
+ 'agents_auth_user_id_fkey, agents_owner_user_id_fkey, board_memberships_board_id_fkey',
212
+ 'EC-1: the invisible FK edges (memberships->boards, agents->auth.users) both exist');
213
+
214
+ -- EC-2: the CLASS itself -- every agent_board function that acquires both a boards
215
+ -- row lock and an auth.users row lock. Asserted as an exact set: a new member fails
216
+ -- here and NAMES itself instead of joining an unexamined class.
217
+ select is(
218
+ (select string_agg(split_part(sig, '(', 1), ', ' order by split_part(sig, '(', 1))
219
+ from _lockclass where reaches_board and reaches_owner),
220
+ '_enroll_federated_human_after_revocation_gate, _enroll_federated_identity_once, '
221
+ || 'enroll_federated_human, enroll_federated_identity, onboard_federated_fleet_agent',
222
+ 'EC-2: the board+owner lock class is exactly the five federated write routes');
223
+
224
+ -- EC-3: THE ORDERING INVARIANT. Never acquire auth.users before boards. The failure
225
+ -- prints the offending signatures (pre-fix this named enroll_federated_identity and
226
+ -- _enroll_federated_identity_once).
227
+ select is(
228
+ (select coalesce(string_agg(split_part(sig, '(', 1), ', ' order by sig), '')
229
+ from _lockclass
230
+ where reaches_board and reaches_owner and board_pos >= owner_pos),
231
+ '',
232
+ 'EC-3: every board+owner route acquires the board BEFORE the owner');
233
+
234
+ -- EC-4: positive exemption. revoke_human_subtree DOES take the auth.users owner
235
+ -- lock and is deliberately outside the class; assert its claim positively (it
236
+ -- reaches no boards acquisition at all), never by absence from a match.
237
+ select is(
238
+ (select own_owner_lock::text || '/' || reaches_board::text
239
+ from _lockclass where sig like 'revoke\_human\_subtree(%'),
240
+ 'true/false',
241
+ 'EC-4: revoke_human_subtree locks the owner and provably acquires no board');
242
+
243
+ -- EC-5: the two routes this migration moved take their board lock in their own
244
+ -- body (not merely through a callee), so a future refactor of the callee cannot
245
+ -- silently drop the ordering.
246
+ select is(
247
+ (select string_agg(split_part(sig, '(', 1), ', ' order by sig)
248
+ from _lockclass
249
+ where own_board_lock
250
+ and split_part(sig, '(', 1) in ('enroll_federated_identity', '_enroll_federated_identity_once')),
251
+ '_enroll_federated_identity_once, enroll_federated_identity',
252
+ 'EC-5: both enroll routes name their own agent_board.boards row lock');
253
+
254
+ -- EC-6: no class member uses dynamic SQL. EC-3's positional claim reads statements
255
+ -- as written; an EXECUTE of a computed string is invisible to it. Rather than
256
+ -- leave that as a caveat in prose, fail the suite the day a member acquires one.
257
+ select is(
258
+ (select coalesce(string_agg(split_part(sig, '(', 1), ', ' order by sig), '')
259
+ from _lockclass
260
+ where (reaches_board or reaches_owner) and uses_dynamic_sql),
261
+ '',
262
+ 'EC-6: no board+owner route uses dynamic SQL, which EC-3 could not read');
263
+
264
+ -- ---------------------------------------------------------------------------
265
+ -- Class-40 propagation. The redemption subtransaction must keep swallowing
266
+ -- deterministic failures (soft-honor) and must STOP swallowing serialization
267
+ -- failures, which reported enrollment SUCCESS with no membership (#194).
268
+ -- ---------------------------------------------------------------------------
269
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
270
+ values ('194a0000-0000-0000-0000-000000000001', 'agent', 'agent',
271
+ 'lo194-owner', 'LO194 Owner', 'active');
272
+
273
+ select count(*) from agent_board.create_board(
274
+ '194a0000-0000-0000-0000-000000000001', 'lo194-board', 'LO194 Board',
275
+ 'Consiliency/message-board#194 redemption-failure class', jsonb_build_object(), 'private');
276
+
277
+ insert into agent_board.identity_providers(
278
+ issuer, audience, supported, policy_approved,
279
+ allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
280
+ values ('https://lockorder194.test', 'message-board', true, true,
281
+ ARRAY[]::text[], ARRAY[]::text[], ARRAY['agent','human']);
282
+
283
+ insert into agent_board.board_invite_tokens(
284
+ code, board_id, preset_role, preset_membership_state, max_uses, expires_at, created_by_agent_id)
285
+ select 'lo194-code-' || g, b.id, 'member', 'joined', 10,
286
+ now() + interval '1 hour', '194a0000-0000-0000-0000-000000000001'
287
+ from agent_board.boards b, generate_series(1, 3) g
288
+ where b.slug = 'lo194-board';
289
+
290
+ -- Capture tables are created EMPTY and filled inside lives_ok: if the call raises,
291
+ -- the assertion reports a named `not ok` and every later assertion still RUNS.
292
+ -- (`create temp table _e as select * from <rpc>` inside lives_ok leaves the table
293
+ -- missing on failure, which aborts the file with no further TAP output -- observed
294
+ -- while mutation-testing this very file.)
295
+ create temp table _e1 (agent_id uuid, auth_user_id uuid, provider_id text, signing_key_id uuid,
296
+ granted_scopes text[], key_id text, key_version int, agent_handle text, invite_redeemed boolean)
297
+ on commit drop;
298
+ create temp table _e2 (like _e1) on commit drop;
299
+ create temp table _e3 (like _e1) on commit drop;
300
+
301
+ -- R-1/R-2/R-3: uncontended redemption still works -- the allow side, so a later
302
+ -- "it raised" is not confused with "it never redeemed".
303
+ select lives_ok(
304
+ $$ insert into _e1
305
+ select * from agent_board.enroll_federated_identity(
306
+ 'https://lockorder194.test', 'message-board', 'sub-lo194',
307
+ jsonb_build_object('iss','https://lockorder194.test','aud','message-board','sub','sub-lo194'),
308
+ 'lo194-a', public.test_pubkey('lo194-k1'), 'raw_ed25519_base64url',
309
+ public.test_key_fingerprint('lo194-k1'), true, null, 'agent',
310
+ ARRAY[]::text[], ARRAY[]::text[], 'lo194-code-1') $$,
311
+ 'R-1: fresh federated enrollment with a usable invite succeeds');
312
+ select is((select invite_redeemed from _e1), true,
313
+ 'R-2: the fresh enrollment reports the invite redeemed');
314
+ select is(
315
+ (select count(*)::int from agent_board.board_memberships m
316
+ join agent_board.boards b on b.id = m.board_id
317
+ where b.slug = 'lo194-board'
318
+ and m.agent_id = (select agent_id from _e1)
319
+ and m.membership_state = 'joined'),
320
+ 1, 'R-3: the fresh enrollment landed a joined membership row on the board');
321
+
322
+ -- R-4/R-5: a DETERMINISTIC redemption failure (unusable code) is still soft-honored
323
+ -- -- the released contract, and the exemption the class-40 filter must not widen.
324
+ select lives_ok(
325
+ $$ insert into _e2
326
+ select * from agent_board.enroll_federated_identity(
327
+ 'https://lockorder194.test', 'message-board', 'sub-lo194',
328
+ jsonb_build_object('iss','https://lockorder194.test','aud','message-board','sub','sub-lo194'),
329
+ 'lo194-b', public.test_pubkey('lo194-k2'), 'raw_ed25519_base64url',
330
+ public.test_key_fingerprint('lo194-k2'), true, null, 'agent',
331
+ ARRAY[]::text[], ARRAY[]::text[], 'lo194-no-such-code') $$,
332
+ 'R-4: an unusable invite code still enrolls (soft-honor preserved)');
333
+ select is((select invite_redeemed from _e2), false,
334
+ 'R-5: the unusable code reports invite_redeemed=false rather than raising');
335
+
336
+ -- Fault injection: make the redemption raise a serialization failure exactly where a
337
+ -- deadlock victim would surface it (the ledger insert inside
338
+ -- _redeem_unbounded_invite_for_agent), with no concurrency required.
339
+ create function pg_temp._lo194_raise_40001() returns trigger
340
+ language plpgsql as $$
341
+ begin
342
+ raise exception 'simulated serialization failure during redemption' using errcode = '40001';
343
+ end;
344
+ $$;
345
+ create trigger _lo194_serialization_failure
346
+ before insert on agent_board.board_invite_redemptions
347
+ for each row execute function pg_temp._lo194_raise_40001();
348
+
349
+ -- R-6: the CREATE path (_enroll_federated_identity_once step 13). Pre-fix this
350
+ -- returned a row with invite_redeemed=false and no error.
351
+ select throws_ok(
352
+ $$ select * from agent_board.enroll_federated_identity(
353
+ 'https://lockorder194.test', 'message-board', 'sub-lo194',
354
+ jsonb_build_object('iss','https://lockorder194.test','aud','message-board','sub','sub-lo194'),
355
+ 'lo194-c', public.test_pubkey('lo194-k3'), 'raw_ed25519_base64url',
356
+ public.test_key_fingerprint('lo194-k3'), true, null, 'agent',
357
+ ARRAY[]::text[], ARRAY[]::text[], 'lo194-code-2') $$,
358
+ '40001', null,
359
+ 'R-6: class 40 during the CREATE-path redemption propagates to the caller');
360
+
361
+ -- R-7: the REPLAY path (enroll_federated_identity own catch). Same key as R-1, so the
362
+ -- frozen replay contract matches and a second, unredeemed code is offered.
363
+ select throws_ok(
364
+ $$ select * from agent_board.enroll_federated_identity(
365
+ 'https://lockorder194.test', 'message-board', 'sub-lo194',
366
+ jsonb_build_object('iss','https://lockorder194.test','aud','message-board','sub','sub-lo194'),
367
+ 'lo194-a', public.test_pubkey('lo194-k1'), 'raw_ed25519_base64url',
368
+ public.test_key_fingerprint('lo194-k1'), true, null, 'agent',
369
+ ARRAY[]::text[], ARRAY[]::text[], 'lo194-code-3') $$,
370
+ '40001', null,
371
+ 'R-7: class 40 during the REPLAY-path redemption propagates to the caller');
372
+
373
+ drop trigger _lo194_serialization_failure on agent_board.board_invite_redemptions;
374
+
375
+ -- R-8/R-9: with the injection removed, the replay path still soft-honors a
376
+ -- deterministic failure -- the filter narrowed the catch, it did not remove it.
377
+ select lives_ok(
378
+ $$ insert into _e3
379
+ select * from agent_board.enroll_federated_identity(
380
+ 'https://lockorder194.test', 'message-board', 'sub-lo194',
381
+ jsonb_build_object('iss','https://lockorder194.test','aud','message-board','sub','sub-lo194'),
382
+ 'lo194-a', public.test_pubkey('lo194-k1'), 'raw_ed25519_base64url',
383
+ public.test_key_fingerprint('lo194-k1'), true, null, 'agent',
384
+ ARRAY[]::text[], ARRAY[]::text[], 'lo194-no-such-code') $$,
385
+ 'R-8: replay with an unusable code still succeeds');
386
+ select is((select invite_redeemed from _e3), false,
387
+ 'R-9: replay with an unusable code reports invite_redeemed=false');
388
+
389
+ select * from finish();
390
+ rollback;
@@ -17,7 +17,7 @@ set search_path = extensions, public, agent_board;
17
17
 
18
18
  begin;
19
19
 
20
- select plan(37);
20
+ select plan(41);
21
21
 
22
22
  -- ---------------------------------------------------------------------------
23
23
  -- Phase 0 (superuser): function metadata + privileges
@@ -216,7 +216,7 @@ begin
216
216
  v_fp := 'sha256:' || rtrim(translate(encode(extensions.digest(decode(translate(v_pub,'-_','+/')||'=','base64'),'sha256'),'base64'),'+/','-_'),'=');
217
217
  select e.agent_id into v_agent
218
218
  from agent_board.enroll_federated_identity(
219
- f.iss, 'message-board', f.sub_a, jsonb_build_object('sub', f.sub_a),
219
+ f.iss, 'message-board', f.sub_a, jsonb_build_object('iss',f.iss,'aud','message-board','sub', f.sub_a),
220
220
  'idlink-'||f.s, v_pub, 'raw_ed25519_base64url', v_fp,
221
221
  true, null, 'agent', '{board:send}', '{}') e;
222
222
  insert into _idlink select a.owner_user_id from agent_board.agents a where a.id = v_agent;
@@ -311,6 +311,50 @@ select throws_ok(
311
311
  iss, sub_a, jsonb_build_object('sub', sub_a)::text) from _f where sub_a like 'portal-user-%'),
312
312
  '42501', null, 'a suspended (non-active) fed-human cannot re-provision a session (42501)');
313
313
 
314
+ -- Durable total-human revocation also closes the production /portal-session RPC seam,
315
+ -- including after auth.users deletion would otherwise recreate the deterministic owner.
316
+ create temp table _portal_revoked(
317
+ issuer text, subject text, agent_id uuid, auth_user_id uuid
318
+ ) on commit drop;
319
+ do $$
320
+ declare
321
+ s4 text := replace(substr(gen_random_uuid()::text,1,8),'-','');
322
+ v_issuer text := 'https://fed-revoked-' || s4 || '.test';
323
+ v_subject text := 'portal-revoked-' || s4;
324
+ v_agent uuid;
325
+ v_auth uuid;
326
+ begin
327
+ insert into agent_board.identity_providers
328
+ (issuer, audience, supported, policy_approved, allowed_scopes, allowed_board_scopes, allowed_agent_kinds)
329
+ values (v_issuer, 'message-board', true, true, '{}', '{}', '{human}');
330
+ select agent_id, auth_user_id into v_agent, v_auth
331
+ from agent_board.enroll_federated_human(
332
+ v_issuer, 'message-board', v_subject, jsonb_build_object('sub', v_subject), null);
333
+ insert into _portal_revoked values (v_issuer, v_subject, v_agent, v_auth);
334
+ perform * from agent_board.revoke_human_subtree(
335
+ v_auth, (select agent_id from _r where label='B'), 'portal-session durable revoke');
336
+ delete from auth.users where id = v_auth;
337
+ end $$;
338
+ -- 36
339
+ select ok(
340
+ pg_get_functiondef('agent_board.enroll_federated_human(text,text,text,jsonb,text)'::regprocedure)
341
+ like '%from agent_board.boards b%for update%from agent_board.identity_providers ip%for update%_ensure_agent_auth_user(v_handle)%from auth.users u where u.id = v_uid for update%fleet_human_revocations r%r.issuer = p_issuer%r.subject = p_subject%_enroll_federated_human_after_revocation_gate%',
342
+ '/portal-session locks default board then provider then owner and checks the durable marker before delegation');
343
+ -- 37
344
+ select throws_ok(
345
+ (select format($q$select * from agent_board.enroll_federated_human(%L,'message-board',%L,%L::jsonb,null)$q$,
346
+ issuer, subject, jsonb_build_object('sub', subject)::text) from _portal_revoked),
347
+ '42501', null, '/portal-session cannot recreate a durably revoked human after auth-user deletion');
348
+ -- 38
349
+ select is(
350
+ (select count(*)::int from agent_board.agents
351
+ where id=(select agent_id from _portal_revoked) and status='active'),
352
+ 0, 'blocked /portal-session leaves the original principal revoked');
353
+ -- 39
354
+ select is(
355
+ (select count(*)::int from auth.users where id=(select auth_user_id from _portal_revoked)),
356
+ 0, 'blocked /portal-session rolls back deterministic auth-user recreation');
357
+
314
358
  select finish();
315
359
 
316
360
  rollback;