@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
@@ -42,7 +42,7 @@ begin
42
42
  values (v_credential, v_agent, extensions.digest('authbound-refresh-' || v_suffix, 'sha256'), 1, 'authbound', now() + interval '1 day');
43
43
 
44
44
  insert into agent_board.agent_signing_keys(agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
45
- values (v_agent, 'primary', 1, repeat('A', 43), 'raw_ed25519_base64url', 'sha256:' || repeat('A', 43), 'active', 'proven')
45
+ values (v_agent, 'primary', 1, public.test_pubkey('authbound-' || v_suffix), 'raw_ed25519_base64url', public.test_key_fingerprint('authbound-' || v_suffix), 'active', 'proven')
46
46
  returning id into v_key;
47
47
 
48
48
  insert into _agent_board_authbound_fixture(user_id, agent_id, credential_id, signing_key_id)
@@ -98,6 +98,14 @@ $$;
98
98
 
99
99
  select pass('current_agent_id honors agent_credential_id revocation before JWT expiry');
100
100
 
101
+ -- #118 (MBHARDEN): _signed_write_actor_id is now revoked from authenticated — it is an
102
+ -- internal-only helper whose only callers are SECURITY DEFINER signed_* RPCs that run as the
103
+ -- owner. Its fail-closed LOGIC (raise actor_mismatch on a missing/mismatched gateway actor)
104
+ -- is role-independent, so exercise it as the owner rather than as authenticated (which would
105
+ -- now hit permission-denied before reaching the logic under test). The remaining assertions
106
+ -- below are role-agnostic catalog reads.
107
+ reset role;
108
+
101
109
  select throws_like(
102
110
  $$select agent_board._signed_write_actor_id(
103
111
  '{}'::jsonb,
@@ -0,0 +1,464 @@
1
+ -- CLAIMGATE Phase 3 — ENFORCE (#133): per-kind GATED fleet-authority denial + renew re-auth
2
+ -- + max-lease horizon. Fleet-graph authority ONLY (NEGATIVE RULE: no board-admin fallback).
3
+ --
4
+ -- Fixtures are built as the postgres superuser (RLS bypassed); each enforcement probe runs
5
+ -- `set local role authenticated` with request.jwt.claim.sub selecting the actor. Per-kind flags
6
+ -- are toggled between phases via `reset role` (service_role-only table). Every DENY is a verified
7
+ -- FLIP against the OFF/authorized twin — a green suite alone proves nothing (repo rules #2/#3).
8
+ --
9
+ -- Allow-side assertions use lives_ok + a separate state read (never `is((select … from rpc()))`,
10
+ -- which aborts the txn on a raise instead of failing — #113). Lease tokens are stashed via
11
+ -- set_config under `reset role` (postgres) to avoid claims RLS on the read.
12
+ --
13
+ -- SAME-FLAG PIN (Phase 2): only enforce_machine is ON, and BOTH acquire-denies-unauthorized AND
14
+ -- renew-denies-revoked-binding deny under that one flag — if renew read a different scope_key it
15
+ -- would be off and the revoked renew would succeed (red). PREDICATE-BEFORE-SWEEP PIN (Phase 10):
16
+ -- a denied enforced acquire leaves a seeded expired-but-active row STILL active (the sweep is
17
+ -- below the deny) — moving the predicate after the sweep flips that assertion.
18
+ create extension if not exists pgtap with schema extensions;
19
+ set search_path = extensions, public;
20
+
21
+ begin;
22
+
23
+ select plan(43);
24
+
25
+ do $$
26
+ declare
27
+ u_owner uuid; u_bound uuid; u_worker uuid; u_nonbound uuid; u_ap uuid;
28
+ begin
29
+ u_owner := public.ensure_github_user('ce-owner', 'ce-owner@example.test', 'ce-owner', 'CE Owner');
30
+ u_bound := public.ensure_github_user('ce-bound', 'ce-bound@example.test', 'ce-bound', 'CE Bound');
31
+ u_worker := public.ensure_github_user('ce-worker', 'ce-worker@example.test', 'ce-worker', 'CE Worker');
32
+ u_nonbound := public.ensure_github_user('ce-nonbound', 'ce-nonbound@example.test', 'ce-nonbound', 'CE NonBound');
33
+ u_ap := public.ensure_github_user('ce-ap', 'ce-ap@example.test', 'ce-ap', 'CE ActorParam');
34
+
35
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
36
+ values
37
+ ('ce000000-0000-4000-8000-0000000000a1', 'agent', 'agent', 'ce-owner', 'CE Owner', 'active', u_owner, u_owner),
38
+ ('ce000000-0000-4000-8000-0000000000a2', 'agent', 'agent', 'ce-bound', 'CE Bound', 'active', u_bound, u_bound),
39
+ ('ce000000-0000-4000-8000-0000000000a4', 'agent', 'agent', 'ce-worker', 'CE Worker', 'active', u_worker, u_worker),
40
+ ('ce000000-0000-4000-8000-0000000000a3', 'agent', 'agent', 'ce-nonbound', 'CE NonBound', 'active', u_nonbound, u_nonbound),
41
+ ('ce000000-0000-4000-8000-0000000000a8', 'agent', 'agent', 'ce-ap', 'CE ActorParam', 'active', u_ap, u_ap);
42
+
43
+ -- Domain D1 (active), owner A_owner.
44
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state)
45
+ values ('ce000000-0000-4000-8000-0000000000d1', 'ce-d1', 'CE Domain 1', 'tailnet', 'ce000000-0000-4000-8000-0000000000a1', 'active');
46
+
47
+ -- Machines owned by A_owner: M1 (bound + worker + ap live here) plus horizon-test machines.
48
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state)
49
+ values
50
+ ('ce000000-0000-4000-8000-0000000000f1', 'ce000000-0000-4000-8000-0000000000d1', 'ce000000-0000-4000-8000-0000000000a1', 'M1', 'fpM1', 'active'),
51
+ ('ce000000-0000-4000-8000-0000000000f5', 'ce000000-0000-4000-8000-0000000000d1', 'ce000000-0000-4000-8000-0000000000a1', 'Mhz', 'fpMhz', 'active'),
52
+ ('ce000000-0000-4000-8000-0000000000f6', 'ce000000-0000-4000-8000-0000000000d1', 'ce000000-0000-4000-8000-0000000000a1', 'Mhz2', 'fpMhz2', 'active'),
53
+ ('ce000000-0000-4000-8000-0000000000f7', 'ce000000-0000-4000-8000-0000000000d1', 'ce000000-0000-4000-8000-0000000000a1', 'Mhz3', 'fpMhz3', 'active'),
54
+ ('ce000000-0000-4000-8000-0000000000f8', 'ce000000-0000-4000-8000-0000000000d1', 'ce000000-0000-4000-8000-0000000000a1', 'Mpromo', 'fpPromo', 'active');
55
+
56
+ -- Harness H1 (active) on M1 with a worker repo-scope (fpR1).
57
+ insert into agent_board.fleet_harness_registrations(id, machine_id, harness_kind, harness_label, lifecycle_state)
58
+ values ('ce000000-0000-4000-8000-0000000000e1', 'ce000000-0000-4000-8000-0000000000f1', 'claude', 'H1', 'active');
59
+ insert into agent_board.fleet_repo_scopes(id, harness_registration_id, repo_slug, repo_fingerprint, scope_role, lifecycle_state)
60
+ values ('ce000000-0000-4000-8000-00000000ab01', 'ce000000-0000-4000-8000-0000000000e1', 'repo1', 'fpR1', 'worker', 'active');
61
+
62
+ -- Bindings on M1: A_bound (machine-level; revoked in phase 2), A_worker (repo scope; revoked in
63
+ -- phase 3), A_ap (machine-level; NEVER revoked — the actor-parameterized probe uses it).
64
+ insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, repo_scope_id, lifecycle_state)
65
+ values
66
+ ('ce000000-0000-4000-8000-00000000ac02', 'ce000000-0000-4000-8000-0000000000f1', 'ce000000-0000-4000-8000-0000000000a2', null, 'active'),
67
+ ('ce000000-0000-4000-8000-00000000ac04', 'ce000000-0000-4000-8000-0000000000f1', 'ce000000-0000-4000-8000-0000000000a4', 'ce000000-0000-4000-8000-00000000ab01', 'active'),
68
+ ('ce000000-0000-4000-8000-00000000ac08', 'ce000000-0000-4000-8000-0000000000f1', 'ce000000-0000-4000-8000-0000000000a8', null, 'active');
69
+
70
+ perform set_config('_ce.owner', u_owner::text, true);
71
+ perform set_config('_ce.bound', u_bound::text, true);
72
+ perform set_config('_ce.worker', u_worker::text, true);
73
+ perform set_config('_ce.nonbound', u_nonbound::text, true);
74
+ perform set_config('_ce.ap', u_ap::text, true);
75
+ end
76
+ $$;
77
+
78
+ set local role authenticated;
79
+ select set_config('request.jwt.claim.role', 'authenticated', true);
80
+
81
+ -- =====================================================================================
82
+ -- PHASE 1 — per-kind OFF (default: no flag rows) -> unauthorized claim SUCCEEDS (shadow).
83
+ -- Unknown fingerprints (authority false); with the kind's flag OFF, acquire blocks NOTHING.
84
+ -- =====================================================================================
85
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
86
+ select lives_ok($$
87
+ select agent_board.acquire_claim(null, 'machine', 'off-unknown-m', now() + interval '1 hour', null, null, '{}'::jsonb)
88
+ $$, 'OFF default: an unauthorized MACHINE claim succeeds (blocks nothing)');
89
+ select lives_ok($$
90
+ select agent_board.acquire_claim(null, 'repository', 'off-unknown-r', now() + interval '1 hour', null, null, '{}'::jsonb)
91
+ $$, 'OFF default: an unauthorized REPOSITORY claim succeeds (blocks nothing)');
92
+ select lives_ok($$
93
+ select agent_board.acquire_claim(null, 'repo_file', 'off-unknown-r:path', now() + interval '1 hour', null, null, '{}'::jsonb)
94
+ $$, 'OFF default: an unauthorized REPO_FILE claim succeeds (blocks nothing)');
95
+
96
+ -- =====================================================================================
97
+ -- PHASE 2 — ONLY enforce_machine ON. The SAME single flag gates BOTH acquire-deny and renew-
98
+ -- deny (same-flag consistency pin). Plus authorized-acquire, authorized-renew, OFF-renew, and
99
+ -- release-stays-ownership-only.
100
+ -- =====================================================================================
101
+ reset role;
102
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('enforce_machine', true)
103
+ on conflict (scope_key) do update set enabled = true;
104
+ set local role authenticated;
105
+ select set_config('request.jwt.claim.role', 'authenticated', true);
106
+
107
+ -- acquire: unauthorized machine DENIED (flip vs Phase 1's OFF success).
108
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
109
+ select throws_ok($$
110
+ select agent_board.acquire_claim(null, 'machine', 'fpM1', now() + interval '1 hour', null, null, '{}'::jsonb)
111
+ $$, '42501', null, 'enforce_machine ON: an unauthorized MACHINE acquire is DENIED (flip)');
112
+
113
+ -- acquire: authorized machine SUCCEEDS (A_bound is actively bound to fpM1).
114
+ select set_config('request.jwt.claim.sub', current_setting('_ce.bound'), true);
115
+ select lives_ok($$
116
+ select agent_board.acquire_claim(null, 'machine', 'fpM1', now() + interval '1 hour', null, null, '{}'::jsonb)
117
+ $$, 'enforce_machine ON: the actively-bound actor CAN acquire fpM1');
118
+ reset role;
119
+ select set_config('_ce.mtok',
120
+ (select lease_token::text from agent_board.claims
121
+ where resource_kind = 'machine' and resource_key = 'fpM1'
122
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a2' and status = 'active'), true);
123
+ set local role authenticated;
124
+ select set_config('request.jwt.claim.role', 'authenticated', true);
125
+ select set_config('request.jwt.claim.sub', current_setting('_ce.bound'), true);
126
+
127
+ -- renew: authorized actor (binding still active) renews fine.
128
+ select lives_ok($$
129
+ select agent_board.renew_claim(current_setting('_ce.mtok')::uuid, now() + interval '2 hours')
130
+ $$, 'enforce_machine ON: the still-bound owner renews fine');
131
+
132
+ -- revoke A_bound's binding, then renew is re-authorized and DENIED under the SAME enforce_machine flag.
133
+ reset role;
134
+ update agent_board.fleet_machine_agents set lifecycle_state = 'revoked', revoked_at = now()
135
+ where id = 'ce000000-0000-4000-8000-00000000ac02';
136
+ set local role authenticated;
137
+ select set_config('request.jwt.claim.role', 'authenticated', true);
138
+ select set_config('request.jwt.claim.sub', current_setting('_ce.bound'), true);
139
+ select throws_ok($$
140
+ select agent_board.renew_claim(current_setting('_ce.mtok')::uuid, now() + interval '2 hours')
141
+ $$, '42501', null, 'enforce_machine ON: a REVOKED binding cannot RENEW (re-auth flip; SAME flag as acquire)');
142
+
143
+ -- flag OFF -> the revoked binding renews again (shadow); pins the OFF direction on renew.
144
+ reset role;
145
+ update agent_board.claimgate_enforcement_flags set enabled = false where scope_key = 'enforce_machine';
146
+ set local role authenticated;
147
+ select set_config('request.jwt.claim.role', 'authenticated', true);
148
+ select set_config('request.jwt.claim.sub', current_setting('_ce.bound'), true);
149
+ select lives_ok($$
150
+ select agent_board.renew_claim(current_setting('_ce.mtok')::uuid, now() + interval '2 hours')
151
+ $$, 'enforce_machine OFF: the revoked binding renews again (shadow, blocks nothing)');
152
+
153
+ -- release stays OWNERSHIP-ONLY: with enforce_machine ON again and the binding revoked, the owner
154
+ -- of the claim ROW still releases it (release never re-auths fleet authority).
155
+ reset role;
156
+ update agent_board.claimgate_enforcement_flags set enabled = true where scope_key = 'enforce_machine';
157
+ set local role authenticated;
158
+ select set_config('request.jwt.claim.role', 'authenticated', true);
159
+ select set_config('request.jwt.claim.sub', current_setting('_ce.bound'), true);
160
+ select lives_ok($$
161
+ select agent_board.release_claim(current_setting('_ce.mtok')::uuid)
162
+ $$, 'release stays OWNERSHIP-ONLY: the revoked-binding owner still releases (no fleet re-auth)');
163
+
164
+ -- =====================================================================================
165
+ -- PHASE 3 — repository + repo_file ON: unauthorized deny + authorized succeed + repo renew re-auth.
166
+ -- =====================================================================================
167
+ reset role;
168
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values
169
+ ('enforce_repository', true), ('enforce_repo_file', true)
170
+ on conflict (scope_key) do update set enabled = true;
171
+ set local role authenticated;
172
+ select set_config('request.jwt.claim.role', 'authenticated', true);
173
+
174
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
175
+ select throws_ok($$
176
+ select agent_board.acquire_claim(null, 'repository', 'fpR1', now() + interval '1 hour', null, null, '{}'::jsonb)
177
+ $$, '42501', null, 'enforce_repository ON: an unauthorized REPOSITORY acquire is DENIED');
178
+ select throws_ok($$
179
+ select agent_board.acquire_claim(null, 'repo_file', 'fpR1:secret.txt', now() + interval '1 hour', null, null, '{}'::jsonb)
180
+ $$, '42501', null, 'enforce_repo_file ON: an unauthorized REPO_FILE acquire is DENIED');
181
+
182
+ select set_config('request.jwt.claim.sub', current_setting('_ce.worker'), true);
183
+ select lives_ok($$
184
+ select agent_board.acquire_claim(null, 'repository', 'fpR1', now() + interval '1 hour', null, null, '{}'::jsonb)
185
+ $$, 'enforce_repository ON: the active worker-scope actor CAN acquire fpR1');
186
+ select lives_ok($$
187
+ select agent_board.acquire_claim(null, 'repo_file', 'fpR1:src/db.sql', now() + interval '1 hour', null, null, '{}'::jsonb)
188
+ $$, 'enforce_repo_file ON: the active worker-scope actor CAN acquire fpR1:src/db.sql');
189
+ reset role;
190
+ select set_config('_ce.rtok',
191
+ (select lease_token::text from agent_board.claims
192
+ where resource_kind = 'repository' and resource_key = 'fpR1'
193
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a4' and status = 'active'), true);
194
+ -- revoke A_worker's binding (its whole repo authority flows through this one edge).
195
+ update agent_board.fleet_machine_agents set lifecycle_state = 'revoked', revoked_at = now()
196
+ where id = 'ce000000-0000-4000-8000-00000000ac04';
197
+ set local role authenticated;
198
+ select set_config('request.jwt.claim.role', 'authenticated', true);
199
+ select set_config('request.jwt.claim.sub', current_setting('_ce.worker'), true);
200
+ select throws_ok($$
201
+ select agent_board.renew_claim(current_setting('_ce.rtok')::uuid, now() + interval '2 hours')
202
+ $$, '42501', null, 'enforce_repository ON: a REVOKED repo binding cannot RENEW (re-auth flip)');
203
+
204
+ -- =====================================================================================
205
+ -- PHASE 4 — ACCEPTANCE: all 3 flags ON -> an unauthorized claim of EACH kind denies
206
+ -- (the roadmap's final exit criterion: verified deny for machine, repository, AND repo_file).
207
+ -- =====================================================================================
208
+ -- (enforce_machine + enforce_repository + enforce_repo_file are all ON now.)
209
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
210
+ select throws_ok($$
211
+ select agent_board.acquire_claim(null, 'machine', 'accept-unknown-m', now() + interval '1 hour', null, null, '{}'::jsonb)
212
+ $$, '42501', null, 'ACCEPTANCE (all 3 ON): unauthorized MACHINE denied');
213
+ select throws_ok($$
214
+ select agent_board.acquire_claim(null, 'repository', 'accept-unknown-r', now() + interval '1 hour', null, null, '{}'::jsonb)
215
+ $$, '42501', null, 'ACCEPTANCE (all 3 ON): unauthorized REPOSITORY denied');
216
+ select throws_ok($$
217
+ select agent_board.acquire_claim(null, 'repo_file', 'accept-unknown-r:p', now() + interval '1 hour', null, null, '{}'::jsonb)
218
+ $$, '42501', null, 'ACCEPTANCE (all 3 ON): unauthorized REPO_FILE denied');
219
+
220
+ -- =====================================================================================
221
+ -- PHASE 5 — MAX-LEASE HORIZON (default 30d, operator-configurable): CLAMP on acquire + renew.
222
+ -- =====================================================================================
223
+ -- over-horizon acquire is CLAMPED to now()+30d (authorized owner; enforce_machine ON).
224
+ select set_config('request.jwt.claim.sub', current_setting('_ce.owner'), true);
225
+ select lives_ok($$
226
+ select agent_board.acquire_claim(null, 'machine', 'fpMhz', now() + interval '60 days', null, null, '{}'::jsonb)
227
+ $$, 'horizon: an over-horizon (60d) authorized acquire is allowed (clamped, not rejected)');
228
+ select ok(
229
+ (select lease_expires_at from agent_board.claims
230
+ where resource_kind = 'machine' and resource_key = 'fpMhz'
231
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a1' and status = 'active')
232
+ between now() + interval '29 days' and now() + interval '31 days',
233
+ 'horizon: the 60d lease is CLAMPED to ~30d (the max horizon)');
234
+
235
+ -- within-horizon acquire is UNCHANGED.
236
+ select lives_ok($$
237
+ select agent_board.acquire_claim(null, 'machine', 'fpMhz2', now() + interval '10 days', null, null, '{}'::jsonb)
238
+ $$, 'horizon: a within-horizon (10d) acquire is allowed');
239
+ select ok(
240
+ (select lease_expires_at from agent_board.claims
241
+ where resource_kind = 'machine' and resource_key = 'fpMhz2'
242
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a1' and status = 'active')
243
+ between now() + interval '9 days' and now() + interval '11 days',
244
+ 'horizon: a within-horizon 10d lease is NOT clamped');
245
+
246
+ -- operator override: a 5-day horizon clamps a 60d acquire to ~5d.
247
+ reset role;
248
+ insert into agent_board.claimgate_settings(key, value) values ('max_lease_horizon', '5 days')
249
+ on conflict (key) do update set value = excluded.value;
250
+ set local role authenticated;
251
+ select set_config('request.jwt.claim.role', 'authenticated', true);
252
+ select set_config('request.jwt.claim.sub', current_setting('_ce.owner'), true);
253
+ select lives_ok($$
254
+ select agent_board.acquire_claim(null, 'machine', 'fpMhz3', now() + interval '60 days', null, null, '{}'::jsonb)
255
+ $$, 'horizon override: with a 5d override, a 60d acquire is allowed (clamped)');
256
+ select ok(
257
+ (select lease_expires_at from agent_board.claims
258
+ where resource_kind = 'machine' and resource_key = 'fpMhz3'
259
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a1' and status = 'active')
260
+ between now() + interval '4 days' and now() + interval '6 days',
261
+ 'horizon override: the configurable 5d override is respected (clamped to ~5d)');
262
+ -- WRITE-TIME validation (panel should-fix): a malformed or non-positive horizon override must
263
+ -- fail the operator's WRITE (not raise/clamp-into-the-past in the hot path). CHECK guards it.
264
+ -- Run as postgres (the settings table is service_role-writable) so the CHECK — not a missing
265
+ -- grant — is what rejects.
266
+ reset role;
267
+ select throws_ok($$
268
+ insert into agent_board.claimgate_settings(key, value) values ('max_lease_horizon', '-5 days')
269
+ $$, '23514', null, 'settings CHECK: a NEGATIVE horizon override is rejected at write time');
270
+ select throws_ok($$
271
+ insert into agent_board.claimgate_settings(key, value) values ('max_lease_horizon', 'not-an-interval')
272
+ $$, '22007', null, 'settings CHECK: a MALFORMED horizon override is rejected at write time');
273
+
274
+ -- remove the override (back to the 30d default) and capture fpMhz3's token for the renew-horizon test.
275
+ reset role;
276
+ delete from agent_board.claimgate_settings where key = 'max_lease_horizon';
277
+ select set_config('_ce.htok',
278
+ (select lease_token::text from agent_board.claims
279
+ where resource_kind = 'machine' and resource_key = 'fpMhz3'
280
+ and agent_id = 'ce000000-0000-4000-8000-0000000000a1' and status = 'active'), true);
281
+ set local role authenticated;
282
+ select set_config('request.jwt.claim.role', 'authenticated', true);
283
+ select set_config('request.jwt.claim.sub', current_setting('_ce.owner'), true);
284
+
285
+ -- horizon on RENEW: a 60d renew is clamped to ~30d (default restored).
286
+ select lives_ok($$
287
+ select agent_board.renew_claim(current_setting('_ce.htok')::uuid, now() + interval '60 days')
288
+ $$, 'horizon: a 60d RENEW is allowed (clamped)');
289
+ select ok(
290
+ (select lease_expires_at from agent_board.claims
291
+ where resource_key = 'fpMhz3' and status = 'active')
292
+ between now() + interval '29 days' and now() + interval '31 days',
293
+ 'horizon: the 60d renew is CLAMPED to ~30d on renew too');
294
+
295
+ -- =====================================================================================
296
+ -- PHASE 6 — task-renew stays OWNERSHIP-ONLY (no re-auth, NO horizon clamp) even with fleet
297
+ -- flags ON. A directly-seeded task claim (agent = A_owner) is renewed with a 60d lease: it is
298
+ -- neither re-authorized (task is not predicate-gated) nor clamped (horizon is fleet-only).
299
+ -- =====================================================================================
300
+ reset role;
301
+ insert into agent_board.claims(id, agent_id, resource_kind, resource_key, lease_expires_at, lease_token)
302
+ values ('ce000000-0000-4000-8000-00000000c101', 'ce000000-0000-4000-8000-0000000000a1', 'task',
303
+ '11111111-2222-4333-8444-555555555555', now() + interval '1 hour', gen_random_uuid());
304
+ select set_config('_ce.tasktok',
305
+ (select lease_token::text from agent_board.claims where id = 'ce000000-0000-4000-8000-00000000c101'), true);
306
+ set local role authenticated;
307
+ select set_config('request.jwt.claim.role', 'authenticated', true);
308
+ select set_config('request.jwt.claim.sub', current_setting('_ce.owner'), true);
309
+ select lives_ok($$
310
+ select agent_board.renew_claim(current_setting('_ce.tasktok')::uuid, now() + interval '60 days')
311
+ $$, 'task-renew stays OWNERSHIP-ONLY: renews fine with all fleet flags ON');
312
+ select ok(
313
+ (select lease_expires_at from agent_board.claims where id = 'ce000000-0000-4000-8000-00000000c101')
314
+ > now() + interval '59 days',
315
+ 'task-renew is NOT horizon-clamped (horizon is fleet-kind-only): the 60d lease stands');
316
+
317
+ -- =====================================================================================
318
+ -- PHASE 7 — FAIL-CLOSED ON UNKNOWN: an unknown machine fingerprint denies when enforced
319
+ -- (even for a fleet participant — A_owner owns other machines but not this fingerprint).
320
+ -- =====================================================================================
321
+ select set_config('request.jwt.claim.sub', current_setting('_ce.owner'), true);
322
+ select throws_ok($$
323
+ select agent_board.acquire_claim(null, 'machine', 'fp-not-in-the-graph', now() + interval '1 hour', null, null, '{}'::jsonb)
324
+ $$, '42501', null, 'fail-closed: an UNKNOWN machine fingerprint denies when enforced');
325
+
326
+ -- =====================================================================================
327
+ -- PHASE 8 — ACTOR-PARAMETERIZED predicate: _can_claim_resource_for computes an ARBITRARY
328
+ -- actor's authority, distinct from the session actor's. Session = A_nonbound (unauthorized);
329
+ -- the param actor A_ap is still bound to fpM1.
330
+ -- =====================================================================================
331
+ -- The two _for probes run as postgres — _for is granted to service_role ONLY (an RLS-bypass
332
+ -- authority oracle; the panel must-fix revoked it from authenticated).
333
+ reset role;
334
+ select is(
335
+ (select agent_board._can_claim_resource_for('ce000000-0000-4000-8000-0000000000a8', 'machine', 'fpM1')),
336
+ true, 'actor-param: _for(A_ap, machine, fpM1) returns A_ap''s authority (true) though the session actor differs');
337
+ select is(
338
+ (select agent_board._can_claim_resource_for('ce000000-0000-4000-8000-0000000000a3', 'machine', 'fpM1')),
339
+ false, 'actor-param: _for(A_nonbound, machine, fpM1) returns A_nonbound''s authority (false)');
340
+ -- the session wrapper resolves to the SESSION actor (A_nonbound), not the param actor.
341
+ set local role authenticated;
342
+ select set_config('request.jwt.claim.role', 'authenticated', true);
343
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
344
+ select is(
345
+ (select agent_board._can_claim_resource('machine', 'fpM1')),
346
+ false, 'actor-param: the session wrapper (A_nonbound) resolves to the SESSION actor (false), not A_ap');
347
+
348
+ -- LEAST-PRIVILEGE (panel must-fix): _can_claim_resource_for is an RLS-bypass fleet-authority
349
+ -- oracle — granted to service_role ONLY, NEVER authenticated. Mutation-pinned: re-granting it to
350
+ -- authenticated flips the first assertion (the wrapper still works either way, so grant is the pin).
351
+ select ok(
352
+ not has_function_privilege('authenticated', 'agent_board._can_claim_resource_for(uuid, text, text)', 'EXECUTE'),
353
+ 'least-privilege: authenticated CANNOT execute _can_claim_resource_for (no fleet-authority oracle)');
354
+ select ok(
355
+ has_function_privilege('service_role', 'agent_board._can_claim_resource_for(uuid, text, text)', 'EXECUTE'),
356
+ 'least-privilege: service_role CAN execute _can_claim_resource_for (operator census path)');
357
+
358
+ -- =====================================================================================
359
+ -- PHASE 9 — CATALOG-DERIVED COMPLETENESS: enforcement disposition for every catalog kind.
360
+ -- With all 3 fleet flags ON, exactly machine/repository/repo_file are kind-enforced; the 4
361
+ -- non-fleet kinds are NEVER kind-enforced. A new kind added to the constraint fails + names itself.
362
+ -- =====================================================================================
363
+ select is(
364
+ (select array_agg(k order by k) from (
365
+ select unnest(array['task','file','repo_file','repository','machine','tool','custom']) as k
366
+ ) kinds where agent_board._claimgate_kind_enforced(k)),
367
+ array['machine','repo_file','repository']::text[],
368
+ 'completeness: exactly the 3 fleet kinds are kind-enforceable (machine/repository/repo_file)');
369
+ select is(
370
+ (select count(*)::int from (
371
+ select unnest(array['task','file','tool','custom']) as k
372
+ ) kinds where agent_board._claimgate_kind_enforced(k)),
373
+ 0, 'completeness: the 4 non-fleet kinds are NEVER kind-enforced (positively asserted)');
374
+ select is(
375
+ (select array_agg(distinct m[1] order by m[1])
376
+ from pg_constraint c
377
+ cross join lateral regexp_matches(pg_get_constraintdef(c.oid), '''([a-z_]+)''', 'g') as m
378
+ where c.conname = 'claims_resource_kind_check'
379
+ and c.conrelid = 'agent_board.claims'::regclass),
380
+ array['custom','file','machine','repo_file','repository','task','tool']::text[],
381
+ 'completeness: the catalog kind set is exactly the 7 the enforcement mapping covers');
382
+
383
+ -- =====================================================================================
384
+ -- PHASE 10 — PREDICATE-BEFORE-SWEEP/CONFLICT (error-code PRECEDENCE pin). The deny raise
385
+ -- rolls back its own writes (a committed-state check of the sweep is NOT a valid pin — the
386
+ -- savepoint undoes it regardless of order). Instead we pin the OBSERVABLE precedence: with a
387
+ -- LIVE conflicting claim on 'fpconflict', an unauthorized enforced acquire must raise 42501
388
+ -- (the predicate deny), NOT 23505 (the conflict check). OFF -> the same acquire reaches the
389
+ -- conflict and raises 23505 (proving the key genuinely conflicts). Moving the predicate below
390
+ -- the sweep/conflict flips the ON case to 23505 -> assertion fails (real mutation pin).
391
+ -- =====================================================================================
392
+ reset role;
393
+ insert into agent_board.claims(id, agent_id, resource_kind, resource_key, lease_expires_at)
394
+ values ('ce000000-0000-4000-8000-00000000c201', 'ce000000-0000-4000-8000-0000000000a1', 'machine',
395
+ 'fpconflict', now() + interval '1 hour'); -- a LIVE (unexpired) active claim.
396
+ update agent_board.claimgate_enforcement_flags set enabled = false where scope_key = 'enforce_machine';
397
+ set local role authenticated;
398
+ select set_config('request.jwt.claim.role', 'authenticated', true);
399
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
400
+ select throws_ok($$
401
+ select agent_board.acquire_claim(null, 'machine', 'fpconflict', now() + interval '1 hour', null, null, '{}'::jsonb)
402
+ $$, '23505', null, 'precedence: enforce_machine OFF -> the acquire reaches the CONFLICT check (23505, key genuinely conflicts)');
403
+
404
+ reset role;
405
+ update agent_board.claimgate_enforcement_flags set enabled = true where scope_key = 'enforce_machine';
406
+ set local role authenticated;
407
+ select set_config('request.jwt.claim.role', 'authenticated', true);
408
+ select set_config('request.jwt.claim.sub', current_setting('_ce.nonbound'), true);
409
+ select throws_ok($$
410
+ select agent_board.acquire_claim(null, 'machine', 'fpconflict', now() + interval '1 hour', null, null, '{}'::jsonb)
411
+ $$, '42501', null, 'precedence: enforce_machine ON -> the DENY (42501) precedes the sweep/conflict (not 23505)');
412
+
413
+ -- =====================================================================================
414
+ -- PHASE 11 — PROMOTION-READINESS gate (_claimgate_promotion_blockers). Verifies the roadmap
415
+ -- "reconcile before enforcement" (:346) + "zero non-canonical at flip" (:352) exit criteria WITHOUT
416
+ -- mutating any lease. Seed one claim per blocker reason, assert each surfaces with the right flags,
417
+ -- then reconcile (delete) them and assert they no longer block. Scoped by claim_id (other phases'
418
+ -- claims also block machine — the operator reconciles ALL; here we pin the mechanism deterministically).
419
+ -- c301 UNAUTHORIZED : nonbound holds an active 'fp-promo-unauth' (unknown fp -> not authorized).
420
+ -- c302 OVER_HORIZON : A_owner holds fpPromo (owner-authorized, canonical) with a 60d lease.
421
+ -- c303 NON_CANONICAL: A_ap holds 'machine:fp-promo-nc' (legacy-prefixed -> canonicalizes away; the
422
+ -- raw key also fails _for's parse, so it reads unauthorized too — both flags).
423
+ -- =====================================================================================
424
+ reset role;
425
+ insert into agent_board.claims(id, agent_id, resource_kind, resource_key, lease_expires_at) values
426
+ ('ce000000-0000-4000-8000-00000000c301', 'ce000000-0000-4000-8000-0000000000a3', 'machine', 'fp-promo-unauth', now() + interval '1 hour'),
427
+ ('ce000000-0000-4000-8000-00000000c302', 'ce000000-0000-4000-8000-0000000000a1', 'machine', 'fpPromo', now() + interval '60 days'),
428
+ ('ce000000-0000-4000-8000-00000000c303', 'ce000000-0000-4000-8000-0000000000a8', 'machine', 'machine:fp-promo-nc', now() + interval '1 hour');
429
+
430
+ -- One aggregate assertion pins ALL three clauses AND their flag columns: a mutation removing the
431
+ -- unauthorized clause drops c301, removing the over-horizon clause drops c302, and zeroing the
432
+ -- non_canonical column flips c303's 'nc' -> the JSON mismatches.
433
+ select is(
434
+ (select jsonb_object_agg(claim_id::text,
435
+ jsonb_build_object('u', unauthorized, 'h', over_horizon, 'nc', non_canonical))
436
+ from agent_board._claimgate_promotion_blockers('machine')
437
+ where claim_id in ('ce000000-0000-4000-8000-00000000c301',
438
+ 'ce000000-0000-4000-8000-00000000c302',
439
+ 'ce000000-0000-4000-8000-00000000c303')),
440
+ jsonb_build_object(
441
+ 'ce000000-0000-4000-8000-00000000c301', jsonb_build_object('u', true, 'h', false, 'nc', false),
442
+ 'ce000000-0000-4000-8000-00000000c302', jsonb_build_object('u', false, 'h', true, 'nc', false),
443
+ 'ce000000-0000-4000-8000-00000000c303', jsonb_build_object('u', true, 'h', false, 'nc', true)),
444
+ 'promotion gate: each seeded claim surfaces with exactly its blocker reason (unauthorized / over-horizon / non-canonical)');
445
+
446
+ -- Reconcile (release/expire) the three, then they no longer block == the runbook's "empty == safe" step.
447
+ delete from agent_board.claims where id in (
448
+ 'ce000000-0000-4000-8000-00000000c301','ce000000-0000-4000-8000-00000000c302','ce000000-0000-4000-8000-00000000c303');
449
+ select is(
450
+ (select count(*)::int from agent_board._claimgate_promotion_blockers('machine')
451
+ where claim_id in ('ce000000-0000-4000-8000-00000000c301',
452
+ 'ce000000-0000-4000-8000-00000000c302',
453
+ 'ce000000-0000-4000-8000-00000000c303')),
454
+ 0, 'promotion gate: reconciling the seeded blockers empties them from the set (safe-to-flip proof)');
455
+
456
+ -- #118 least-privilege: the promotion gate is an operator census/oracle — service_role ONLY.
457
+ select ok(
458
+ not has_function_privilege('authenticated', 'agent_board._claimgate_promotion_blockers(text)', 'EXECUTE'),
459
+ 'least-privilege: authenticated CANNOT execute _claimgate_promotion_blockers (operator-only oracle)');
460
+
461
+ reset role;
462
+ select * from finish();
463
+
464
+ rollback;