@consiliency/agent-board-schema 1.12.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 (156) hide show
  1. package/fixtures/already-applied.json +160 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  34. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  35. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  36. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  37. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  38. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  39. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  40. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  41. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  42. package/package.json +1 -1
  43. package/schema-manifest.json +363 -3
  44. package/src/tooling.test.mjs +113 -2
  45. package/tests/agent_board_actorspoof.test.sql +349 -0
  46. package/tests/agent_board_agentsdir.test.sql +154 -0
  47. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  48. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  49. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  50. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  51. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  52. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  53. package/tests/agent_board_claimkey.test.sql +229 -0
  54. package/tests/agent_board_consent_backfill.test.sql +48 -0
  55. package/tests/agent_board_consent_emission.test.sql +601 -0
  56. package/tests/agent_board_consent_gating.test.sql +489 -0
  57. package/tests/agent_board_consent_substrate.test.sql +720 -0
  58. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  59. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  60. package/tests/agent_board_delegation_policies.test.sql +107 -1
  61. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  62. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  63. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  64. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  65. package/tests/agent_board_fedsession.test.sql +51 -5
  66. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  67. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  68. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  69. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  70. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  71. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  72. package/tests/agent_board_fleet_policy.test.sql +2 -2
  73. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  74. package/tests/agent_board_identity_providers.test.sql +9 -0
  75. package/tests/agent_board_inbox_reads.test.sql +45 -31
  76. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  77. package/tests/agent_board_invitepolicy.test.sql +205 -0
  78. package/tests/agent_board_leaveidem.test.sql +194 -0
  79. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  80. package/tests/agent_board_mint_harden.test.sql +303 -0
  81. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  82. package/tests/agent_board_obsgate.test.sql +178 -0
  83. package/tests/agent_board_obsgate2.test.sql +153 -0
  84. package/tests/agent_board_obsgate3.test.sql +170 -0
  85. package/tests/agent_board_obsgate4.test.sql +196 -0
  86. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  87. package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
  88. package/tests/agent_board_pubexec_class.test.sql +53 -0
  89. package/tests/agent_board_rejectsem.test.sql +271 -0
  90. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  91. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  92. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  93. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  94. package/tests/agent_board_rolereset.test.sql +351 -0
  95. package/tests/agent_board_roomdeleg.test.sql +5 -3
  96. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  97. package/tests/agent_board_roomlink.test.sql +27 -11
  98. package/tests/agent_board_rowid.test.sql +1274 -0
  99. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  100. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  101. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  102. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  103. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  104. package/tests/agent_board_signing_store.test.sql +6 -6
  105. package/tests/agent_board_wake_recovery.test.sql +756 -0
  106. package/tests/fixtures/isolation-substrate.sql +93 -0
  107. package/tests/fixtures/test-prereqs.sql +33 -0
  108. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  109. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  110. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  111. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  112. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  113. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  114. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  115. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  116. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  117. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  118. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  119. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  120. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  121. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  122. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  123. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  124. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  125. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  126. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  127. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  128. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  129. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  130. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  131. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  132. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  133. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  134. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  135. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  136. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  137. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  138. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  139. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  140. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  141. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  142. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  143. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  144. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  145. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  146. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  147. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  148. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  149. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  150. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  151. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  152. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  153. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  154. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  155. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
  156. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -80,17 +80,17 @@ begin
80
80
  -- Signing keys.
81
81
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
82
82
  values
83
- (k_a1, v_a1, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_a1::text,'-',''),43,'A'), 'active', 'proven'),
84
- (k_a5, v_a5, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_a5::text,'-',''),43,'A'), 'active', 'proven'),
85
- (k_csusp, v_csusp, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_csusp::text,'-',''),43,'A'), 'active', 'proven'),
86
- (k_a7, v_a7, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_a7::text,'-',''),43,'A'), 'active', 'proven'),
87
- (k_c2, v_c2, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_c2::text,'-',''),43,'A'), 'active', 'proven');
83
+ (k_a1, v_a1, 'primary', 1, public.test_pubkey(k_a1::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_a1::text), 'active', 'proven'),
84
+ (k_a5, v_a5, 'primary', 1, public.test_pubkey(k_a5::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_a5::text), 'active', 'proven'),
85
+ (k_csusp, v_csusp, 'primary', 1, public.test_pubkey(k_csusp::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_csusp::text), 'active', 'proven'),
86
+ (k_a7, v_a7, 'primary', 1, public.test_pubkey(k_a7::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_a7::text), 'active', 'proven'),
87
+ (k_c2, v_c2, 'primary', 1, public.test_pubkey(k_c2::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_c2::text), 'active', 'proven');
88
88
  -- Revoked key.
89
89
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state, revoked_at)
90
- values (k_a3, v_a3, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_a3::text,'-',''),43,'A'), 'revoked', 'revoked', now());
90
+ values (k_a3, v_a3, 'primary', 1, public.test_pubkey(k_a3::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_a3::text), 'revoked', 'revoked', now());
91
91
  -- Expired key.
92
92
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
93
- values (k_a4, v_a4, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_a4::text,'-',''),43,'A'), 'expired', 'expired');
93
+ values (k_a4, v_a4, 'primary', 1, public.test_pubkey(k_a4::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_a4::text), 'expired', 'expired');
94
94
 
95
95
  -- Challenges. Pending + unexpired unless noted.
96
96
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at, consumed_at)
@@ -156,7 +156,7 @@ begin
156
156
  insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id, granted_scopes, grant_expires_at)
157
157
  values (v_ce, v_root, v_root, v_pol, jsonb_build_array('scope:a'), now() - interval '1 minute');
