@consiliency/agent-board-schema 1.10.0 → 1.22.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,153 @@
1
+ -- OBSGATE-2 (#123) pgTAP: the board posting rule now applies to every thread-authoring RPC,
2
+ -- not just post_message. An observer with board-thread access cannot create tasks, delegate
3
+ -- tasks, add artifacts, raise conflicts, or raise escalations into a board-linked thread; a
4
+ -- non-observer member, board-less threads, and service_role are unaffected.
5
+ --
6
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
7
+ -- request.jwt.claim.sub picks the actor.
8
+ --
9
+ -- The DENY assertions are the mutation pins (each raises 42501 via _current_agent_can_post_to_
10
+ -- thread). delegate_task is pinned to prove the TRANSITIVE close through create_task. The ALLOW
11
+ -- twins guard against over-block/regression, including the internal create_tasking_delivery path.
12
+ create extension if not exists pgtap with schema extensions;
13
+ set search_path = extensions, public, agent_board;
14
+
15
+ begin;
16
+
17
+ select plan(11);
18
+
19
+ create temp table _o2_fixture (
20
+ board uuid,
21
+ member_agent uuid,
22
+ observer_agent uuid,
23
+ board_thread uuid,
24
+ boardless_thread uuid,
25
+ parent_task uuid,
26
+ member_user uuid,
27
+ observer_user uuid
28
+ ) on commit drop;
29
+
30
+ do $$
31
+ declare
32
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
33
+ v_member_user uuid;
34
+ v_observer_user uuid;
35
+ v_creator uuid := gen_random_uuid();
36
+ v_member uuid := gen_random_uuid();
37
+ v_observer uuid := gen_random_uuid();
38
+ v_board uuid;
39
+ v_board_thread uuid := gen_random_uuid();
40
+ v_boardless_thread uuid := gen_random_uuid();
41
+ v_parent_task uuid;
42
+ begin
43
+ v_member_user := public.ensure_github_user('o2-member-' || v_suffix, 'o2-member-' || v_suffix || '@example.test', 'o2-member-' || v_suffix, 'O2 Member');
44
+ v_observer_user := public.ensure_github_user('o2-observer-' || v_suffix, 'o2-observer-' || v_suffix || '@example.test', 'o2-observer-' || v_suffix, 'O2 Observer');
45
+
46
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
47
+ values
48
+ (v_creator, 'agent', 'o2-creator-' || v_suffix, 'O2 Creator', 'active', null, null),
49
+ (v_member, 'agent', 'o2-member-' || v_suffix, 'O2 Member', 'active', v_member_user, v_member_user),
50
+ (v_observer, 'agent', 'o2-observer-' || v_suffix, 'O2 Observer', 'active', v_observer_user, v_observer_user);
51
+
52
+ select board_id into v_board
53
+ from agent_board.create_board(v_creator, 'o2-board-' || v_suffix, 'O2 Board', 'obsgate2 test', '{}'::jsonb, 'private');
54
+
55
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
56
+ values
57
+ (v_board, v_member, 'joined', v_creator, 'member'),
58
+ (v_board, v_observer, 'joined', v_creator, 'observer');
59
+
60
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
61
+ values
62
+ (v_board_thread, 'coordination', v_member, 'O2 board thread'),
63
+ (v_boardless_thread, 'coordination', v_member, 'O2 board-less thread');
64
+
65
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
66
+ perform set_config('request.jwt.claim.sub', v_member_user::text, true);
67
+ -- Fan a board message so the board thread is stamped and the observer gains thread access.
68
+ perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
69
+ -- The member seeds a parent task (non-observer, permitted) for the delegate_task test.
70
+ select task_id into v_parent_task
71
+ from agent_board.create_task(v_board_thread, v_member, 'parent task', 'd');
72
+
73
+ insert into _o2_fixture values
74
+ (v_board, v_member, v_observer, v_board_thread, v_boardless_thread, v_parent_task, v_member_user, v_observer_user);
75
+ end
76
+ $$;
77
+
78
+ -- DENY (mutation pins): an observer cannot author into the board thread by any door ----------
79
+ select set_config('request.jwt.claim.role', 'authenticated', true);
80
+ select set_config('request.jwt.claim.sub', (select observer_user from _o2_fixture)::text, true);
81
+
82
+ select throws_ok(
83
+ (select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'obs task', 'd')$q$,
84
+ board_thread, observer_agent) from _o2_fixture),
85
+ '42501', 'thread posting requires a non-observer board role',
86
+ 'observer cannot create_task in a board thread');
87
+
88
+ select throws_ok(
89
+ (select format($q$select agent_board.delegate_task(%L::uuid, %L::uuid, 'obs deleg', 'd', %L::uuid)$q$,
90
+ parent_task, observer_agent, member_agent) from _o2_fixture),
91
+ '42501', 'thread posting requires a non-observer board role',
92
+ 'observer cannot delegate_task into a board thread (transitive via create_task)');
93
+
94
+ select throws_ok(
95
+ (select format($q$select agent_board.add_artifact(%L::uuid, 'https://x.test/a', 'url', 'external_url', %L::uuid)$q$,
96
+ observer_agent, board_thread) from _o2_fixture),
97
+ '42501', 'thread posting requires a non-observer board role',
98
+ 'observer cannot add_artifact to a board thread');
99
+
100
+ select throws_ok(
101
+ (select format($q$select agent_board.raise_conflict(%L::uuid, %L::uuid, 'obs conflict')$q$,
102
+ board_thread, observer_agent) from _o2_fixture),
103
+ '42501', 'thread posting requires a non-observer board role',
104
+ 'observer cannot raise_conflict in a board thread');
105
+
106
+ select throws_ok(
107
+ (select format($q$select agent_board.raise_escalation(%L::uuid, %L::uuid, 'obs escalation')$q$,
108
+ board_thread, observer_agent) from _o2_fixture),
109
+ '42501', 'thread posting requires a non-observer board role',
110
+ 'observer cannot raise_escalation in a board thread');
111
+
112
+ -- ALLOW twins: a non-observer member is unaffected ------------------------------------------
113
+ select set_config('request.jwt.claim.sub', (select member_user from _o2_fixture)::text, true);
114
+
115
+ select lives_ok(
116
+ (select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'member task', 'd')$q$,
117
+ board_thread, member_agent) from _o2_fixture),
118
+ 'a member may create_task in a board thread');
119
+
120
+ select lives_ok(
121
+ (select format($q$select agent_board.add_artifact(%L::uuid, 'https://x.test/b', 'url', 'external_url', %L::uuid)$q$,
122
+ member_agent, board_thread) from _o2_fixture),
123
+ 'a member may add_artifact to a board thread');
124
+
125
+ -- thread-only (no task): also exercises the v_event no-task fix folded into this migration.
126
+ select lives_ok(
127
+ (select format($q$select agent_board.raise_escalation(%L::uuid, %L::uuid, 'member escalation')$q$,
128
+ board_thread, member_agent) from _o2_fixture),
129
+ 'a member may raise_escalation (thread-only) in a board thread');
130
+
131
+ -- regression: the internal create_tasking_delivery -> create_task path still works for a member
132
+ select lives_ok(
133
+ (select format($q$select agent_board.create_tasking_delivery(%L::uuid, %L::uuid, 'member tasking', 'd', null, %L::uuid)$q$,
134
+ board_thread, member_agent, board) from _o2_fixture),
135
+ 'a member may create_tasking_delivery (internal create_task) — gate does not break the tasking path');
136
+
137
+ -- board-less thread: no board link -> access-only, no role gate (no regression) --------------
138
+ select lives_ok(
139
+ (select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'boardless task', 'd')$q$,
140
+ boardless_thread, member_agent) from _o2_fixture),
141
+ 'a board-less thread is unaffected by the posting gate (access-only)');
142
+
143
+ -- service_role carve-out --------------------------------------------------------------------
144
+ select set_config('request.jwt.claim.role', 'service_role', true);
145
+ select set_config('request.jwt.claim.sub', '', true);
146
+ select lives_ok(
147
+ (select format($q$select agent_board.create_task(%L::uuid, %L::uuid, 'svc task', 'd')$q$,
148
+ board_thread, observer_agent) from _o2_fixture),
149
+ 'service_role may author into a board thread regardless of role (carve-out)');
150
+
151
+ select * from finish();
152
+
153
+ rollback;
@@ -0,0 +1,170 @@
1
+ -- OBSGATE-3 (#125) pgTAP: task-lifecycle mutation on a board-thread task requires a
2
+ -- non-observer board role OR being the task's assignee/creator. A board observer cannot
3
+ -- mutate a task it is not a party to (append_task_event and its callers acquire_claim /
4
+ -- resolve_conflict / resolve_escalation), but a legitimate non-member ASSIGNEE still can.
5
+ --
6
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
7
+ -- request.jwt.claim.sub picks the actor.
8
+ --
9
+ -- The DENY assertions are the mutation pins (append_task_event's mutate-task gate). The
10
+ -- assignee/creator ALLOW twins are the over-block guards — the whole point of the
11
+ -- assignee-aware rule (a blanket board-role gate would lock out a non-member assignee).
12
+ create extension if not exists pgtap with schema extensions;
13
+ set search_path = extensions, public, agent_board;
14
+
15
+ begin;
16
+
17
+ select plan(11);
18
+
19
+ create temp table _o3_fixture (
20
+ board uuid,
21
+ member_agent uuid,
22
+ observer_agent uuid,
23
+ nonmember_agent uuid,
24
+ member_task uuid,
25
+ assignee_task uuid,
26
+ observer_task uuid,
27
+ boardless_task uuid,
28
+ conflict_id uuid,
29
+ escalation_id uuid,
30
+ member_user uuid,
31
+ observer_user uuid,
32
+ nonmember_user uuid
33
+ ) on commit drop;
34
+
35
+ do $$
36
+ declare
37
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
38
+ v_member_user uuid; v_observer_user uuid; v_nonmember_user uuid;
39
+ v_creator uuid := gen_random_uuid();
40
+ v_member uuid := gen_random_uuid();
41
+ v_observer uuid := gen_random_uuid();
42
+ v_nonmember uuid := gen_random_uuid();
43
+ v_board uuid;
44
+ v_board_thread uuid := gen_random_uuid();
45
+ v_boardless_thread uuid := gen_random_uuid();
46
+ v_member_task uuid; v_assignee_task uuid; v_observer_task uuid; v_boardless_task uuid;
47
+ v_conflict uuid; v_escalation uuid;
48
+ begin
49
+ v_member_user := public.ensure_github_user('o3-member-' || v_suffix, 'o3-member-' || v_suffix || '@example.test', 'o3-member-' || v_suffix, 'O3 Member');
50
+ v_observer_user := public.ensure_github_user('o3-observer-' || v_suffix, 'o3-observer-' || v_suffix || '@example.test', 'o3-observer-' || v_suffix, 'O3 Observer');
51
+ v_nonmember_user := public.ensure_github_user('o3-nonmember-' || v_suffix, 'o3-nonmember-' || v_suffix || '@example.test', 'o3-nonmember-' || v_suffix, 'O3 Nonmember');
52
+
53
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
54
+ values
55
+ (v_creator, 'agent', 'o3-creator-' || v_suffix, 'O3 Creator', 'active', null, null),
56
+ (v_member, 'agent', 'o3-member-' || v_suffix, 'O3 Member', 'active', v_member_user, v_member_user),
57
+ (v_observer, 'agent', 'o3-observer-' || v_suffix, 'O3 Observer', 'active', v_observer_user, v_observer_user),
58
+ (v_nonmember, 'agent', 'o3-nonmember-' || v_suffix, 'O3 Nonmember', 'active', v_nonmember_user, v_nonmember_user);
59
+
60
+ select board_id into v_board
61
+ from agent_board.create_board(v_creator, 'o3-board-' || v_suffix, 'O3 Board', 'obsgate3 test', '{}'::jsonb, 'private');
62
+
63
+ -- member is a board member; observer is an observer; nonmember is NOT on the board.
64
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
65
+ values
66
+ (v_board, v_member, 'joined', v_creator, 'member'),
67
+ (v_board, v_observer, 'joined', v_creator, 'observer');
68
+
69
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
70
+ values
71
+ (v_board_thread, 'coordination', v_member, 'O3 board thread'),
72
+ (v_boardless_thread, 'coordination', v_member, 'O3 board-less thread');
73
+
74
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
75
+ perform set_config('request.jwt.claim.sub', v_member_user::text, true);
76
+ perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
77
+
78
+ -- member creates the board-thread tasks (non-observer, permitted by #123).
79
+ select task_id into v_member_task from agent_board.create_task(v_board_thread, v_member, 'member task', 'd');
80
+ select task_id into v_assignee_task from agent_board.create_task(v_board_thread, v_member, 'assignee task', 'd', v_nonmember);
81
+ select task_id into v_observer_task from agent_board.create_task(v_board_thread, v_member, 'observer-assigned task', 'd', v_observer);
82
+ select task_id into v_boardless_task from agent_board.create_task(v_boardless_thread, v_member, 'boardless task', 'd');
83
+ -- member raises a conflict + escalation on the member task (task-attached, so they append events).
84
+ select conflict_id into v_conflict from agent_board.raise_conflict(v_board_thread, v_member, 'c', 'other', 'medium', v_member_task);
85
+ select escalation_id into v_escalation from agent_board.raise_escalation(v_board_thread, v_member, 'e', 'other', 'medium', v_member_task);
86
+
87
+ insert into _o3_fixture values
88
+ (v_board, v_member, v_observer, v_nonmember, v_member_task, v_assignee_task, v_observer_task,
89
+ v_boardless_task, v_conflict, v_escalation, v_member_user, v_observer_user, v_nonmember_user);
90
+ end
91
+ $$;
92
+
93
+ -- DENY (mutation pins): an observer cannot mutate a board-thread task it is not a party to ----
94
+ select set_config('request.jwt.claim.role', 'authenticated', true);
95
+ select set_config('request.jwt.claim.sub', (select observer_user from _o3_fixture)::text, true);
96
+
97
+ select throws_ok(
98
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
99
+ member_task, observer_agent) from _o3_fixture),
100
+ '42501', 'task mutation requires a non-observer board role, or task assignment/creation',
101
+ 'observer cannot append_task_event to a board-thread task it is not a party to');
102
+
103
+ select throws_ok(
104
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'custom', 'k1', now() + interval '1 hour', %L::uuid)$q$,
105
+ observer_agent, member_task) from _o3_fixture),
106
+ '42501', 'task mutation requires a non-observer board role, or task assignment/creation',
107
+ 'observer cannot acquire_claim on a board-thread task (transitive)');
108
+
109
+ select throws_ok(
110
+ (select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
111
+ conflict_id, observer_agent) from _o3_fixture),
112
+ '42501', 'task mutation requires a non-observer board role, or task assignment/creation',
113
+ 'observer cannot resolve_conflict on a board-thread task (transitive)');
114
+
115
+ select throws_ok(
116
+ (select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
117
+ escalation_id, observer_agent) from _o3_fixture),
118
+ '42501', 'task mutation requires a non-observer board role, or task assignment/creation',
119
+ 'observer cannot resolve_escalation on a board-thread task (transitive)');
120
+
121
+ -- ALLOW twins: the assignee/creator carve-out preserves legitimate workers -------------------
122
+ -- A NON-board-member assignee may work their assigned task (the over-block this rule prevents).
123
+ select set_config('request.jwt.claim.sub', (select nonmember_user from _o3_fixture)::text, true);
124
+ select lives_ok(
125
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
126
+ assignee_task, nonmember_agent) from _o3_fixture),
127
+ 'a non-member ASSIGNEE may mutate their assigned board-thread task (carve-out)');
128
+
129
+ -- An observer who is the ASSIGNEE may mutate that task (assignment authorizes it).
130
+ select set_config('request.jwt.claim.sub', (select observer_user from _o3_fixture)::text, true);
131
+ select lives_ok(
132
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
133
+ observer_task, observer_agent) from _o3_fixture),
134
+ 'an observer who is the assignee may mutate that task (carve-out)');
135
+
136
+ -- A non-observer member may mutate any board-thread task.
137
+ select set_config('request.jwt.claim.sub', (select member_user from _o3_fixture)::text, true);
138
+ select lives_ok(
139
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
140
+ member_task, member_agent) from _o3_fixture),
141
+ 'a non-observer member may mutate a board-thread task');
142
+
143
+ select lives_ok(
144
+ (select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
145
+ conflict_id, member_agent) from _o3_fixture),
146
+ 'a member may resolve_conflict on a board-thread task (transitive path works for a legit actor)');
147
+
148
+ -- board-less task: no board link -> access-only, no role gate (no regression).
149
+ select lives_ok(
150
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
151
+ boardless_task, member_agent) from _o3_fixture),
152
+ 'a board-less task is unaffected by the mutate gate (access-only)');
153
+
154
+ -- service_role carve-out.
155
+ select set_config('request.jwt.claim.role', 'service_role', true);
156
+ select set_config('request.jwt.claim.sub', '', true);
157
+ select lives_ok(
158
+ (select format($q$select agent_board.append_task_event(%L::uuid, %L::uuid, 'commented')$q$,
159
+ member_task, observer_agent) from _o3_fixture),
160
+ 'service_role may mutate a board-thread task regardless of role (carve-out)');
161
+
162
+ -- the new SECURITY DEFINER predicate carries no PUBLIC EXECUTE (MBHARDEN #118).
163
+ select set_config('request.jwt.claim.role', '', true);
164
+ select ok(
165
+ not has_function_privilege('public', 'agent_board._current_agent_can_mutate_task(uuid)', 'EXECUTE'),
166
+ '_current_agent_can_mutate_task carries no PUBLIC EXECUTE (class invariant)');
167
+
168
+ select * from finish();
169
+
170
+ rollback;
@@ -0,0 +1,196 @@
1
+ -- OBSGATE-4 (#127) pgTAP: a claim on a TASK resource requires task-mutate rights (closing the
2
+ -- squat), and a task-less resolve_conflict/resolve_escalation is gated on the board posting rule
3
+ -- (and no longer errors on the no-task path).
4
+ --
5
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role(),
6
+ -- request.jwt.claim.sub picks the actor.
7
+ --
8
+ -- DENY assertions are the mutation pins (append/_current_agent_can_mutate_task on acquire; the
9
+ -- posting gate on task-less resolve). ALLOW twins guard over-block and prove the v_event no-task fix.
10
+ create extension if not exists pgtap with schema extensions;
11
+ set search_path = extensions, public, agent_board;
12
+
13
+ begin;
14
+
15
+ select plan(13);
16
+
17
+ create temp table _o4_fixture (
18
+ board uuid,
19
+ member_agent uuid,
20
+ observer_agent uuid,
21
+ nonmember_agent uuid,
22
+ member_task uuid,
23
+ member_task2 uuid,
24
+ assignee_task uuid,
25
+ restricted_task uuid,
26
+ board_thread uuid,
27
+ taskless_conflict uuid,
28
+ taskless_escalation uuid,
29
+ member_user uuid,
30
+ observer_user uuid,
31
+ nonmember_user uuid
32
+ ) on commit drop;
33
+
34
+ do $$
35
+ declare
36
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
37
+ v_member_user uuid; v_observer_user uuid; v_nonmember_user uuid;
38
+ v_creator uuid := gen_random_uuid();
39
+ v_member uuid := gen_random_uuid();
40
+ v_observer uuid := gen_random_uuid();
41
+ v_nonmember uuid := gen_random_uuid();
42
+ v_board uuid;
43
+ v_board_thread uuid := gen_random_uuid();
44
+ v_member_task uuid; v_member_task2 uuid; v_assignee_task uuid; v_restricted_task uuid;
45
+ v_restricted_thread uuid := gen_random_uuid();
46
+ v_conflict uuid; v_escalation uuid;
47
+ begin
48
+ v_member_user := public.ensure_github_user('o4-member-' || v_suffix, 'o4-member-' || v_suffix || '@example.test', 'o4-member-' || v_suffix, 'O4 Member');
49
+ v_observer_user := public.ensure_github_user('o4-observer-' || v_suffix, 'o4-observer-' || v_suffix || '@example.test', 'o4-observer-' || v_suffix, 'O4 Observer');
50
+ v_nonmember_user := public.ensure_github_user('o4-nonmember-' || v_suffix, 'o4-nonmember-' || v_suffix || '@example.test', 'o4-nonmember-' || v_suffix, 'O4 Nonmember');
51
+
52
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
53
+ values
54
+ (v_creator, 'agent', 'o4-creator-' || v_suffix, 'O4 Creator', 'active', null, null),
55
+ (v_member, 'agent', 'o4-member-' || v_suffix, 'O4 Member', 'active', v_member_user, v_member_user),
56
+ (v_observer, 'agent', 'o4-observer-' || v_suffix, 'O4 Observer', 'active', v_observer_user, v_observer_user),
57
+ (v_nonmember, 'agent', 'o4-nonmember-' || v_suffix, 'O4 Nonmember', 'active', v_nonmember_user, v_nonmember_user);
58
+
59
+ select board_id into v_board
60
+ from agent_board.create_board(v_creator, 'o4-board-' || v_suffix, 'O4 Board', 'obsgate4 test', '{}'::jsonb, 'private');
61
+
62
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
63
+ values
64
+ (v_board, v_member, 'joined', v_creator, 'member'),
65
+ (v_board, v_observer, 'joined', v_creator, 'observer');
66
+
67
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
68
+ values (v_board_thread, 'coordination', v_member, 'O4 board thread');
69
+
70
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
71
+ perform set_config('request.jwt.claim.sub', v_member_user::text, true);
72
+ perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
73
+
74
+ select task_id into v_member_task from agent_board.create_task(v_board_thread, v_member, 'member task', 'd');
75
+ select task_id into v_member_task2 from agent_board.create_task(v_board_thread, v_member, 'member task 2', 'd');
76
+ select task_id into v_assignee_task from agent_board.create_task(v_board_thread, v_member, 'assignee task', 'd', v_nonmember);
77
+ -- task-LESS conflict + escalation on the board thread (raised by the member; #123 permits).
78
+ select conflict_id into v_conflict from agent_board.raise_conflict(v_board_thread, v_member, 'c');
79
+ select escalation_id into v_escalation from agent_board.raise_escalation(v_board_thread, v_member, 'e');
80
+
81
+ -- A RESTRICTED thread the member is NOT on, board-stamped, with a task. The member holds board
82
+ -- send-role (so _current_agent_can_mutate_task is true via _current_agent_can_post_to_thread)
83
+ -- but has NO thread access — the case where mutate != access. Set up as service_role.
84
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, access_mode)
85
+ values (v_restricted_thread, 'coordination', v_creator, 'O4 restricted thread', 'restricted');
86
+ insert into agent_board.thread_memberships(thread_id, agent_id, role, granted_by_agent_id)
87
+ values (v_restricted_thread, v_creator, 'member', v_creator);
88
+ perform set_config('request.jwt.claim.role', 'service_role', true);
89
+ perform set_config('request.jwt.claim.sub', '', true);
90
+ perform agent_board.post_board_message(v_restricted_thread, v_creator, v_board, 'seed', 'seed');
91
+ select task_id into v_restricted_task from agent_board.create_task(v_restricted_thread, v_creator, 'restricted task', 'd');
92
+
93
+ insert into _o4_fixture values
94
+ (v_board, v_member, v_observer, v_nonmember, v_member_task, v_member_task2, v_assignee_task,
95
+ v_restricted_task, v_board_thread, v_conflict, v_escalation, v_member_user, v_observer_user, v_nonmember_user);
96
+ end
97
+ $$;
98
+
99
+ -- DENY (mutation pins): a task-resource claim requires task-mutate rights -----
100
+ select set_config('request.jwt.claim.role', 'authenticated', true);
101
+ select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
102
+
103
+ -- the squat: resource_kind='task', resource_key=<task id>, NO task/thread context
104
+ select throws_ok(
105
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
106
+ observer_agent, member_task::text) from _o4_fixture),
107
+ '42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
108
+ 'observer cannot squat a task-resource claim with no context (DoS closed)');
109
+
110
+ select throws_ok(
111
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', null, %L::uuid)$q$,
112
+ observer_agent, member_task::text, board_thread) from _o4_fixture),
113
+ '42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
114
+ 'observer cannot squat a task-resource claim via the thread path either');
115
+
116
+ select throws_ok(
117
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
118
+ observer_agent, member_task::text, member_task, board_thread) from _o4_fixture),
119
+ '42501', 'claiming a task resource requires a non-observer board role, or task assignment/creation',
120
+ 'observer cannot acquire a task-bound claim on a board-thread task');
121
+
122
+ -- cross-task: the gate authorizes the LOCKED task (resource_key), not p_task_id. An assignee with
123
+ -- mutate rights on their OWN task cannot squat a DIFFERENT task's lock by passing their task as
124
+ -- p_task_id. (nonmember is assignee of assignee_task, but not a party to member_task2.)
125
+ select set_config('request.jwt.claim.sub', (select nonmember_user from _o4_fixture)::text, true);
126
+ select throws_ok(
127
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
128
+ nonmember_agent, member_task2::text, assignee_task, board_thread) from _o4_fixture),
129
+ '42501', NULL,
130
+ 'an assignee cannot squat a different task''s lock via p_task_id (gate keys on resource_key)');
131
+
132
+ -- restricted thread: a board member with mutate (send-role) but NO thread access cannot squat the
133
+ -- restricted task's claim — the gate composes ACCESS + MUTATE like append_task_event (round-5 codex).
134
+ select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
135
+ select throws_ok(
136
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
137
+ member_agent, restricted_task::text) from _o4_fixture),
138
+ '42501', 'task not accessible to current agent',
139
+ 'a board member without restricted-thread access cannot squat that task''s claim (access gate)');
140
+
141
+ -- ALLOW twins -------------------------------------------------------------------------------
142
+ select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
143
+ select lives_ok(
144
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid, %L::uuid)$q$,
145
+ member_agent, member_task::text, member_task, board_thread) from _o4_fixture),
146
+ 'a member may acquire a task-resource claim');
147
+
148
+ select set_config('request.jwt.claim.sub', (select nonmember_user from _o4_fixture)::text, true);
149
+ select lives_ok(
150
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour', %L::uuid)$q$,
151
+ nonmember_agent, assignee_task::text, assignee_task) from _o4_fixture),
152
+ 'a non-member ASSIGNEE may acquire their assigned task''s resource claim (carve-out)');
153
+
154
+ -- task-less resolve: observer denied, member allowed (+ v_event no-task fix) ------------------
155
+ select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
156
+ select throws_ok(
157
+ (select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
158
+ taskless_conflict, observer_agent) from _o4_fixture),
159
+ '42501', 'thread posting requires a non-observer board role',
160
+ 'observer cannot resolve a task-less conflict on a board thread');
161
+
162
+ select throws_ok(
163
+ (select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
164
+ taskless_escalation, observer_agent) from _o4_fixture),
165
+ '42501', 'thread posting requires a non-observer board role',
166
+ 'observer cannot resolve a task-less escalation on a board thread');
167
+
168
+ select set_config('request.jwt.claim.sub', (select member_user from _o4_fixture)::text, true);
169
+ select lives_ok(
170
+ (select format($q$select agent_board.resolve_conflict(%L::uuid, %L::uuid)$q$,
171
+ taskless_conflict, member_agent) from _o4_fixture),
172
+ 'a member may resolve a task-less conflict (v_event no-task fix — no 55000)');
173
+
174
+ select lives_ok(
175
+ (select format($q$select agent_board.resolve_escalation(%L::uuid, %L::uuid)$q$,
176
+ taskless_escalation, member_agent) from _o4_fixture),
177
+ 'a member may resolve a task-less escalation (v_event no-task fix)');
178
+
179
+ -- scope boundary: a NON-task resource claim is not gated by OBSGATE (out of scope) -----------
180
+ select set_config('request.jwt.claim.sub', (select observer_user from _o4_fixture)::text, true);
181
+ select lives_ok(
182
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'custom', 'o4-custom-key', now() + interval '1 hour')$q$,
183
+ observer_agent) from _o4_fixture),
184
+ 'a non-task resource claim is not gated by OBSGATE-4 (coordination-authz, out of scope)');
185
+
186
+ -- service_role carve-out.
187
+ select set_config('request.jwt.claim.role', 'service_role', true);
188
+ select set_config('request.jwt.claim.sub', '', true);
189
+ select lives_ok(
190
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'task', %L, now() + interval '1 hour')$q$,
191
+ member_agent, member_task2::text) from _o4_fixture),
192
+ 'service_role may acquire a task-resource claim regardless of role (carve-out)');
193
+
194
+ select * from finish();
195
+
196
+ rollback;
@@ -53,8 +53,8 @@ begin
53
53
  -- A SECOND signing key (key_version 2; enroll auto-enrolled version 1) + a pending
