@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
@@ -0,0 +1,360 @@
1
+ -- CLAIMGATE Phase 2 — SHADOW (#133): _can_claim_resource authority predicate + shadow stamping.
2
+ --
3
+ -- Fleet-graph authority ONLY (NEGATIVE RULE: no board-admin fallback). Fixtures are built as
4
+ -- the postgres superuser (RLS bypassed), then each authority probe runs `set local role
5
+ -- authenticated` with request.jwt.claim.sub selecting the actor — the same harness as
6
+ -- agent_board_fleet_machines_rls.test.sql. `_can_claim_resource` returns a boolean and never
7
+ -- raises, so authority probes use is((select …), true/false) directly; the acquire-side
8
+ -- (shadow-stamp / TTL / quota) uses lives_ok/throws_ok + a separate state read (#113).
9
+ --
10
+ -- Each destructive mutation (revoke binding / scope / harness / machine / domain) is paired with
11
+ -- a BEFORE assertion (authorized) and an AFTER assertion (denied) so the deny is a verified FLIP,
12
+ -- not an incidental false. The harness-revoked pin is the design panel's key amendment: a harness
13
+ -- revoke does NOT cascade to child repo-scopes, so the scope stays 'active' yet the actor must be
14
+ -- denied — that only holds because the chain walk re-checks fhr.lifecycle_state='active'.
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public;
17
+
18
+ begin;
19
+
20
+ select plan(36);
21
+
22
+ do $$
23
+ declare
24
+ u_owner uuid; u_bound uuid; u_nonbound uuid; u_worker uuid; u_obs uuid; u_h2 uuid;
25
+ u_d2owner uuid; u_d2worker uuid; u_ttl uuid; u_quota uuid; u_bindrev uuid;
26
+ begin
27
+ u_owner := public.ensure_github_user('cg-owner', 'cg-owner@example.test', 'cg-owner', 'CG Owner');
28
+ u_bound := public.ensure_github_user('cg-bound', 'cg-bound@example.test', 'cg-bound', 'CG Bound');
29
+ u_nonbound := public.ensure_github_user('cg-nonbound', 'cg-nonbound@example.test', 'cg-nonbound', 'CG NonBound');
30
+ u_worker := public.ensure_github_user('cg-worker', 'cg-worker@example.test', 'cg-worker', 'CG Worker');
31
+ u_obs := public.ensure_github_user('cg-obs', 'cg-obs@example.test', 'cg-obs', 'CG Observer');
32
+ u_h2 := public.ensure_github_user('cg-h2', 'cg-h2@example.test', 'cg-h2', 'CG H2');
33
+ u_d2owner := public.ensure_github_user('cg-d2owner', 'cg-d2owner@example.test', 'cg-d2owner', 'CG D2 Owner');
34
+ u_d2worker := public.ensure_github_user('cg-d2worker', 'cg-d2worker@example.test', 'cg-d2worker', 'CG D2 Worker');
35
+ u_ttl := public.ensure_github_user('cg-ttl', 'cg-ttl@example.test', 'cg-ttl', 'CG TTL');
36
+ u_quota := public.ensure_github_user('cg-quota', 'cg-quota@example.test', 'cg-quota', 'CG Quota');
37
+ u_bindrev := public.ensure_github_user('cg-bindrev', 'cg-bindrev@example.test', 'cg-bindrev', 'CG BindRev');
38
+
39
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
40
+ values
41
+ ('c9a70000-0000-4000-8000-0000000000a1', 'agent', 'agent', 'cg-owner', 'CG Owner', 'active', u_owner, u_owner),
42
+ ('c9a70000-0000-4000-8000-0000000000a2', 'agent', 'agent', 'cg-bound', 'CG Bound', 'active', u_bound, u_bound),
43
+ ('c9a70000-0000-4000-8000-0000000000a3', 'agent', 'agent', 'cg-nonbound', 'CG NonBound', 'active', u_nonbound, u_nonbound),
44
+ ('c9a70000-0000-4000-8000-0000000000a4', 'agent', 'agent', 'cg-worker', 'CG Worker', 'active', u_worker, u_worker),
45
+ ('c9a70000-0000-4000-8000-0000000000a5', 'agent', 'agent', 'cg-obs', 'CG Observer', 'active', u_obs, u_obs),
46
+ ('c9a70000-0000-4000-8000-0000000000a6', 'agent', 'agent', 'cg-h2', 'CG H2', 'active', u_h2, u_h2),
47
+ ('c9a70000-0000-4000-8000-0000000000b1', 'agent', 'agent', 'cg-d2owner', 'CG D2 Owner', 'active', u_d2owner, u_d2owner),
48
+ ('c9a70000-0000-4000-8000-0000000000b2', 'agent', 'agent', 'cg-d2worker', 'CG D2 Worker','active', u_d2worker, u_d2worker),
49
+ ('c9a70000-0000-4000-8000-0000000000c9', 'agent', 'agent', 'cg-ttl', 'CG TTL', 'active', u_ttl, u_ttl),
50
+ ('c9a70000-0000-4000-8000-0000000000ca', 'agent', 'agent', 'cg-quota', 'CG Quota', 'active', u_quota, u_quota),
51
+ ('c9a70000-0000-4000-8000-0000000000a7', 'agent', 'agent', 'cg-bindrev', 'CG BindRev', 'active', u_bindrev, u_bindrev);
52
+
53
+ -- ---- DOMAIN D1 (active) --------------------------------------------------------------
54
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state)
55
+ values ('c9a70000-0000-4000-8000-0000000000d1', 'cg-d1', 'CG Domain 1', 'tailnet', 'c9a70000-0000-4000-8000-0000000000a1', 'active');
56
+
57
+ -- M1 fpM1 (owner = A_owner); Mrev fpMrev (owner = A_owner, revoked mid-test).
58
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state)
59
+ values
60
+ ('c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000d1', 'c9a70000-0000-4000-8000-0000000000a1', 'M1', 'fpM1', 'active'),
61
+ ('c9a70000-0000-4000-8000-0000000000f2', 'c9a70000-0000-4000-8000-0000000000d1', 'c9a70000-0000-4000-8000-0000000000a1', 'Mrev', 'fpMrev', 'active');
62
+
63
+ -- Harness H1 (active) on M1: worker + observer scopes on the SAME repo (fpR1).
64
+ insert into agent_board.fleet_harness_registrations(id, machine_id, harness_kind, harness_label, lifecycle_state)
65
+ values ('c9a70000-0000-4000-8000-0000000000e1', 'c9a70000-0000-4000-8000-0000000000f1', 'claude', 'H1', 'active');
66
+ insert into agent_board.fleet_repo_scopes(id, harness_registration_id, repo_slug, repo_fingerprint, scope_role, lifecycle_state)
67
+ values
68
+ ('c9a70000-0000-4000-8000-00000000ab01', 'c9a70000-0000-4000-8000-0000000000e1', 'repo1', 'fpR1', 'worker', 'active'),
69
+ ('c9a70000-0000-4000-8000-00000000ab02', 'c9a70000-0000-4000-8000-0000000000e1', 'repo1', 'fpR1', 'observer', 'active'),
70
+ -- RSbr: a distinct active worker scope (fpR1b) for the repo-arm BINDING-revoke pin. Distinct
71
+ -- fingerprint so it does not collide with RSW on (harness, repo_fingerprint, scope_role).
72
+ ('c9a70000-0000-4000-8000-00000000ab05', 'c9a70000-0000-4000-8000-0000000000e1', 'repo1b', 'fpR1b', 'worker', 'active');
73
+
74
+ -- Harness H2 (active, REVOKED mid-test) on M1: worker scope on fpR2 (the harness-revoke pin).
75
+ insert into agent_board.fleet_harness_registrations(id, machine_id, harness_kind, harness_label, lifecycle_state)
76
+ values ('c9a70000-0000-4000-8000-0000000000e2', 'c9a70000-0000-4000-8000-0000000000f1', 'codex', 'H2', 'active');
77
+ insert into agent_board.fleet_repo_scopes(id, harness_registration_id, repo_slug, repo_fingerprint, scope_role, lifecycle_state)
78
+ values ('c9a70000-0000-4000-8000-00000000ab03', 'c9a70000-0000-4000-8000-0000000000e2', 'repo2', 'fpR2', 'worker', 'active');
79
+
80
+ -- Bindings on M1.
81
+ insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, repo_scope_id, lifecycle_state)
82
+ values
83
+ ('c9a70000-0000-4000-8000-00000000ac02', 'c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000a2', null, 'active'),
84
+ ('c9a70000-0000-4000-8000-00000000ac04', 'c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000a4', 'c9a70000-0000-4000-8000-00000000ab01', 'active'),
85
+ ('c9a70000-0000-4000-8000-00000000ac05', 'c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000a5', 'c9a70000-0000-4000-8000-00000000ab02', 'active'),
86
+ ('c9a70000-0000-4000-8000-00000000ac06', 'c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000a6', 'c9a70000-0000-4000-8000-00000000ab03', 'active'),
87
+ ('c9a70000-0000-4000-8000-00000000ac07', 'c9a70000-0000-4000-8000-0000000000f1', 'c9a70000-0000-4000-8000-0000000000a7', 'c9a70000-0000-4000-8000-00000000ab05', 'active');
88
+
89
+ -- ---- DOMAIN D2 (active, REVOKED mid-test) --------------------------------------------
90
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state)
91
+ values ('c9a70000-0000-4000-8000-0000000000d2', 'cg-d2', 'CG Domain 2', 'tailnet', 'c9a70000-0000-4000-8000-0000000000b1', 'active');
92
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state)
93
+ values ('c9a70000-0000-4000-8000-0000000000f3', 'c9a70000-0000-4000-8000-0000000000d2', 'c9a70000-0000-4000-8000-0000000000b1', 'M2', 'fpM2', 'active');
94
+ insert into agent_board.fleet_harness_registrations(id, machine_id, harness_kind, harness_label, lifecycle_state)
95
+ values ('c9a70000-0000-4000-8000-0000000000e3', 'c9a70000-0000-4000-8000-0000000000f3', 'claude', 'H3', 'active');
96
+ insert into agent_board.fleet_repo_scopes(id, harness_registration_id, repo_slug, repo_fingerprint, scope_role, lifecycle_state)
97
+ values ('c9a70000-0000-4000-8000-00000000ab04', 'c9a70000-0000-4000-8000-0000000000e3', 'repo3', 'fpR3', 'worker', 'active');
98
+ insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, repo_scope_id, lifecycle_state)
99
+ values ('c9a70000-0000-4000-8000-00000000ac23', 'c9a70000-0000-4000-8000-0000000000f3', 'c9a70000-0000-4000-8000-0000000000b2', 'c9a70000-0000-4000-8000-00000000ab04', 'active');
100
+
101
+ -- NEGATIVE RULE fixture: A_nonbound is a board OWNER but has NO fleet binding — must be denied.
102
+ insert into agent_board.boards(id, slug, name, created_by_agent_id)
103
+ values ('c9a70000-0000-4000-8000-00000000ad01', 'cg-neg-board', 'CG Neg Board', 'c9a70000-0000-4000-8000-0000000000a3');
104
+
105
+ perform set_config('_cg.owner', u_owner::text, true);
106
+ perform set_config('_cg.bound', u_bound::text, true);
107
+ perform set_config('_cg.nonbound', u_nonbound::text, true);
108
+ perform set_config('_cg.worker', u_worker::text, true);
109
+ perform set_config('_cg.obs', u_obs::text, true);
110
+ perform set_config('_cg.h2', u_h2::text, true);
111
+ perform set_config('_cg.d2owner', u_d2owner::text, true);
112
+ perform set_config('_cg.d2worker', u_d2worker::text, true);
113
+ perform set_config('_cg.ttl', u_ttl::text, true);
114
+ perform set_config('_cg.quota', u_quota::text, true);
115
+ perform set_config('_cg.bindrev', u_bindrev::text, true);
116
+ end
117
+ $$;
118
+
119
+ -- Seed 49 = (quota - 1) active file claims for the quota agent (postgres insert, RLS bypassed).
120
+ -- The 50th acquire reaches the cap and lives; the 51st is denied.
121
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
122
+ select 'c9a70000-0000-4000-8000-0000000000ca', 'file', 'cg-quota-seed-' || g, now() + interval '1 hour'
123
+ from generate_series(1, agent_board._claimgate_ownerless_quota() - 1) g;
124
+
125
+ set local role authenticated;
126
+ select set_config('request.jwt.claim.role', 'authenticated', true);
127
+
128
+ -- ============================ MACHINE ARM ============================================
129
+ select set_config('request.jwt.claim.sub', current_setting('_cg.owner'), true);
130
+ select is((select agent_board._can_claim_resource('machine', 'fpM1')), true,
131
+ 'machine: the machine OWNER is authorized');
132
+
133
+ select set_config('request.jwt.claim.sub', current_setting('_cg.bound'), true);
134
+ select is((select agent_board._can_claim_resource('machine', 'fpM1')), true,
135
+ 'machine: an actively BOUND agent is authorized');
136
+
137
+ select set_config('request.jwt.claim.sub', current_setting('_cg.nonbound'), true);
138
+ select is((select agent_board._can_claim_resource('machine', 'fpM1')), false,
139
+ 'machine: a non-bound agent is DENIED');
140
+
141
+ -- machine-revoked flip (owner of Mrev, fpMrev): authorized BEFORE, denied AFTER revoke.
142
+ select set_config('request.jwt.claim.sub', current_setting('_cg.owner'), true);
143
+ select is((select agent_board._can_claim_resource('machine', 'fpMrev')), true,
144
+ 'machine: owner authorized on an active machine (pre-revoke)');
145
+ reset role;
146
+ update agent_board.fleet_machines set lifecycle_state = 'revoked', revoked_at = now()
147
+ where id = 'c9a70000-0000-4000-8000-0000000000f2';
148
+ set local role authenticated;
149
+ select set_config('request.jwt.claim.role', 'authenticated', true);
150
+ select set_config('request.jwt.claim.sub', current_setting('_cg.owner'), true);
151
+ select is((select agent_board._can_claim_resource('machine', 'fpMrev')), false,
152
+ 'machine: a REVOKED machine denies even its owner (flip)');
153
+
154
+ -- revoked-binding flip (A_bound on M1): authorized above, denied after its binding is revoked.
155
+ reset role;
156
+ update agent_board.fleet_machine_agents set lifecycle_state = 'revoked', revoked_at = now()
157
+ where id = 'c9a70000-0000-4000-8000-00000000ac02';
158
+ set local role authenticated;
159
+ select set_config('request.jwt.claim.role', 'authenticated', true);
160
+ select set_config('request.jwt.claim.sub', current_setting('_cg.bound'), true);
161
+ select is((select agent_board._can_claim_resource('machine', 'fpM1')), false,
162
+ 'machine: a REVOKED binding denies (flip)');
163
+
164
+ -- ============================ REPO ARM ==============================================
165
+ select set_config('request.jwt.claim.sub', current_setting('_cg.worker'), true);
166
+ select is((select agent_board._can_claim_resource('repository', 'fpR1')), true,
167
+ 'repo: an active non-observer (worker) scope is authorized');
168
+ select is((select agent_board._can_claim_resource('repo_file', 'fpR1:src/db.sql')), true,
169
+ 'repo_file: fp extracted before the first colon is authorized (worker scope)');
170
+
171
+ select set_config('request.jwt.claim.sub', current_setting('_cg.obs'), true);
172
+ select is((select agent_board._can_claim_resource('repository', 'fpR1')), false,
173
+ 'repo: an OBSERVER scope is DENIED');
174
+
175
+ -- repo-arm BINDING-revoke flip (A_bindrev on fpR1b): authorized BEFORE, denied AFTER only its
176
+ -- own fleet_machine_agents binding is revoked (scope + harness + machine + domain stay active).
177
+ -- This is the sole pin on the repo arm's `fma.lifecycle_state='active'` edge — the entire effect
178
+ -- of revoke_fleet_subject('agent',…), which revokes ONLY bindings, flows through that one line.
179
+ select set_config('request.jwt.claim.sub', current_setting('_cg.bindrev'), true);
180
+ select is((select agent_board._can_claim_resource('repository', 'fpR1b')), true,
181
+ 'repo: an active binding to an active non-observer scope is authorized (pre binding-revoke)');
182
+ reset role;
183
+ update agent_board.fleet_machine_agents set lifecycle_state = 'revoked', revoked_at = now()
184
+ where id = 'c9a70000-0000-4000-8000-00000000ac07';
185
+ set local role authenticated;
186
+ select set_config('request.jwt.claim.role', 'authenticated', true);
187
+ select set_config('request.jwt.claim.sub', current_setting('_cg.bindrev'), true);
188
+ select is((select agent_board._can_claim_resource('repository', 'fpR1b')), false,
189
+ 'repo: a REVOKED binding denies the repo arm though scope/harness/machine stay active (flip)');
190
+
191
+ -- harness-revoked pin (A_h2 on fpR2): authorized BEFORE, denied AFTER the HARNESS is revoked,
192
+ -- while its repo-scope stays active (revoke does not cascade — the load-bearing fhr edge check).
193
+ select set_config('request.jwt.claim.sub', current_setting('_cg.h2'), true);
194
+ select is((select agent_board._can_claim_resource('repository', 'fpR2')), true,
195
+ 'repo: worker scope under an active harness is authorized (pre harness-revoke)');
196
+ reset role;
197
+ update agent_board.fleet_harness_registrations set lifecycle_state = 'revoked', revoked_at = now()
198
+ where id = 'c9a70000-0000-4000-8000-0000000000e2';
199
+ select is(
200
+ (select count(*)::int from agent_board.fleet_repo_scopes
201
+ where id = 'c9a70000-0000-4000-8000-00000000ab03' and lifecycle_state = 'active'),
202
+ 1, 'repo: the child repo-scope is STILL active after the harness revoke (no cascade)');
203
+ set local role authenticated;
204
+ select set_config('request.jwt.claim.role', 'authenticated', true);
205
+ select set_config('request.jwt.claim.sub', current_setting('_cg.h2'), true);
206
+ select is((select agent_board._can_claim_resource('repository', 'fpR2')), false,
207
+ 'repo: a REVOKED HARNESS denies though the scope is active (PANEL amendment pin, flip)');
208
+
209
+ -- revoked-scope flip (A_worker on fpR1): revoke the worker scope -> denied.
210
+ reset role;
211
+ update agent_board.fleet_repo_scopes set lifecycle_state = 'revoked', revoked_at = now()
212
+ where id = 'c9a70000-0000-4000-8000-00000000ab01';
213
+ set local role authenticated;
214
+ select set_config('request.jwt.claim.role', 'authenticated', true);
215
+ select set_config('request.jwt.claim.sub', current_setting('_cg.worker'), true);
216
+ select is((select agent_board._can_claim_resource('repository', 'fpR1')), false,
217
+ 'repo: a REVOKED repo-scope denies (flip)');
218
+
219
+ -- domain-revoked flip (D2): A_d2owner (machine) and A_d2worker (repo) authorized BEFORE, denied AFTER.
220
+ select set_config('request.jwt.claim.sub', current_setting('_cg.d2owner'), true);
221
+ select is((select agent_board._can_claim_resource('machine', 'fpM2')), true,
222
+ 'domain: D2 machine owner authorized (pre domain-revoke)');
223
+ select set_config('request.jwt.claim.sub', current_setting('_cg.d2worker'), true);
224
+ select is((select agent_board._can_claim_resource('repository', 'fpR3')), true,
225
+ 'domain: D2 repo worker authorized (pre domain-revoke)');
226
+ reset role;
227
+ update agent_board.fleet_trust_domains set lifecycle_state = 'revoked', revoked_at = now()
228
+ where id = 'c9a70000-0000-4000-8000-0000000000d2';
229
+ set local role authenticated;
230
+ select set_config('request.jwt.claim.role', 'authenticated', true);
231
+ select set_config('request.jwt.claim.sub', current_setting('_cg.d2owner'), true);
232
+ select is((select agent_board._can_claim_resource('machine', 'fpM2')), false,
233
+ 'domain: a REVOKED trust domain denies the machine arm (flip)');
234
+ select set_config('request.jwt.claim.sub', current_setting('_cg.d2worker'), true);
235
+ select is((select agent_board._can_claim_resource('repository', 'fpR3')), false,
236
+ 'domain: a REVOKED trust domain denies the repo arm (flip)');
237
+
238
+ -- ============================ NEGATIVE RULE ==========================================
239
+ -- A board OWNER with no fleet binding is denied on BOTH arms (fleet graph only, no fallback).
240
+ select set_config('request.jwt.claim.sub', current_setting('_cg.nonbound'), true);
241
+ select is((select agent_board._can_claim_resource('machine', 'fpM1')), false,
242
+ 'NEGATIVE: a board owner NOT in the fleet graph is denied (machine)');
243
+ select is((select agent_board._can_claim_resource('repository', 'fpR1')), false,
244
+ 'NEGATIVE: a board owner NOT in the fleet graph is denied (repository)');
245
+
246
+ -- ============================ OWNERLESS TAIL (predicate returns true; guards live in acquire) ===
247
+ select set_config('request.jwt.claim.sub', current_setting('_cg.nonbound'), true);
248
+ select is((select agent_board._can_claim_resource('file', 'anything')), true,
249
+ 'ownerless: file is not fleet-gated (predicate true)');
250
+ select is((select agent_board._can_claim_resource('task', gen_random_uuid()::text)), true,
251
+ 'task: not this predicate''s concern (predicate true)');
252
+
253
+ -- ============================ CATALOG-DERIVED COMPLETENESS ============================
254
+ -- Every resource_kind the CHECK constraint admits resolves to a non-null verdict — a new kind
255
+ -- (added to the constraint without a predicate arm) makes this fail and names itself.
256
+ select is(
257
+ (select count(*)::int
258
+ from (
259
+ select unnest(array['task','file','repo_file','repository','machine','tool','custom']) as k
260
+ ) kinds
261
+ where agent_board._can_claim_resource(
262
+ kinds.k,
263
+ case kinds.k when 'repo_file' then 'fp:path' when 'task' then gen_random_uuid()::text else 'x' end
264
+ ) is null),
265
+ 0, 'completeness: every catalog resource_kind resolves to a non-null verdict');
266
+ select is(
267
+ (select array_agg(distinct m[1] order by m[1])
268
+ from pg_constraint c
269
+ cross join lateral regexp_matches(pg_get_constraintdef(c.oid), '''([a-z_]+)''', 'g') as m
270
+ where c.conname = 'claims_resource_kind_check'
271
+ and c.conrelid = 'agent_board.claims'::regclass),
272
+ array['custom','file','machine','repo_file','repository','task','tool']::text[],
273
+ 'completeness: the catalog kind set is exactly the 7 the predicate enumerates (guards a silent new kind)');
274
+
275
+ -- ============================ SHADOW STAMP — BLOCKS NOTHING (denied actor STILL acquires) ===
276
+ -- The discriminating case: acquire as a fleet-DENIED actor. The claim MUST be created and the
277
+ -- stamp MUST record authorized=false. (With an authorized actor this would prove nothing.)
278
+ select set_config('request.jwt.claim.sub', current_setting('_cg.nonbound'), true);
279
+ select lives_ok($$
280
+ select agent_board.acquire_claim(
281
+ null, 'machine', 'fpM1', now() + interval '1 hour', null, null, '{}'::jsonb)
282
+ $$, 'shadow: a fleet-DENIED actor can still acquire (blocks nothing)');
283
+
284
+ select is(
285
+ (select (metadata #>> '{claimgate_shadow,authorized}')::boolean
286
+ from agent_board.claims
287
+ where resource_kind = 'machine' and resource_key = 'fpM1'
288
+ and agent_id = 'c9a70000-0000-4000-8000-0000000000a3'),
289
+ false, 'shadow: the stamp on the denied actor''s claim records authorized=false');
290
+
291
+ select is(
292
+ (select metadata #>> '{claimgate_shadow,kind}'
293
+ from agent_board.claims
294
+ where resource_kind = 'machine' and resource_key = 'fpM1'
295
+ and agent_id = 'c9a70000-0000-4000-8000-0000000000a3'),
296
+ 'machine', 'shadow: the stamp records the resource kind');
297
+
298
+ -- Authorized-verdict stamp: an ownerless (file) acquire as A_owner records authorized=true.
299
+ select set_config('request.jwt.claim.sub', current_setting('_cg.owner'), true);
300
+ select lives_ok($$
301
+ select agent_board.acquire_claim(
302
+ null, 'file', 'cg-owner-file-1', now() + interval '1 hour', null, null, '{}'::jsonb)
303
+ $$, 'shadow: an authorized (ownerless) acquire succeeds');
304
+ select is(
305
+ (select (metadata #>> '{claimgate_shadow,authorized}')::boolean
306
+ from agent_board.claims
307
+ where resource_kind = 'file' and resource_key = 'cg-owner-file-1'),
308
+ true, 'shadow: an authorized verdict stamps authorized=true');
309
+
310
+ -- ============================ OWNERLESS GUARDS — OFF BY DEFAULT (blocks nothing) ======
311
+ -- The TTL cap + quota are gated behind _claimgate_ownerless_guards_enabled(), which DEFAULTS
312
+ -- FALSE (no flag row). Per the census amendment SHADOW ships with guards OFF, so an over-cap
313
+ -- AND an over-quota ownerless acquire both SUCCEED — SHADOW literally blocks nothing.
314
+ select set_config('request.jwt.claim.sub', current_setting('_cg.ttl'), true);
315
+ select lives_ok($$
316
+ select agent_board.acquire_claim(
317
+ null, 'file', 'cg-ttl-offcap', now() + interval '40 days', null, null, '{}'::jsonb)
318
+ $$, 'guards OFF (default): an over-cap ownerless lease SUCCEEDS (blocks nothing)');
319
+
320
+ -- u_quota holds (quota-1)=49 seeded; with guards off, pushing to and past the quota still succeeds.
321
+ select set_config('request.jwt.claim.sub', current_setting('_cg.quota'), true);
322
+ select lives_ok($$
323
+ select agent_board.acquire_claim(
324
+ null, 'file', 'cg-quota-off-a', now() + interval '1 hour', null, null, '{}'::jsonb)
325
+ $$, 'guards OFF: the quota-th ownerless acquire succeeds');
326
+ select lives_ok($$
327
+ select agent_board.acquire_claim(
328
+ null, 'file', 'cg-quota-off-b', now() + interval '1 hour', null, null, '{}'::jsonb)
329
+ $$, 'guards OFF (default): an over-quota ownerless acquire SUCCEEDS (blocks nothing)');
330
+
331
+ -- ============================ OWNERLESS GUARDS — FLIP ON (operator post-census) =======
332
+ -- The operator enables the guards after the census ratifies the cap; now the cap + quota enforce.
333
+ reset role;
334
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
335
+ on conflict (scope_key) do update set enabled = true;
336
+ set local role authenticated;
337
+ select set_config('request.jwt.claim.role', 'authenticated', true);
338
+
339
+ -- u_quota now holds 49 + 2 = 51 active >= quota(50): any further ownerless acquire is denied.
340
+ select set_config('request.jwt.claim.sub', current_setting('_cg.quota'), true);
341
+ select throws_ok($$
342
+ select agent_board.acquire_claim(
343
+ null, 'file', 'cg-quota-on', now() + interval '1 hour', null, null, '{}'::jsonb)
344
+ $$, '54000', null, 'guards ON: an over-quota ownerless acquire is denied');
345
+
346
+ -- TTL cap enforces once ON: over-horizon rejected (checked before quota), within-horizon allowed.
347
+ select set_config('request.jwt.claim.sub', current_setting('_cg.ttl'), true);
348
+ select throws_ok($$
349
+ select agent_board.acquire_claim(
350
+ null, 'file', 'cg-ttl-on-over', now() + interval '40 days', null, null, '{}'::jsonb)
351
+ $$, '22023', null, 'guards ON: an over-cap ownerless lease is rejected');
352
+ select lives_ok($$
353
+ select agent_board.acquire_claim(
354
+ null, 'file', 'cg-ttl-on-ok', now() + interval '1 hour', null, null, '{}'::jsonb)
355
+ $$, 'guards ON: a within-horizon ownerless lease is allowed');
356
+
357
+ reset role;
358
+ select * from finish();
359
+
360
+ rollback;
@@ -0,0 +1,229 @@
1
+ -- CLAIMGATE Phase 1 — CLAIMKEY (#133) pgTAP.
2
+ --
3
+ -- Pins IF-0-CLAIMKEY-1 (the canonical claim-key grammar) and the NORMALIZE-ONLY wiring into
4
+ -- acquire_claim / renew_claim / release_claim. Every property below is mutation-pinned: the
5
+ -- deliberate break that flips each assertion is noted inline.
6
+ --
7
+ -- Runs as the postgres db role; request.jwt.claim.role='service_role' takes the operator
8
+ -- carve-out so acquire honours the supplied agent id and renew/release skip the ownership
9
+ -- check (we exercise the KEY NORMALIZATION, not the authz gates, which are unchanged here).
10
+ --
11
+ -- Allow-side is lives_ok(...) + a separate state read (never is((select … from rpc()))) —
12
+ -- repo rule #113. The normalizer itself is a PURE function (no side effect, cannot abort the
13
+ -- txn), so its shape checks use is() directly.
14
+ create extension if not exists pgtap with schema extensions;
15
+ set search_path = extensions, public, agent_board;
16
+
17
+ begin;
18
+
19
+ select plan(25);
20
+
21
+ select set_config('request.jwt.claim.role', 'service_role', true);
22
+ select set_config('request.jwt.claim.sub', '', true);
23
+
24
+ create temp table _ck_fixture (
25
+ agent uuid,
26
+ tok_dup_legacy uuid, -- 'machine:fpDup' — active, aliased with a canonical twin
27
+ tok_heal uuid, -- 'machine:fpHeal' — active, lone (canonical is free)
28
+ tok_release uuid -- 'machine:fpRel' — active, lone
29
+ ) on commit drop;
30
+
31
+ do $$
32
+ declare
33
+ v_agent uuid := gen_random_uuid();
34
+ v_tok_dup_legacy uuid;
35
+ v_tok_heal uuid;
36
+ v_tok_release uuid;
37
+ begin
38
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
39
+ values (v_agent, 'agent', 'ck-agent-' || substr(v_agent::text, 1, 8), 'CK Agent', 'active');
40
+
41
+ -- Aliased pair for the renew never-raise pin: a legacy-shaped key and its canonical twin,
42
+ -- both ACTIVE (distinct resource_key values, so the partial unique index permits both).
43
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
44
+ values (v_agent, 'machine', 'machine:fpDup', now() + interval '1 hour')
45
+ returning lease_token into v_tok_dup_legacy;
46
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
47
+ values (v_agent, 'machine', 'fpDup', now() + interval '1 hour');
48
+
49
+ -- Lone legacy claim whose canonical form is FREE — renew must self-heal it.
50
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
51
+ values (v_agent, 'machine', 'machine:fpHeal', now() + interval '1 hour')
52
+ returning lease_token into v_tok_heal;
53
+
54
+ -- Lone legacy claim for the release canonicalization pin.
55
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
56
+ values (v_agent, 'machine', 'machine:fpRel', now() + interval '1 hour')
57
+ returning lease_token into v_tok_release;
58
+
59
+ -- MUST-FIX 3: still-active LEGACY-keyed claims; a fresh acquire whose canonical form aliases
60
+ -- one must be rejected (no second live hold on the same machine during the legacy window).
61
+ -- Two independent legacy rows so the prefixed-input and bare-input pins don't contaminate each
62
+ -- other (a first wrongful insert must not satisfy the second's conflict check).
63
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
64
+ values (v_agent, 'machine', 'machine:fpDH', now() + interval '1 hour'),
65
+ (v_agent, 'machine', 'machine:fpDH2', now() + interval '1 hour');
66
+
67
+ insert into _ck_fixture values (v_agent, v_tok_dup_legacy, v_tok_heal, v_tok_release);
68
+ end
69
+ $$;
70
+
71
+ -- ============================================================================
72
+ -- A. _canonical_claim_key grammar (IF-0-CLAIMKEY-1) — pure function shape pins
73
+ -- Mutation: revert the normalizer to identity (return btrim(p_resource_key))
74
+ -- -> assertions 1,3-8 flip (bare/trim cases hold; transforms fail).
75
+ -- ============================================================================
76
+ select is(agent_board._canonical_claim_key('machine', 'machine:fp1'), 'fp1',
77
+ 'machine: strips a legacy machine: prefix to the bare fingerprint');
78
+ select is(agent_board._canonical_claim_key('machine', 'fp1'), 'fp1',
79
+ 'machine: a bare fingerprint is already canonical');
80
+ select is(agent_board._canonical_claim_key('machine', ' fp2 '), 'fp2',
81
+ 'machine: surrounding whitespace is trimmed');
82
+ select is(agent_board._canonical_claim_key('repository', 'repository:r1'), 'r1',
83
+ 'repository: strips a legacy repository: prefix');
84
+ select is(agent_board._canonical_claim_key('repository', 'repo:r1'), 'r1',
85
+ 'repository: strips a legacy repo: prefix');
86
+ select is(agent_board._canonical_claim_key('repo_file', 'repo_file:fpR:src/db.sql'), 'fpR:src/db.sql',
87
+ 'repo_file: strips the kind prefix and keeps <fp>:<path>');
88
+ select is(agent_board._canonical_claim_key('repo_file', 'fpR:src/db.sql'), 'fpR:src/db.sql',
89
+ 'repo_file: a bare <fp>:<path> is already canonical');
90
+ select is(agent_board._canonical_claim_key('repo_file', 'fpR:a:b'), 'fpR:a:b',
91
+ 'repo_file: only the FIRST colon splits fp|path (path may contain colons)');
92
+ select is(agent_board._canonical_claim_key('custom', ' ck '), ' ck ',
93
+ 'custom: free-form key passes through VERBATIM — not even trimmed (SHOULD-FIX 4; " x " != "x")');
94
+ select is(
95
+ agent_board._canonical_claim_key('task', '11111111-2222-3333-4444-555555555555'),
96
+ '11111111-2222-3333-4444-555555555555',
97
+ 'task: uuid key passes through unchanged');
98
+ -- Unmappable keys are shadow-logged and returned UNCHANGED — never rejected (normalize-only).
99
+ select is(agent_board._canonical_claim_key('machine', 'machine:'), 'machine:',
100
+ 'machine: an empty-after-strip key is unmappable — returned unchanged, not raised');
101
+ select is(agent_board._canonical_claim_key('repo_file', 'repo_file:justfp'), 'repo_file:justfp',
102
+ 'repo_file: a single-component key has no <fp>:<path> split — unmappable, returned unchanged (not rejected)');
103
+
104
+ -- ============================================================================
105
+ -- B. acquire_claim normalizes the SUPPLIED key before insert/uniqueness
106
+ -- Mutation: delete the `p_resource_key := _canonical_claim_key(...)` line in
107
+ -- acquire_claim -> assertion 14 (stored key) and 15 (canonical dedup) flip.
108
+ -- ============================================================================
109
+ select lives_ok(
110
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'machine', 'machine:fpAcq', now() + interval '1 hour')$q$, agent)
111
+ from _ck_fixture),
112
+ 'acquire_claim accepts a legacy-prefixed machine key (normalize-only, no rejection)');
113
+ select is(
114
+ (select resource_key from agent_board.claims
115
+ where resource_kind = 'machine' and status = 'active'
116
+ and agent_id = (select agent from _ck_fixture)
117
+ and resource_key = 'fpAcq'),
118
+ 'fpAcq',
119
+ 'acquire_claim STORES the canonical bare fingerprint, not the prefixed input');
120
+ -- The bare canonical form now contends on the SAME uniqueness slot -> conflict.
121
+ select throws_ok(
122
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'machine', 'fpAcq', now() + interval '1 hour')$q$, agent)
123
+ from _ck_fixture),
124
+ '23505', 'conflicting active claim',
125
+ 'a bare key canonically aliasing an active prefixed acquire conflicts (normalization feeds the index)');
126
+
127
+ -- ============================================================================
128
+ -- C. renew_claim is NON-RAISING on a canonical alias via retry-once (MUST-FIX 1)
129
+ -- The canonical twin 'fpDup' is committed-visible, so renew's first UPDATE toward 'fpDup'
130
+ -- deterministically collides (23505) and the unique_violation handler retries keeping the
131
+ -- legacy key — exercising the SAME handler that covers the concurrent race.
132
+ -- Mutation: drop the exception handler (bare unconditional-canonical UPDATE) ->
133
+ -- assertion 16 (lives_ok) flips red with 23505. (Concurrent race pinned by the
134
+ -- isolation spec claimkey_renew_race.spec.)
135
+ -- ============================================================================
136
+ select lives_ok(
137
+ (select format($q$select agent_board.renew_claim(%L::uuid, now() + interval '2 hours')$q$, tok_dup_legacy)
138
+ from _ck_fixture),
139
+ 'renew_claim does NOT raise when the canonical key is already held by an active twin');
140
+ select is(
141
+ (select resource_key from agent_board.claims
142
+ where lease_token = (select tok_dup_legacy from _ck_fixture)),
143
+ 'machine:fpDup',
144
+ 'renew_claim KEEPS the legacy key when its canonical form is occupied (deferred alias for ENFORCE)');
145
+
146
+ -- ============================================================================
147
+ -- D. renew_claim self-heals a lone legacy key to canonical
148
+ -- Mutation: drop the resource_key CASE from renew's UPDATE set-list ->
149
+ -- assertion 19 flips (key stays 'machine:fpHeal').
150
+ -- ============================================================================
151
+ select lives_ok(
152
+ (select format($q$select agent_board.renew_claim(%L::uuid, now() + interval '2 hours')$q$, tok_heal)
153
+ from _ck_fixture),
154
+ 'renew_claim renews a lone legacy-keyed lease');
155
+ select is(
156
+ (select resource_key from agent_board.claims
157
+ where lease_token = (select tok_heal from _ck_fixture)),
158
+ 'fpHeal',
159
+ 'renew_claim canonicalizes a lone legacy key when the canonical form is free (self-heal)');
160
+
161
+ -- ============================================================================
162
+ -- E. release_claim canonicalizes the stored key (always safe — row leaves index)
163
+ -- Mutation: drop the resource_key assignment from release's UPDATE ->
164
+ -- assertion 21 flips (key stays 'machine:fpRel').
165
+ -- ============================================================================
166
+ select lives_ok(
167
+ (select format($q$select agent_board.release_claim(%L::uuid)$q$, tok_release)
168
+ from _ck_fixture),
169
+ 'release_claim releases a legacy-keyed lease');
170
+ select is(
171
+ (select resource_key || '|' || status from agent_board.claims
172
+ where lease_token = (select tok_release from _ck_fixture)),
173
+ 'fpRel|released',
174
+ 'release_claim canonicalizes the stored key as it releases');
175
+
176
+ -- ============================================================================
177
+ -- E2. acquire is ALIAS-AWARE against an un-healed legacy row (MUST-FIX 3)
178
+ -- 'machine:fpDH' is active (legacy); an acquire whose canonical form is 'fpDH' must
179
+ -- conflict — otherwise two agents hold the same machine during the legacy window.
180
+ -- Mutation: revert acquire's sweep/conflict to exact `resource_key = p_resource_key`
181
+ -- -> both acquires below succeed and assertions 22,23 flip to lives (throws_ok fails).
182
+ -- ============================================================================
183
+ select throws_ok(
184
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'machine', 'machine:fpDH', now() + interval '1 hour')$q$, agent)
185
+ from _ck_fixture),
186
+ '23505', 'conflicting active claim',
187
+ 'acquire of a legacy-prefixed key aliasing an active legacy row conflicts (no dual live hold)');
188
+ select throws_ok(
189
+ (select format($q$select agent_board.acquire_claim(%L::uuid, 'machine', 'fpDH2', now() + interval '1 hour')$q$, agent)
190
+ from _ck_fixture),
191
+ '23505', 'conflicting active claim',
192
+ 'acquire of the bare canonical key aliasing an active legacy row also conflicts (independent row)');
193
+
194
+ -- ============================================================================
195
+ -- F. Catalog-derived completeness (repo rule #1): the resource_key writer class,
196
+ -- enumerated from pg_proc — NOT from a hand list. A new claims writer changes
197
+ -- the set (set_eq names it) and, if it skips the normalizer, fails assertion 25.
198
+ -- Regexes are case-insensitive, whitespace-tolerant, and match an UNQUALIFIED
199
+ -- `update claims` (schema on search_path) — a qualifier/spacing dodge no longer
200
+ -- fails-open (MUST-FIX 2). Mutation: add any new agent_board function that writes
201
+ -- claims without routing through _canonical_claim_key -> both 24 and 25 flip and name it.
202
+ -- ============================================================================
203
+ select set_eq(
204
+ $$ select p.proname
205
+ from pg_proc p
206
+ join pg_namespace n on n.oid = p.pronamespace
207
+ where n.nspname = 'agent_board'
208
+ and p.prokind = 'f'
209
+ and pg_get_functiondef(p.oid) ~* '(insert\s+into|update)\s+(agent_board\.)?claims\M' $$,
210
+ -- reclaim_fleet_machine (#150) is a fourth writer: on operator reclaim it RELEASES the squatter's
211
+ -- active machine claim(s) on the reclaimed fingerprint so the new owner can acquire. It routes the
212
+ -- key through _canonical_claim_key (assertion 25 stays 0); its positive state claim (status set to
213
+ -- 'released') is pinned in agent_board_fleet_machine_reclaim.test.sql.
214
+ array['acquire_claim', 'renew_claim', 'release_claim', 'reclaim_fleet_machine'],
215
+ 'exactly acquire/renew/release/reclaim write agent_board.claims (catalog-enumerated writer class)');
216
+ select is(
217
+ (select count(*)::int
218
+ from pg_proc p
219
+ join pg_namespace n on n.oid = p.pronamespace
220
+ where n.nspname = 'agent_board'
221
+ and p.prokind = 'f'
222
+ and pg_get_functiondef(p.oid) ~* '(insert\s+into|update)\s+(agent_board\.)?claims\M'
223
+ and pg_get_functiondef(p.oid) !~ '_canonical_claim_key'),
224
+ 0,
225
+ 'every catalog-enumerated claims writer routes its resource_key through _canonical_claim_key');
226
+
227
+ select * from finish();
228
+
229
+ rollback;
@@ -0,0 +1,48 @@
1
+ -- message-board#103 — BEHAVIOURAL test of the MBCONSENT-A (20260806000100) backfill.
2
+ --
3
+ -- The test runner seeds a legacy 'requested' board_membership BEFORE that migration
4
+ -- (tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql), so the SHIPPED backfill
5
+ -- runs against a real row. This asserts what the shipped migration actually wrote — it FAILS if
6
+ -- the backfill is removed or miscorrelated, with the migration unmodified (message-board#103
7
+ -- acceptance). Reads persisted replay state (no fixture of its own); the begin/rollback wrapper
8
+ -- the runner adds does not undo the already-applied backfill.
9
+ create extension if not exists pgtap with schema extensions;
10
+ set search_path = extensions, public;
11
+
12
+ begin;
13
+
14
+ select plan(3);
15
+
16
+ -- The seeded pending subject received exactly ONE backfill event, with the exact shape the
17
+ -- shipped backfill promises: neutral kind, the pending state preserved, board-authored provenance,
18
+ -- and the board creator as the actor. Remove the backfill -> 0 rows -> fails. Miscorrelate the
19
+ -- not-exists / join (e.agent_id, bm.board_id, created_by) -> wrong shape or 0 -> fails.
20
+ select is(
21
+ (select count(*)::int from agent_board.board_membership_events e
22
+ where e.board_id = 'bf103bbb-0000-0000-0000-0000000000b1'
23
+ and e.agent_id = 'bf103aaa-0000-0000-0000-000000000002'
24
+ and e.event_kind = 'backfill_neutral'
25
+ and e.result_state = 'requested'
26
+ and e.provenance = 'board-authored'
27
+ and e.actor_agent_id = 'bf103aaa-0000-0000-0000-000000000001'),
28
+ 1,
29
+ 'shipped MBCONSENT-A backfill emitted the neutral board-authored event for the seeded pending row');
30
+
31
+ -- Exactly one event for the subject (the DISTINCT ON / not-exists de-duplication held).
32
+ select is(
33
+ (select count(*)::int from agent_board.board_membership_events e
34
+ where e.board_id = 'bf103bbb-0000-0000-0000-0000000000b1'
35
+ and e.agent_id = 'bf103aaa-0000-0000-0000-000000000002'),
36
+ 1,
37
+ 'the seeded subject received exactly one backfill event (no duplicate emission)');
38
+
39
+ -- The predicate derived consent presence from the backfilled ledger: a board-authored neutral
40
+ -- event is NOT the subject's own consent act, so the subject is not consent-present.
41
+ select is(
42
+ agent_board._membership_consent_present('bf103bbb-0000-0000-0000-0000000000b1', 'bf103aaa-0000-0000-0000-000000000002'),
43
+ false,
44
+ 'a backfilled board-authored neutral event does not by itself establish subject consent');
45
+
46
+ select * from finish();
47
+
48
+ rollback;