158
158
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
159
- values (k_ce, v_ce, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_ce::text,'-',''),43,'A'), 'active', 'proven');
159
+ values (k_ce, v_ce, 'primary', 1, public.test_pubkey(k_ce::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_ce::text), 'active', 'proven');
160
160
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at)
161
161
  values (ch_ce, k_ce, v_ce, ch_ce::text, 'x', 'pending', now() + interval '10 minutes');
162
162
 
@@ -166,7 +166,7 @@ begin
166
166
  insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id, granted_scopes, grant_expires_at)
167
167
  values (v_cl, v_root, v_root, v_pol, jsonb_build_array('scope:a'), now() + interval '2 hours');
168
168
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
169
- values (k_cl, v_cl, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_cl::text,'-',''),43,'A'), 'active', 'proven');
169
+ values (k_cl, v_cl, 'primary', 1, public.test_pubkey(k_cl::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_cl::text), 'active', 'proven');
170
170
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at)
171
171
  values
172
172
  (ch_cl, k_cl, v_cl, ch_cl::text, 'x', 'pending', now() + interval '10 minutes'),
@@ -176,7 +176,7 @@ begin
176
176
  insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id)
177
177
  values (v_et, 'agent', 'gr-et-'||s, 'ET', 'active', u_et);
178
178
  insert into agent_board.agent_signing_keys(id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state, expires_at)
179
- values (k_et, v_et, 'primary', 1, repeat('A',43), 'raw_ed25519_base64url', 'sha256:'||rpad(replace(k_et::text,'-',''),43,'A'), 'active', 'proven', now() - interval '1 minute');
179
+ values (k_et, v_et, 'primary', 1, public.test_pubkey(k_et::text), 'raw_ed25519_base64url', public.test_key_fingerprint(k_et::text), 'active', 'proven', now() - interval '1 minute');
180
180
  insert into agent_board.agent_signing_key_challenges(id, signing_key_id, agent_id, challenge_nonce, challenge_bytes, proof_state, expires_at)
181
181
  values (ch_et, k_et, v_et, ch_et::text, 'x', 'pending', now() + interval '10 minutes');
182
182
 