54
54
  -- challenge bound to it, so the mint step below can drive PoP -> mint deterministically.
55
55
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
56
- values (v_key, v_agent, 'primary', 2, repeat('A',43), 'raw_ed25519_base64url',
57
- 'sha256:'||rpad(replace(v_key::text,'-',''),43,'A'), 'active', 'proven');
56
+ values (v_key, v_agent, 'primary', 2, public.test_pubkey(v_key::text), 'raw_ed25519_base64url',
57
+ public.test_key_fingerprint(v_key::text), 'active', 'proven');
58
58
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at, consumed_at)
59
59
  values (v_ch, v_key, v_agent, v_ch::text, 'agent-signing-key-proof.v1:'||v_ch||':n', 'pending', now() + interval '10 minutes', null);
60
60
 
@@ -97,6 +97,17 @@ select lives_ok(
97
97
  select set_config('request.jwt.claim.role', '', true);
98
98
  select set_config('request.jwt.claim.sub', '', true);
99
99
 
100
+ -- The send LANDED, not merely raised no exception (message-board#87): the prior
101
+ -- lives_ok proves no error, but the header's "board writes are unaffected" claim
102
+ -- needs the fan-out write to be observable. Assert the board post produced board
103
+ -- delivery rows for the board — the write path executed Portal-offline.
104
+ select ok(
105
+ exists(
106
+ select 1 from agent_board.delivery_recipients
107
+ where board_id = (select board from _pofx)
108
+ and delivery_mode = 'board'),
109
+ 'the Portal-offline board-member send landed board delivery rows (board writes unaffected)');
110
+
100
111
  -- revoke: the lifecycle terminates cleanly; the agent is no longer active. ---------
101
112
  select lives_ok(
102
113
  (select format($q$select agent_board.revoke_agent(%L::uuid, 'portal-offline lifecycle test')$q$, agent) from _pofx),
@@ -0,0 +1,53 @@
1
+ -- MBHARDEN / #118 — no agent_board SECURITY DEFINER function may carry a PUBLIC EXECUTE
2
+ -- ACL. CREATE FUNCTION defaults EXECUTE to PUBLIC; a SECURITY DEFINER function runs as the
3
+ -- schema owner, so a stray PUBLIC grant lets any authenticated caller drive owner-privileged
4
+ -- logic. #97 closed mark_retention_metadata; 20260812000100 closes the other five.
5
+ --
6
+ -- Assertions are catalog-derived (the durable class invariant) plus two named pins for the
7
+ -- genuine holes, and one over-revoke guard. Each is mutation-pinned:
8
+ -- * removing ANY of the five revokes -> the class count becomes >= 1 -> assert 1 fails.
9
+ -- * removing the _record_signed_write_evidence / _resolve_addressing_recipient_rows revoke
10
+ -- -> its named assert flips to true -> fails.
11
+ -- * "fixing" the invariant by nuking authenticated grants instead of PUBLIC -> assert 4
12
+ -- (an intentionally authenticated SECURITY DEFINER RPC) fails.
13
+
14
+ create extension if not exists pgtap with schema extensions;
15
+ set search_path = extensions, public;
16
+
17
+ begin;
18
+
19
+ select plan(4);
20
+
21
+ -- The class invariant. PUBLIC is the `=X` pseudo-grantee; has_function_privilege('public',…)
22
+ -- is true ONLY when PUBLIC itself holds EXECUTE, so intentional named grants (e.g. to
23
+ -- authenticated) do not trip this.
24
+ select is(
25
+ (select count(*)::int
26
+ from pg_proc p
27
+ where p.pronamespace = 'agent_board'::regnamespace
28
+ and p.prosecdef
29
+ and has_function_privilege('public', p.oid, 'EXECUTE')),
30
+ 0,
31
+ 'class invariant: no agent_board SECURITY DEFINER function carries a PUBLIC EXECUTE ACL');
32
+
33
+ -- Named pin: the forge hole. Authenticated must not be able to INSERT forged signed-write
34
+ -- attestations by calling the evidence recorder directly.
35
+ select ok(
36
+ not has_function_privilege('authenticated', 'agent_board._record_signed_write_evidence(text,uuid,text,jsonb,jsonb)', 'EXECUTE'),
37
+ 'authenticated cannot execute _record_signed_write_evidence (attestation forge)');
38
+
39
+ -- Named pin: the enumeration leak. Authenticated must not be able to run the actor-spoofable
40
+ -- directory resolver directly, bypassing resolve_recipients'' session rebind.
41
+ select ok(
42
+ not has_function_privilege('authenticated', 'agent_board._resolve_addressing_recipient_rows(uuid,jsonb)', 'EXECUTE'),
43
+ 'authenticated cannot execute _resolve_addressing_recipient_rows (directory leak)');
44
+
45
+ -- Over-revoke guard: the invariant targets PUBLIC, not intended named grants. A SECURITY
46
+ -- DEFINER RPC deliberately granted to authenticated must keep working.
47
+ select ok(
48
+ has_function_privilege('authenticated', 'agent_board.get_schema_version()', 'EXECUTE'),
49
+ 'intended authenticated grant on a SECURITY DEFINER RPC is preserved');
50
+
51
+ select * from finish();
52
+
53
+ rollback;