@@ -0,0 +1,303 @@
1
+ -- MINTHARDEN (#138) pgTAP — child-key fingerprint integrity + EF-mediated authenticated
2
+ -- minting hardening. Every property is mutation-oriented (CLAUDE.md rule #2):
3
+ --
4
+ -- * catalog-derived fingerprint-writer SWEEP (a new string-derivation writer names
5
+ -- itself) — rule #1;
6
+ -- * table-boundary integrity TRIGGER (direct DML the sweep cannot see);
7
+ -- * mint raw-byte fingerprint + cross-path dedup (same key => same fingerprint across
8
+ -- mint and enroll, so the UNIQUE backstop finally bites);
9
+ -- * non-positive-TTL floor;
10
+ -- * F1 authenticated (self-service) RE-CLAMP of max_active_children and TTL ceiling —
11
+ -- the stored caps are NOT trusted (a root can self-widen its policy, #142) — vs the
12
+ -- operator path which keeps its latitude;
13
+ -- * per-root provisioning RATE cap on the authenticated path only;
14
+ -- * EXECUTE class: mint stays service_role-only (topology A; the EF is the auth surface).
15
+ --
16
+ -- Allow-side assertions use lives_ok(...) + a separate state read (never
17
+ -- is((select ... from mint(...)))) so an allow that silently raises cannot pass green (#113).
18
+ -- Runs as postgres (service_role-equivalent); p_self_service=true simulates the EF-forwarded
19
+ -- authenticated call (the EF binds root==session; that binding is tested in the EF suite).
20
+ create extension if not exists pgtap with schema extensions;
21
+ set search_path = extensions, public, agent_board;
22
+
23
+ begin;
24
+
25
+ select plan(31);
26
+
27
+ create temp table _mh (
28
+ owner_id uuid,
29
+ r_key uuid, r_wide uuid, r_capnull uuid, r_rate uuid,
30
+ d_dup uuid, t_agent uuid,
31
+ k_dup text, k9 text, ktrig1 text, ktrig2 text
32
+ ) on commit drop;
33
+
34
+ do $$
35
+ declare
36
+ s text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
37
+ u_owner uuid;
38
+ v_r_key uuid := gen_random_uuid();
39
+ v_r_wide uuid := gen_random_uuid();
40
+ v_r_capnull uuid := gen_random_uuid();
41
+ v_r_rate uuid := gen_random_uuid();
42
+ v_d_dup uuid := gen_random_uuid();
43
+ v_t_agent uuid := gen_random_uuid();
44
+ v_k_dup text := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
45
+ v_k9 text := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
46
+ v_ktrig1 text := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
47
+ v_ktrig2 text := rtrim(translate(encode(extensions.gen_random_bytes(32), 'base64'), '+/', '-_'), '=');
48
+ v_child uuid;
49
+ i int;
50
+ begin
51
+ u_owner := public.ensure_github_user('mh-own-'||s, 'mh-own-'||s||'@example.test', 'mh-own-'||s, 'MH Owner');
52
+
53
+ -- r_key: root_minted, requires a child signing key, uncapped -> fingerprint + dedup + key tests.
54
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
55
+ values (v_r_key, 'agent', 'mh-rkey-'||s, 'MH RKey', 'active', u_owner);
56
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
57
+ values (v_r_key, 'root_minted', 1, jsonb_build_array('scope:a'), interval '8 hours', true);
58
+
59
+ -- r_wide: operator_vetted, uncapped, 30-day ceiling, no key -> F1 TTL clamp (op vs self).
60
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
61
+ values (v_r_wide, 'agent', 'mh-rwide-'||s, 'MH RWide', 'active', u_owner);
62
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
63
+ values (v_r_wide, 'root_minted', 1, jsonb_build_array('scope:a'), interval '30 days', false);
64
+
65
+ -- r_capnull: operator-set max_active_children=100 (a self-widened row), no key -> F1 cap
66
+ -- clamp. Pre-load 25 LIVE grants: self-service clamps to 25 (raise); operator keeps 100.
67
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
68
+ values (v_r_capnull, 'agent', 'mh-rcap-'||s, 'MH RCap', 'active', u_owner);
69
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, max_active_children, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
70
+ values (v_r_capnull, 'root_minted', 1, 100, jsonb_build_array('scope:a'), interval '8 hours', false);
71
+ for i in 1..25 loop
72
+ v_child := gen_random_uuid();
73
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
74
+ values (v_child, 'agent', 'mh-cap-child-'||s||'-'||i, 'MH Cap Child', 'active', u_owner);
75
+ insert into agent_board.delegation_grants(child_agent_id, root_agent_id, parent_agent_id, delegation_policy_id, granted_scopes, grant_expires_at)
76
+ select v_child, v_r_capnull, v_r_capnull, dp.id, '[]'::jsonb, now() + interval '1 hour'
77
+ from agent_board.delegation_policies dp where dp.root_agent_id = v_r_capnull;
78
+ end loop;
79
+
80
+ -- r_rate: uncapped, no key -> rate cap test. Pre-load 25 rate events (at the cap).
81
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
82
+ values (v_r_rate, 'agent', 'mh-rrate-'||s, 'MH RRate', 'active', u_owner);
83
+ insert into agent_board.delegation_policies(root_agent_id, mode, max_depth, allowed_scopes, child_ttl_ceiling, require_child_signing_key)
84
+ values (v_r_rate, 'root_minted', 1, jsonb_build_array('scope:a'), interval '8 hours', false);
85
+ for i in 1..25 loop
86
+ insert into agent_board.child_mint_rate_events(root_agent_id, created_at) values (v_r_rate, now());
87
+ end loop;
88
+
89
+ -- d_dup: a dummy agent already holding the raw key k_dup (helper fingerprint) -> the
90
+ -- cross-path dedup collision target.
91
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
92
+ values (v_d_dup, 'agent', 'mh-ddup-'||s, 'MH DDup', 'active', u_owner);
93
+ insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
94
+ values (v_d_dup, 'primary', 1, v_k_dup, 'raw_ed25519_base64url', agent_board._raw_key_fingerprint(v_k_dup), 'active', 'proven');
95
+
96
+ -- t_agent: holder for the trigger integrity tests.
97
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, owner_user_id)
98
+ values (v_t_agent, 'agent', 'mh-trig-'||s, 'MH Trig', 'active', u_owner);
99
+
100
+ insert into _mh values (u_owner, v_r_key, v_r_wide, v_r_capnull, v_r_rate, v_d_dup, v_t_agent,
101
+ v_k_dup, v_k9, v_ktrig1, v_ktrig2);
102
+ end
103
+ $$;
104
+
105
+ -- ===========================================================================
106
+ -- A. Catalog-derived fingerprint-writer sweep (rule #1) + helper derivation.
107
+ -- ===========================================================================
108
+ select has_function('agent_board', '_raw_key_fingerprint', array['text'], '_raw_key_fingerprint exists');
109
+
110
+ -- No function INSERTing an agent_signing_keys.fingerprint bypasses the shared helper —
111
+ -- a new divergent writer appears in this string and names itself.
112
+ -- The MATERIALIZED CTE restricts pg_get_functiondef to agent_board NORMAL functions
113
+ -- (prokind='f') first — otherwise the planner may evaluate it over pg_catalog aggregates
114
+ -- (array_agg) and raise "X is an aggregate function".
115
+ select is(
116
+ (with fns as materialized (
117
+ select p.oid, p.proname
118
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
119
+ where n.nspname = 'agent_board' and p.prokind = 'f')
120
+ select coalesce(string_agg(proname, ', ' order by proname), '')
121
+ from fns
122
+ where pg_get_functiondef(oid) ~* 'insert[[:space:]]+into[[:space:]]+(agent_board\.)?agent_signing_keys'
123
+ and pg_get_functiondef(oid) ~ 'fingerprint'
124
+ and pg_get_functiondef(oid) !~ '_raw_key_fingerprint'),
125
+ '',
126
+ 'no agent_signing_keys.fingerprint writer bypasses _raw_key_fingerprint (catalog-derived; a new writer names itself)');
127
+
128
+ -- Positive: both live fingerprint writers derive through the helper.
129
+ select ok(
130
+ (with fns as materialized (
131
+ select p.oid
132
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
133
+ where n.nspname = 'agent_board' and p.prokind = 'f'
134
+ and p.proname in ('mint_delegated_child', 'enroll_federated_identity'))
135
+ select bool_and(pg_get_functiondef(oid) ~ '_raw_key_fingerprint') from fns),
136
+ 'mint_delegated_child and enroll_federated_identity both derive via _raw_key_fingerprint');
137
+
138
+ -- The helper derives over the RAW bytes, not the base64url string (revert => this flips).
139
+ select is(
140
+ (select agent_board._raw_key_fingerprint(k9) from _mh),
141
+ (select 'sha256:' || rtrim(translate(encode(extensions.digest(
142
+ decode(translate(k9, '-_', '+/') || '=', 'base64'), 'sha256'), 'base64'), '+/', '-_'), '=') from _mh),
143
+ '_raw_key_fingerprint hashes the RAW 32 bytes');
144
+ select ok(
145
+ (select agent_board._raw_key_fingerprint(k9) <> 'sha256:' || rtrim(translate(encode(extensions.digest(
146
+ convert_to(k9, 'UTF8'), 'sha256'), 'base64'), '+/', '-_'), '=') from _mh),
147
+ 'the raw-byte fingerprint differs from the retired base64url-STRING derivation');
148
+
149
+ -- ===========================================================================
150
+ -- B. Table-boundary integrity trigger (catches direct/dynamic DML).
151
+ -- ===========================================================================
152
+ -- A raw key with a MISMATCHED fingerprint is rejected at the table boundary.
153
+ select throws_ok(
154
+ (select format($q$insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
155
+ values (%L::uuid, 'bad', 1, %L, 'raw_ed25519_base64url', 'sha256:'||repeat('Z',43), 'active', 'proven')$q$,
156
+ t_agent, ktrig1) from _mh),
157
+ '23514', null, 'trigger rejects a raw key whose fingerprint != the raw-byte derivation');
158
+
159
+ -- A raw key with the CORRECT fingerprint is accepted (lives_ok + state read, #113).
160
+ select lives_ok(
161
+ (select format($q$insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
162
+ values (%L::uuid, 'good', 1, %L, 'raw_ed25519_base64url', agent_board._raw_key_fingerprint(%L), 'active', 'proven')$q$,
163
+ t_agent, ktrig1, ktrig1) from _mh),
164
+ 'trigger accepts a raw key whose fingerprint matches the derivation');
165
+ select is(
166
+ (select fingerprint from agent_board.agent_signing_keys k, _mh
167
+ where k.agent_id = _mh.t_agent and k.key_id = 'good'),
168
+ (select agent_board._raw_key_fingerprint(ktrig1) from _mh),
169
+ 'the accepted key stored the raw-byte fingerprint');
170
+
171
+ -- UPDATE-ing public_key to a different key while keeping the old fingerprint is rejected.
172
+ select throws_ok(
173
+ (select format($q$update agent_board.agent_signing_keys set public_key = %L
174
+ where agent_id = %L::uuid and key_id = 'good'$q$, ktrig2, t_agent) from _mh),
175
+ '23514', null, 'trigger rejects an UPDATE that swaps public_key without re-deriving the fingerprint');
176
+
177
+ -- An UPDATE that touches neither key nor fingerprint is NOT re-validated (legacy rows stay operable).
178
+ select lives_ok(
179
+ (select format($q$update agent_board.agent_signing_keys set metadata = jsonb_build_object('touched', true)
180
+ where agent_id = %L::uuid and key_id = 'good'$q$, t_agent) from _mh),
181
+ 'trigger skips an unrelated-column UPDATE (no key/fingerprint change)');
182
+
183
+ -- MUST-FIX A: a public_key_format TOGGLE cannot smuggle a mismatched fingerprint into a raw
184
+ -- row. Non-raw formats skip validation, so every transition INTO the raw format is validated.
185
+ select lives_ok(
186
+ (select format($q$insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
187
+ values (%L::uuid, 'toggle', 2, %L, 'raw_ed25519_base64url', agent_board._raw_key_fingerprint(%L), 'active', 'proven')$q$,
188
+ t_agent, ktrig2, ktrig2) from _mh),
189
+ 'seed a valid raw key for the format-toggle test');
190
+ select lives_ok(
191
+ (select format($q$update agent_board.agent_signing_keys set public_key_format = 'spki_pem', fingerprint = 'sha256:'||repeat('Z',43)
192
+ where agent_id = %L::uuid and key_id = 'toggle'$q$, t_agent) from _mh),
193
+ 'flipping a key to a non-raw format is exempt (non-raw fingerprints are out of scope)');
194
+ select throws_ok(
195
+ (select format($q$update agent_board.agent_signing_keys set public_key_format = 'raw_ed25519_base64url'
196
+ where agent_id = %L::uuid and key_id = 'toggle'$q$, t_agent) from _mh),
197
+ '23514', null, 'flipping BACK to raw re-validates the fingerprint (toggle bypass closed)');
198
+
199
+ -- ===========================================================================
200
+ -- C. mint raw-byte fingerprint + cross-path dedup.
201
+ -- ===========================================================================
202
+ select lives_ok(
203
+ (select format($q$create temp table _m9 as select * from agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), %L)$q$,
204
+ r_key, 'mh-c9-'||replace(substr(gen_random_uuid()::text,1,8),'-',''), k9) from _mh),
205
+ 'operator mint with a signing key succeeds');
206
+ select is(
207
+ (select k.fingerprint from agent_board.agent_signing_keys k
208
+ where k.agent_id = (select child_agent_id from _m9)),
209
+ (select agent_board._raw_key_fingerprint(k9) from _mh),
210
+ 'minted child key stored the RAW-byte fingerprint (single-sourced with enroll)');
211
+
212
+ -- Cross-path dedup: minting a child with a key already registered (d_dup) collides on the
213
+ -- shared raw fingerprint. Under the retired string derivation the fingerprints differed and
214
+ -- this mint SUCCEEDED — the mutation that reverts mint to string form flips this to a pass.
215
+ select throws_ok(
216
+ (select format($q$select agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), %L)$q$,
217
+ r_key, 'mh-dup-'||replace(substr(gen_random_uuid()::text,1,8),'-',''), k_dup) from _mh),
218
+ '23505', null, 'minting a child with an already-registered key is rejected (shared raw fingerprint dedup)');
219
+
220
+ -- ===========================================================================
221
+ -- D. Non-positive-TTL floor (unconditional).
222
+ -- ===========================================================================
223
+ select throws_ok(
224
+ (select format($q$select agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, interval '0')$q$,
225
+ r_wide, 'mh-t0-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
226
+ '22023', 'non_positive_ttl: requested grant TTL must be positive',
227
+ 'mint rejects a zero TTL (immediately-expired grant escapes the child count)');
228
+ select throws_ok(
229
+ (select format($q$select agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, interval '-1 hour')$q$,
230
+ r_wide, 'mh-tn-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
231
+ '22023', 'non_positive_ttl: requested grant TTL must be positive',
232
+ 'mint rejects a negative TTL');
233
+
234
+ -- ===========================================================================
235
+ -- E. F1 — authenticated (self-service) re-clamp vs operator latitude.
236
+ -- ===========================================================================
237
+ -- Operator path: the 30-day stored ceiling is honored (no clamp).
238
+ select lives_ok(
239
+ (select format($q$create temp table _m_op_ttl as select * from agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, false)$q$,
240
+ r_wide, 'mh-opttl-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
241
+ 'operator mint under a 30-day ceiling succeeds');
242
+ select ok(
243
+ (select grant_expires_at > now() + interval '25 days' from _m_op_ttl),
244
+ 'operator path is NOT TTL-clamped (grant honors the 30-day ceiling)');
245
+
246
+ -- Self-service path: the ceiling is re-clamped to <= 24h regardless of the stored 30 days.
247
+ select lives_ok(
248
+ (select format($q$create temp table _m_ss_ttl as select * from agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, true)$q$,
249
+ r_wide, 'mh-ssttl-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
250
+ 'self-service mint under a 30-day stored ceiling succeeds');
251
+ select ok(
252
+ (select grant_expires_at <= now() + interval '24 hours' + interval '1 minute' from _m_ss_ttl),
253
+ 'self-service path re-clamps the TTL ceiling to <= 24h (stored 30-day ceiling NOT trusted)');
254
+
255
+ -- Self-service cap: stored max_active_children=100 is re-clamped to 25; with 25 live grants
256
+ -- the self-service mint is refused (the mutation dropping the clamp lets a 26th through).
257
+ select throws_ok(
258
+ (select format($q$select agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, true)$q$,
259
+ r_capnull, 'mh-sscap-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
260
+ '42501', 'max_active_children: root has reached its active delegated-child ceiling',
261
+ 'self-service re-clamps max_active_children to 25 (stored 100 NOT trusted)');
262
+
263
+ -- Operator path under the SAME 100-cap policy + 25 live grants still mints (its latitude).
264
+ select lives_ok(
265
+ (select format($q$create temp table _m_op_cap as select * from agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, false)$q$,
266
+ r_capnull, 'mh-opcap-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
267
+ 'operator path honors the stored 100 cap (25 < 100 => mint succeeds)');
268
+ select is((select count(*) from _m_op_cap), 1::bigint,
269
+ 'operator cap mint produced exactly one child (not a silent zero-row pass)');
270
+
271
+ -- ===========================================================================
272
+ -- F. Per-root provisioning RATE cap (authenticated path only).
273
+ -- ===========================================================================
274
+ -- r_rate has 25 rate events (at the cap) and 0 live grants, so only the rate cap can fire.
275
+ select throws_ok(
276
+ (select format($q$select agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, true)$q$,
277
+ r_rate, 'mh-ssrate-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
278
+ '42501', 'mint_rate_exceeded: root has reached its provisioning rate cap for the current window',
279
+ 'self-service mint is refused once the per-root provisioning rate cap is reached');
280
+
281
+ -- The operator path is NOT rate-capped (the same full rate table does not block it).
282
+ select lives_ok(
283
+ (select format($q$create temp table _m_op_rate as select * from agent_board.mint_delegated_child(%L::uuid, %L, jsonb_build_array('scope:a'), null, null, false)$q$,
284
+ r_rate, 'mh-oprate-'||replace(substr(gen_random_uuid()::text,1,8),'-','')) from _mh),
285
+ 'operator path is not rate-capped (mints despite a full rate table)');
286
+ select is((select count(*) from _m_op_rate), 1::bigint,
287
+ 'operator rate-path mint produced exactly one child (not a silent zero-row pass)');
288
+
289
+ -- ===========================================================================
290
+ -- G. EXECUTE class — mint stays service_role-only (topology A; EF is the auth surface).
291
+ -- ===========================================================================
292
+ select ok(not has_function_privilege('authenticated',
293
+ 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean)', 'EXECUTE'),
294
+ 'authenticated has NO direct EXECUTE on mint_delegated_child (EF-mediated only)');
295
+ select ok(not has_function_privilege('anon',
296
+ 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean)', 'EXECUTE'),
297
+ 'anon has NO EXECUTE on mint_delegated_child');
298
+ select ok(has_function_privilege('service_role',
299
+ 'agent_board.mint_delegated_child(uuid, text, jsonb, text, interval, boolean)', 'EXECUTE'),
300
+ 'service_role retains EXECUTE on mint_delegated_child');
301
+
302
+ select finish();
303
+ rollback;
@@ -0,0 +1,95 @@
1
+ -- MINTHARDEN (#138) pgTAP — data-derived fingerprint BACKFILL (20260822000200).
2
+ --
3
+ -- Behavioural (message-board#103 seed hook): tests/seeds/20260822000200_*.pre.sql planted a
4
+ -- string-era mint child key (canonical public_key, base64url-STRING fingerprint) BEFORE the
5
+ -- backfill ran. These assertions prove the SHIPPED backfill re-derived it to the raw-byte
6
+ -- fingerprint — a mutation removing the re-derivation UPDATE leaves the stale value and the
7
+ -- first assertion fails. The canonicalization helper and the fail-closed cross-owner
8
+ -- collision report are pinned directly (the collision path cannot be exercised as a live
9
+ -- migration abort without failing the replay, so its DETECTOR is pinned on constructed data).
10
+ create extension if not exists pgtap with schema extensions;
11
+ set search_path = extensions, public, agent_board;
12
+
13
+ begin;
14
+ select plan(10);
15
+
16
+ -- Existence.
17
+ select has_function('agent_board', '_raw_key_canonical', array['text'], '_raw_key_canonical exists');
18
+ select has_function('agent_board', '_raw_fingerprint_backfill_collisions', '_raw_fingerprint_backfill_collisions exists');
19
+
20
+ -- (1) The seeded string-era row was re-derived to the RAW-byte fingerprint.
21
+ select is(
22
+ (select fingerprint from agent_board.agent_signing_keys where id = 'fb111111-0000-4b11-8000-000000000201'),
23
+ public.test_key_fingerprint('fbfill-legacy-1'),
24
+ 'backfill re-derived the string-era mint key fingerprint to the raw-byte value');
25
+
26
+ -- (2) ...and it is NOT the retired base64url-STRING derivation any more.
27
+ select ok(
28
+ (select fingerprint <> 'sha256:' || rtrim(translate(encode(
29
+ extensions.digest(convert_to(public.test_pubkey('fbfill-legacy-1'), 'UTF8'), 'sha256'), 'base64'), '+/', '-_'), '=')
30
+ from agent_board.agent_signing_keys where id = 'fb111111-0000-4b11-8000-000000000201'),
31
+ 'the backfilled fingerprint is no longer the base64url-STRING form');
32
+
33
+ -- (3/4/5) _raw_key_canonical: canonicalizes a non-canonical alias, is idempotent on the
34
+ -- canonical form, and returns NULL on a non-32-byte value. repeat('A',42)||'B' is a
35
+ -- non-canonical alias of the 32-zero-byte key (repeat('A',43)).
36
+ select is(agent_board._raw_key_canonical(repeat('A', 42) || 'B'), repeat('A', 43),
37
+ '_raw_key_canonical collapses a non-canonical alias to the canonical key');
38
+ select is(agent_board._raw_key_canonical(repeat('A', 43)), repeat('A', 43),
39
+ '_raw_key_canonical is idempotent on an already-canonical key');
40
+ select ok(agent_board._raw_key_canonical('too-short') is null,
41
+ '_raw_key_canonical returns NULL for a value that is not 32 raw bytes');
42
+
43
+ -- (6) Cross-IDENTITY collision detector. Two scenarios, both = one keypair backing two
44
+ -- AGENTS (a canonical key + its alias, DISTINCT stored fingerprints so the UNIQUE is not
45
+ -- hit). Built under session_replication_role=replica to bypass the integrity trigger; rolled
46
+ -- back with the test.
47
+ -- Scenario 1 (cross-OWNER): agents aa/ab under owners oa/ob share the zero key.
48
+ -- Scenario 2 (same-OWNER, two agents): agents ac/ad under one owner oc share the 'Q' key —
49
+ -- MUST-FIX B: auto-dedup must NOT delete a different agent's binding, so this ABORTS too.
50
+ do $$
51
+ declare
52
+ oa uuid; ob uuid; oc uuid;
53
+ aa uuid := gen_random_uuid();
54
+ ab uuid := gen_random_uuid();
55
+ ac uuid := gen_random_uuid();
56
+ ad uuid := gen_random_uuid();
57
+ begin
58
+ oa := public.ensure_github_user('mhcoll-a', 'mhcoll-a@example.test', 'mhcoll-a', 'Coll A');
59
+ ob := public.ensure_github_user('mhcoll-b', 'mhcoll-b@example.test', 'mhcoll-b', 'Coll B');
60
+ oc := public.ensure_github_user('mhcoll-c', 'mhcoll-c@example.test', 'mhcoll-c', 'Coll C');
61
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
62
+ values (aa, 'agent', 'mhcoll-a-agent', 'Coll A Agent', 'active', oa, oa),
63
+ (ab, 'agent', 'mhcoll-b-agent', 'Coll B Agent', 'active', ob, ob),
64
+ (ac, 'agent', 'mhcoll-c-agent', 'Coll C Agent', 'active', oc, oc),
65
+ (ad, 'agent', 'mhcoll-d-agent', 'Coll D Agent', 'active', null, oc);
66
+
67
+ set local session_replication_role = replica;
68
+ -- Scenario 1: cross-owner, zero key (repeat('A',43)) + its alias (repeat('A',42)||'B').
69
+ insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
70
+ values (aa, 'primary', 1, repeat('A', 43), 'raw_ed25519_base64url', agent_board._raw_key_fingerprint(repeat('A', 43)), 'active', 'proven'),
71
+ (ab, 'primary', 1, repeat('A', 42) || 'B', 'raw_ed25519_base64url', 'sha256:' || repeat('C', 43), 'active', 'proven');
72
+ -- Scenario 2: same owner (oc), 'Q' key (repeat('Q',43)) + its alias (repeat('Q',42)||'R').
73
+ insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
74
+ values (ac, 'primary', 1, repeat('Q', 43), 'raw_ed25519_base64url', agent_board._raw_key_fingerprint(repeat('Q', 43)), 'active', 'proven'),
75
+ (ad, 'primary', 1, repeat('Q', 42) || 'R', 'raw_ed25519_base64url', 'sha256:' || repeat('D', 43), 'active', 'proven');
76
+ set local session_replication_role = default;
77
+ end
78
+ $$;
79
+
80
+ select ok(
81
+ (select count(*) from agent_board._raw_fingerprint_backfill_collisions()) >= 2,
82
+ 'the detector flags BOTH the cross-owner and the same-owner two-agent collisions');
83
+ select is(
84
+ (select array_length(agent_ids, 1) from agent_board._raw_fingerprint_backfill_collisions()
85
+ where computed_fingerprint = agent_board._raw_key_fingerprint(repeat('A', 43))),
86
+ 2,
87
+ 'the cross-owner colliding fingerprint spans exactly 2 agents');
88
+ select is(
89
+ (select owner_count from agent_board._raw_fingerprint_backfill_collisions()
90
+ where computed_fingerprint = agent_board._raw_key_fingerprint(repeat('Q', 43))),
91
+ 1::bigint,
92
+ 'MUST-FIX B: a same-owner TWO-AGENT collision is flagged (owner_count=1) — dedup must not delete a different agent''s key');
93
+
94
+ select finish();
95
+ rollback;
@@ -0,0 +1,178 @@
1
+ -- OBSGATE (#78) pgTAP: an observer with board-thread access can no longer author via the
2
+ -- low-level post_message; the board send-role gate now applies to board-linked threads at the
3
+ -- post_message chokepoint, while board-less threads (DM/multicast) and service_role are
4
+ -- unaffected.
5
+ --
6
+ -- Runs as the postgres db role; request.jwt.claim.role toggles _is_service_role()
7
+ -- (''/service_role => true, 'authenticated' => false), request.jwt.claim.sub picks the actor.
8
+ --
9
+ -- The DENY assertion (observer post_message raises 42501) is the mutation pin: drop the guard
10
+ -- in post_message and it stops raising -> the test fails. The ALLOW twins (member posts, a
11
+ -- board-less thread posts, service_role posts) guard against over-blocking / regression.
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(13);
18
+
19
+ create temp table _og_fixture (
20
+ board uuid,
21
+ board2 uuid,
22
+ member_agent uuid,
23
+ observer_agent uuid,
24
+ board_thread uuid,
25
+ boardless_thread uuid,
26
+ tasking_thread uuid,
27
+ multi_thread uuid,
28
+ member_user uuid,
29
+ observer_user uuid
30
+ ) on commit drop;
31
+
32
+ do $$
33
+ declare
34
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
35
+ v_member_user uuid;
36
+ v_observer_user uuid;
37
+ v_creator uuid := gen_random_uuid();
38
+ v_member uuid := gen_random_uuid();
39
+ v_observer uuid := gen_random_uuid();
40
+ v_board uuid;
41
+ v_board2 uuid;
42
+ v_board_thread uuid := gen_random_uuid();
43
+ v_boardless_thread uuid := gen_random_uuid();
44
+ v_tasking_thread uuid := gen_random_uuid();
45
+ v_multi_thread uuid := gen_random_uuid();
46
+ begin
47
+ v_member_user := public.ensure_github_user('og-member-' || v_suffix, 'og-member-' || v_suffix || '@example.test', 'og-member-' || v_suffix, 'OG Member');
48
+ v_observer_user := public.ensure_github_user('og-observer-' || v_suffix, 'og-observer-' || v_suffix || '@example.test', 'og-observer-' || v_suffix, 'OG Observer');
49
+
50
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
51
+ values
52
+ (v_creator, 'agent', 'og-creator-' || v_suffix, 'OG Creator', 'active', null, null),
53
+ (v_member, 'agent', 'og-member-' || v_suffix, 'OG Member', 'active', v_member_user, v_member_user),
54
+ (v_observer, 'agent', 'og-observer-' || v_suffix, 'OG Observer', 'active', v_observer_user, v_observer_user);
55
+
56
+ select board_id into v_board
57
+ from agent_board.create_board(v_creator, 'og-board-' || v_suffix, 'OG Board', 'obsgate test', '{}'::jsonb, 'private');
58
+ select board_id into v_board2
59
+ from agent_board.create_board(v_creator, 'og-board2-' || v_suffix, 'OG Board 2', 'obsgate test 2', '{}'::jsonb, 'private');
60
+
61
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
62
+ values
63
+ (v_board, v_member, 'joined', v_creator, 'member'),
64
+ (v_board, v_observer, 'joined', v_creator, 'observer');
65
+
66
+ -- Both threads authored by the member. The board thread will be stamped + fanned below; the
67
+ -- board-less thread stays unlinked.
68
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
69
+ values
70
+ (v_board_thread, 'coordination', v_member, 'OG board thread'),
71
+ (v_boardless_thread, 'coordination', v_member, 'OG board-less thread'),
72
+ (v_tasking_thread, 'coordination', v_member, 'OG tasking thread'),
73
+ (v_multi_thread, 'coordination', v_creator, 'OG multi-board thread');
74
+
75
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
76
+ perform set_config('request.jwt.claim.sub', v_member_user::text, true);
77
+ -- The member (non-observer) posts a board message: fans delivery to all members (giving the
78
+ -- observer thread access) AND stamps threads.board_id via _upsert_delivery_recipient.
79
+ perform agent_board.post_board_message(v_board_thread, v_member, v_board, 'seed', 'seed body');
80
+ -- A tasking fan is TASK-sourced (source_message_id null): exercises the stamp resolving the
81
+ -- thread from source_task_id so this path is not a bypass (was the #78 round-1 panel finding).
82
+ perform agent_board.create_tasking_delivery(v_tasking_thread, v_member, 'task title', 'details', null, v_board);
83
+
84
+ -- Multi-board thread: fan the SAME thread to v_board then v_board2 (as operator). first-board-wins
85
+ -- means it stamps to v_board and is NEVER left NULL (the round-2 codex finding).
86
+ perform set_config('request.jwt.claim.role', 'service_role', true);
87
+ perform set_config('request.jwt.claim.sub', '', true);
88
+ perform agent_board.post_board_message(v_multi_thread, v_creator, v_board, 'm1', 'first board');
89
+ perform agent_board.post_board_message(v_multi_thread, v_creator, v_board2, 'm2', 'second board');
90
+
91
+ insert into _og_fixture values
92
+ (v_board, v_board2, v_member, v_observer, v_board_thread, v_boardless_thread, v_tasking_thread, v_multi_thread, v_member_user, v_observer_user);
93
+ end
94
+ $$;
95
+
96
+ -- existence + stamp -----------------------------------------------------------
97
+ select has_function('agent_board', '_current_agent_can_post_to_thread', array['uuid'], '_current_agent_can_post_to_thread exists');
98
+
99
+ select is(
100
+ (select board_id from agent_board.threads where id = (select board_thread from _og_fixture)),
101
+ (select board from _og_fixture),
102
+ 'the board thread was stamped with its board_id on first board delivery');
103
+
104
+ select is(
105
+ (select post_policy from agent_board.threads where id = (select board_thread from _og_fixture)),
106
+ 'inherit', 'threads default to post_policy = inherit');
107
+
108
+ -- THE FIX (mutation pin): an observer with thread access cannot author via post_message -------
109
+ select set_config('request.jwt.claim.role', 'authenticated', true);
110
+ select set_config('request.jwt.claim.sub', (select observer_user from _og_fixture)::text, true);
111
+ select throws_ok(
112
+ (select format($q$select agent_board.post_message(%L::uuid, %L::uuid, 'obs body')$q$,
113
+ board_thread, observer_agent) from _og_fixture),
114
+ '42501', 'thread posting requires a non-observer board role',
115
+ 'observer cannot author into a board thread via post_message (bypass closed)');
116
+
117
+ -- multi-board thread: stamped to the FIRST board (never left NULL) — round-2 panel finding ----
118
+ select is(
119
+ (select board_id from agent_board.threads where id = (select multi_thread from _og_fixture)),
120
+ (select board from _og_fixture),
121
+ 'a multi-board thread is stamped first-board-wins, never left NULL (no access-only bypass)');
122
+
123
+ -- tasking-fan path (TASK-sourced delivery) also stamps + gates (round-1 panel bypass, closed) --
124
+ select is(
125
+ (select board_id from agent_board.threads where id = (select tasking_thread from _og_fixture)),
126
+ (select board from _og_fixture),
127
+ 'a tasking-fanned thread is stamped too (stamp resolves the thread from source_task_id)');
128
+
129
+ select set_config('request.jwt.claim.sub', (select observer_user from _og_fixture)::text, true);
130
+ select throws_ok(
131
+ (select format($q$select agent_board.post_message(%L::uuid, %L::uuid, 'obs via tasking')$q$,
132
+ tasking_thread, observer_agent) from _og_fixture),
133
+ '42501', 'thread posting requires a non-observer board role',
134
+ 'observer cannot author into a tasking-fanned board thread via post_message (bypass closed)');
135
+
136
+ -- observer is still blocked at post_board_message too (existing gate intact) -----------------
137
+ select set_config('request.jwt.claim.sub', (select observer_user from _og_fixture)::text, true);
138
+ select throws_ok(
139
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
140
+ board_thread, observer_agent, board) from _og_fixture),
141
+ '42501', 'board send requires a non-observer board role',
142
+ 'observer still blocked at post_board_message (unchanged)');
143
+
144
+ -- ALLOW twins ---------------------------------------------------------------
145
+ select set_config('request.jwt.claim.sub', (select member_user from _og_fixture)::text, true);
146
+ select lives_ok(
147
+ (select format($q$select agent_board.post_message(%L::uuid, %L::uuid, 'member body')$q$,
148
+ board_thread, member_agent) from _og_fixture),
149
+ 'a non-observer member may author into the board thread via post_message');
150
+
151
+ select lives_ok(
152
+ (select format($q$select agent_board.post_board_message(%L::uuid, %L::uuid, %L::uuid, 'sub', 'body')$q$,
153
+ board_thread, member_agent, board) from _og_fixture),
154
+ 'a non-observer member may post_board_message (control)');
155
+
156
+ -- board-less thread: no board link -> access-only, no role gate (no regression) --------------
157
+ select lives_ok(
158
+ (select format($q$select agent_board.post_message(%L::uuid, %L::uuid, 'boardless body')$q$,
159
+ boardless_thread, member_agent) from _og_fixture),
160
+ 'a board-less thread is unaffected by the board role gate (access-only)');
161
+
162
+ -- service_role carve-out: may post to the board thread regardless of role -------------------
163
+ select set_config('request.jwt.claim.role', 'service_role', true);
164
+ select set_config('request.jwt.claim.sub', '', true);
165
+ select lives_ok(
166
+ (select format($q$select agent_board.post_message(%L::uuid, %L::uuid, 'svc body')$q$,
167
+ board_thread, observer_agent) from _og_fixture),
168
+ 'service_role may post to a board thread regardless of board role (carve-out)');
169
+
170
+ -- the new SECURITY DEFINER predicate carries no PUBLIC EXECUTE (MBHARDEN #118) --------------
171
+ select set_config('request.jwt.claim.role', '', true);
172
+ select ok(
173
+ not has_function_privilege('public', 'agent_board._current_agent_can_post_to_thread(uuid)', 'EXECUTE'),
174
+ '_current_agent_can_post_to_thread carries no PUBLIC EXECUTE (class invariant)');
175
+
176
+ select * from finish();
177
+
178
+ rollback;