@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,170 @@
1
+ -- Consiliency/message-board#193 pgTAP: enroll_federated_human must reject an
2
+ -- ABSENT (issuer, audience) BEFORE it provisions or locks the human owner.
3
+ --
4
+ -- WHY THE GATE IS WHERE IT IS. PostgreSQL cannot row-lock a row that does not
5
+ -- exist, so with the provider absent there was nothing for the board/provider
6
+ -- ordering to attach to: the function skipped it, locked auth.users, and delegated
7
+ -- -- and the delegate's fresh READ COMMITTED snapshot could then see a provider
8
+ -- committed in the gap and auto-join its default board while this transaction held
9
+ -- the owner. Owner -> board, inverted against fleet onboarding.
10
+ --
11
+ -- WHAT THIS FILE CAN AND CANNOT PROVE. A single pgTAP transaction cannot exhibit
12
+ -- the interleaving; the load-bearing proof is
13
+ -- tests/isolation/specs/fedsession_provider_absent_owner_lock.spec, where a guard
14
+ -- session holds the owner row and the pre-fix function BLOCKS while the fixed one
15
+ -- raises without waiting. What is pinned HERE is (a) that the rejection comes from
16
+ -- the pre-lock gate and not from the delegate's identical-verdict step (4) -- which
17
+ -- is why the two messages differ -- (b) that it is positionally ahead of the owner
18
+ -- provisioning in the shipped definition, and (c) that the deeper provider gates and
19
+ -- the auto-join allow path are unchanged.
20
+ create extension if not exists pgtap with schema extensions;
21
+ set search_path = extensions, public, agent_board;
22
+
23
+ begin;
24
+
25
+ select plan(8);
26
+
27
+ -- G-1: the ABSENT-provider rejection, identified by the pre-lock gate's own message.
28
+ -- The delegate raises the same 42501 with a DIFFERENT message, so an errcode-only
29
+ -- assertion here would pass with the gate removed.
30
+ select throws_ok(
31
+ $$ select agent_board.enroll_federated_human(
32
+ 'https://lo193-absent.test', 'message-board', 'lo193-orphan',
33
+ jsonb_build_object('sub','lo193-orphan'), null) $$,
34
+ '42501',
35
+ 'issuer/audience not in the identity-provider allowlist (rejected before any owner or board lock)',
36
+ 'G-1: an absent (issuer,audience) is rejected by the PRE-LOCK gate');
37
+
38
+ -- G-2: and it is positionally ahead of owner provisioning in the shipped body.
39
+ --
40
+ -- WHAT THIS ASSERTION HAD TO SURVIVE, because each version read as evidence while
41
+ -- being defeatable. The marker is located on a body with comments and dollar-quoted
42
+ -- blocks stripped and single-quoted literals KEPT (the raise message is a literal,
43
+ -- so stripping literals would remove the very thing being located), and it is
44
+ -- anchored to the RAISE SHAPE rather than to the bare message:
45
+ -- * bare message on raw text -> a one-line comment quoting the message passes it
46
+ -- with the gate moved below _ensure_agent_auth_user;
47
+ -- * bare message on the stripped body -> `raise debug 'ordering note: %', '<msg>'`
48
+ -- or `declare v_note text := '<msg>'` passes it the same way. Both verified.
49
+ -- * raise-exception-shaped on the stripped body -> all four masks fail, including
50
+ -- a composite that also makes the provider freeze null-tolerant and goes 8/8
51
+ -- GREEN against every other assertion here with the owner provisioned before the
52
+ -- verdict.
53
+ -- RESIDUAL, stated rather than implied: this narrows the vector, it does not close
54
+ -- the shape. A dead-code `raise exception '<msg>'` earlier in the body still passes.
55
+ -- What that residual cannot reach is the LOCK order: a gate moved below the
56
+ -- auth.users FOR UPDATE is caught by
57
+ -- tests/isolation/specs/fedsession_provider_absent_owner_lock.spec (the call goes
58
+ -- `<waiting ...>` instead of raising), verified by mutation. A gate moved below
59
+ -- _ensure_agent_auth_user but ABOVE that lock is caught by NEITHER the spec nor the
60
+ -- pre-remedy G-2 -- which is precisely the hole this anchoring closes.
61
+ select is(
62
+ (select case
63
+ when p_gate = 0 then 'gate-missing'
64
+ when p_ensure = 0 or p_lock is null then 'owner-provisioning-not-found'
65
+ when p_gate < p_ensure and p_gate < p_lock then 'gate-before-owner'
66
+ else 'gate-after-owner'
67
+ end
68
+ from (
69
+ select coalesce(strpos(d, substring(d from
70
+ $g$raise\s+exception\s+'issuer/audience not in the identity-provider allowlist \(rejected before any owner or board lock\)'$g$)), 0) as p_gate,
71
+ strpos(d, '_ensure_agent_auth_user') as p_ensure,
72
+ strpos(d, substring(d from 'from\s+auth\.users[^;]{0,250}?for\s+update')) as p_lock
73
+ from (select regexp_replace(p.prosrc,
74
+ $re$(\$[A-Za-z_0-9]*\$).*?\1|('([^']|'')*')|(--[^\n]*)|(/\*([^*]|\*+[^*/])*\*+/)$re$,
75
+ '\2', 'g') as d
76
+ from pg_proc p
77
+ join pg_namespace n on n.oid = p.pronamespace
78
+ where n.nspname = 'agent_board'
79
+ and p.proname = 'enroll_federated_human') s
80
+ ) t),
81
+ 'gate-before-owner',
82
+ 'G-2: the absence gate precedes both _ensure_agent_auth_user and the auth.users lock');
83
+
84
+ -- Fixtures for the allow side and for the deeper gates the new one must not shadow.
85
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
86
+ values ('193a0000-0000-0000-0000-000000000001', 'agent', 'agent',
87
+ 'lo193-owner', 'LO193 Owner', 'active');
88
+
89
+ select count(*) from agent_board.create_board(
90
+ '193a0000-0000-0000-0000-000000000001', 'lo193-board', 'LO193 Board',
91
+ 'Consiliency/message-board#193 provider-absence gate', jsonb_build_object(), 'private');
92
+
93
+ insert into agent_board.identity_providers(
94
+ issuer, audience, supported, policy_approved,
95
+ allowed_scopes, allowed_board_scopes, allowed_agent_kinds, default_board_id)
96
+ values
97
+ ('https://lo193-autojoin.test', 'message-board', true, true,
98
+ ARRAY[]::text[], ARRAY[]::text[], ARRAY['agent','human'],
99
+ (select id from agent_board.boards where slug = 'lo193-board')),
100
+ ('https://lo193-noboard.test', 'message-board', true, true,
101
+ ARRAY[]::text[], ARRAY[]::text[], ARRAY['agent','human'], null),
102
+ ('https://lo193-unapproved.test', 'message-board', true, false,
103
+ ARRAY[]::text[], ARRAY[]::text[], ARRAY['agent','human'], null);
104
+
105
+ -- G-3/G-4: provider PRESENT with a designated default board -- enrollment succeeds
106
+ -- and the auto-join still lands. The gate narrowed the absent case only.
107
+ select lives_ok(
108
+ $$ select agent_board.enroll_federated_human(
109
+ 'https://lo193-autojoin.test', 'message-board', 'lo193-joiner',
110
+ jsonb_build_object('sub','lo193-joiner'), 'LO193 Joiner') $$,
111
+ 'G-3: an allowlisted provider with a default board still enrolls');
112
+ select is(
113
+ (select count(*)::int
114
+ from agent_board.board_memberships m
115
+ join agent_board.agents a on a.id = m.agent_id
116
+ where m.board_id = (select id from agent_board.boards where slug = 'lo193-board')
117
+ and a.metadata #>> ARRAY['federation','subject'] = 'lo193-joiner'
118
+ and m.membership_state = 'joined'),
119
+ 1, 'G-4: the fedsession auto-join still lands a joined membership');
120
+
121
+ -- G-5: provider PRESENT with a NULL default board -- enrolls, no auto-join. This is
122
+ -- the neighbouring absence 20260901000140 already handled (NULL designation), kept
123
+ -- distinct from the provider-absent case this migration closes.
124
+ select lives_ok(
125
+ $$ select agent_board.enroll_federated_human(
126
+ 'https://lo193-noboard.test', 'message-board', 'lo193-plain',
127
+ jsonb_build_object('sub','lo193-plain'), 'LO193 Plain') $$,
128
+ 'G-5: an allowlisted provider with no default board still enrolls');
129
+
130
+ -- G-6: the deeper provider gates are NOT shadowed -- an allowlisted but
131
+ -- unapproved provider still fails inside the delegate, with the delegate's own
132
+ -- message. Asserted positively so "42501 came from somewhere" is never enough.
133
+ select throws_ok(
134
+ $$ select agent_board.enroll_federated_human(
135
+ 'https://lo193-unapproved.test', 'message-board', 'lo193-unapproved',
136
+ jsonb_build_object('sub','lo193-unapproved'), null) $$,
137
+ '42501', 'identity provider is not policy-approved',
138
+ 'G-6: a present-but-unapproved provider is still rejected by the delegate');
139
+
140
+ -- G-7: the pre-lock gate is the ONLY new rejection -- a present provider never
141
+ -- reaches it. (Mutating the gate to fire unconditionally fails G-3/G-5/G-7.)
142
+ select is(
143
+ (select count(*)::int
144
+ from agent_board.agents a
145
+ where a.kind = 'human'
146
+ and a.metadata #>> ARRAY['federation','subject'] in ('lo193-joiner', 'lo193-plain')),
147
+ 2, 'G-7: both allowlisted enrollments provisioned their human agents');
148
+
149
+
150
+ -- G-8: the CLASS of routes that reject an absent provider, enumerated from the
151
+ -- catalog rather than described in prose. All four reject with 42501; none
152
+ -- serializes the absence, and that is the recorded decision (#195) -- the
153
+ -- serialization alternative was built, measured and reverted because no shipped
154
+ -- caller can distinguish 42501 from a retryable code. A new route joining this
155
+ -- class fails here and names itself.
156
+ select is(
157
+ (select string_agg(p.proname, ', ' order by p.proname)
158
+ from pg_proc p
159
+ join pg_namespace n on n.oid = p.pronamespace
160
+ where n.nspname = 'agent_board'
161
+ and p.prokind = 'f'
162
+ and regexp_replace(p.prosrc,
163
+ $re$(\$[A-Za-z_0-9]*\$).*?\1|('([^']|'')*')|(--[^\n]*)|(/\*([^*]|\*+[^*/])*\*+/)$re$,
164
+ '\2', 'g') ~ 'not in the identity-provider allowlist'),
165
+ '_enroll_federated_human_after_revocation_gate, _enroll_federated_identity_once, '
166
+ || 'enroll_federated_human, enroll_federated_identity',
167
+ 'G-8: exactly four routes reject an absent identity provider');
168
+
169
+ select * from finish();
170
+ rollback;
@@ -0,0 +1,419 @@
1
+ -- #153 whole-domain squatting arbitration — reassign a squatted fleet_trust_domain's owner.
2
+ --
3
+ -- reclaim_fleet_trust_domain (service_role only) is the operator arbitration path for a WHOLE
4
+ -- domain squatted by someone other than its rightful owner — the residual #150's machine-level
5
+ -- reclaim could not cover. It reassigns fleet_trust_domains.owner_agent_id to the arbitrated new
6
+ -- owner under a row lock; the reassignment is atomic at the domain row and post-commit every authz
7
+ -- site (revoke_fleet_subject('trust_domain'), register_fleet_machine, set_fleet_receive_policy) reads
8
+ -- the new owner. SERIALIZATION CAVEAT (#159): only revoke_fleet_subject('trust_domain') is locked
9
+ -- against an in-flight reclaim here; register_fleet_machine / set_fleet_receive_policy are NOT yet
10
+ -- serialized (tracked in #159). It writes NO fleet_revocation_events row (a trust_domain ledger row
11
+ -- over-denies the whole domain incl. the new owner — #150 divergence rationale), and APPENDS the
12
+ -- audit to metadata.reclaim_history.
13
+ --
14
+ -- These assertions pin:
15
+ -- * reclaim reassigns owner_agent_id (state + returned prior owner) — PIN: skip the reassign ⇒
16
+ -- (3)/(10)/(12)/(13) fail (owner stays squatter, authority does not move).
17
+ -- * NO trust_domain revocation event is written by reclaim (7) — PIN: add a revoke-event write ⇒
18
+ -- a row appears ⇒ (7) fails. Pins the over-deny divergence rationale.
19
+ -- * NON-GOAL scope: a machine in the reclaimed domain KEEPS owner=squatter (8) — PIN: also
20
+ -- reassign machine owners ⇒ (8) fails. Documents domain-vs-machine separation.
21
+ -- * AUTHORITY BRACKET (rule #2/#3): each authority probe is bracketed by a BEFORE probe under the
22
+ -- same authenticated principal proving current_agent_id() resolves — so an AFTER flip is a
23
+ -- genuine authority change, not a null-actor artifact. register_fleet_machine into the domain is
24
+ -- the reversible authority lever: squatter CAN before / CANNOT after; new owner CANNOT before /
25
+ -- CAN after (9-12). The kill switch itself: squatter's revoke('trust_domain') is REFUSED after
26
+ -- reclaim (13) — PIN: skip reassign ⇒ squatter still owns ⇒ not refused ⇒ (13) fails.
27
+ -- * reclaim is operator-only: an authenticated caller (the victim) is refused 42501 (14) — PIN:
28
+ -- remove the _is_service_role() body guard ⇒ (14) fails (body guard isolated from the ACL by a
29
+ -- temp grant to authenticated).
30
+ -- * reclaim validates the new owner + provenance agent exist and the domain exists (15,16,17).
31
+ -- * REVOKED-DOMAIN RESIDUAL (documented, positive pin): reclaim of a domain the squatter already
32
+ -- revoked SUCCEEDS + transfers owner (18,19) but the domain STAYS revoked (20) — no domain
33
+ -- un-revoke path today. Pins the boundary positively (rule #1) rather than leaving a silent gap.
34
+ -- * AUDIT APPEND (rule #2): a second reclaim APPENDS to reclaim_history (21) — PIN: overwrite ⇒
35
+ -- length stays 1 ⇒ (21) fails.
36
+ -- * CLASS (rule #1 / EC-4): reclaim_fleet_trust_domain is the ONLY function-writer of
37
+ -- fleet_trust_domains.owner_agent_id (22) — catalog-derived, so a future owner-writer names
38
+ -- itself. (No RPC INSERTs a domain; domains are seeded by direct table INSERT, which is not a
39
+ -- function and cannot match.)
40
+ --
41
+ -- Tests run as session_user=postgres ⇒ _is_service_role() is true; probes and the authz assertion
42
+ -- flip to role=authenticated (jwt claims) to exercise the principal / body guard.
43
+
44
+ create extension if not exists pgtap with schema extensions;
45
+ set search_path = extensions, public, agent_board;
46
+
47
+ begin;
48
+
49
+ select plan(22);
50
+
51
+ -- (1) Shape.
52
+ select has_function(
53
+ 'agent_board', 'reclaim_fleet_trust_domain',
54
+ array['uuid', 'uuid', 'uuid', 'text'],
55
+ 'reclaim_fleet_trust_domain exists'
56
+ );
57
+
58
+ create temp table _dr_fx (
59
+ operator uuid, -- arbitration actor (p_reclaimed_by); no auth mapping needed
60
+ squatter uuid, -- squats the domains; owner of dom_core/dom_revoked/dom_authz
61
+ new_owner uuid, -- arbitrated rightful owner
62
+ victim uuid, -- used for the operator-only authz probe
63
+ dom_core uuid, -- main reclaim + authority + kill-switch
64
+ dom_revoked uuid, -- revoked by the squatter BEFORE reclaim (residual pin)
65
+ dom_authz uuid, -- operator-only probe target
66
+ m_core uuid, -- a machine in dom_core (non-goal: stays owned by squatter)
67
+ squatter_user uuid,
68
+ new_owner_user uuid,
69
+ victim_user uuid
70
+ ) on commit drop;
71
+ grant all on table _dr_fx to authenticated, service_role;
72
+
73
+ create temp table _dr_probe (label text, allowed boolean) on commit drop;
74
+ grant all on table _dr_probe to authenticated, service_role;
75
+
76
+ do $$
77
+ declare
78
+ v_operator uuid := gen_random_uuid();
79
+ v_squatter uuid := gen_random_uuid();
80
+ v_new_owner uuid := gen_random_uuid();
81
+ v_victim uuid := gen_random_uuid();
82
+ v_dom_core uuid;
83
+ v_dom_revoked uuid;
84
+ v_dom_authz uuid;
85
+ v_m_core uuid;
86
+ begin
87
+ insert into agent_board.agents(id, agent_type, handle, display_name)
88
+ values
89
+ (v_operator, 'agent', 'dr-operator', 'DR Operator'),
90
+ (v_squatter, 'agent', 'dr-squatter', 'DR Squatter'),
91
+ (v_new_owner, 'agent', 'dr-new-owner', 'DR New Owner'),
92
+ (v_victim, 'agent', 'dr-victim', 'DR Victim');
93
+
94
+ -- All three domains are squatted (owned by the squatter). dom_core + dom_authz active; dom_revoked
95
+ -- active now (revoked below via the squatter's own kill switch to exercise the residual).
96
+ insert into agent_board.fleet_trust_domains(slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
97
+ values
98
+ ('dr-domain-core', 'DR Domain Core', 'tailnet', v_squatter, 'active', now()),
99
+ ('dr-domain-revoked', 'DR Domain Revoked', 'tailnet', v_squatter, 'active', now()),
100
+ ('dr-domain-authz', 'DR Domain Authz', 'tailnet', v_squatter, 'active', now());
101
+ select id into v_dom_core from agent_board.fleet_trust_domains where slug = 'dr-domain-core';
102
+ select id into v_dom_revoked from agent_board.fleet_trust_domains where slug = 'dr-domain-revoked';
103
+ select id into v_dom_authz from agent_board.fleet_trust_domains where slug = 'dr-domain-authz';
104
+
105
+ -- A machine the squatter owns inside dom_core (non-goal: domain reclaim must NOT reassign it).
106
+ select machine_id into v_m_core from agent_board.register_fleet_machine(
107
+ v_squatter, v_dom_core, 'dr-m-core', 'sha256:dr-m-core', 'portal', '{"redacted":true}'::jsonb);
108
+
109
+ insert into _dr_fx(operator, squatter, new_owner, victim, dom_core, dom_revoked, dom_authz, m_core)
110
+ values (v_operator, v_squatter, v_new_owner, v_victim, v_dom_core, v_dom_revoked, v_dom_authz, v_m_core);
111
+ end
112
+ $$;
113
+
114
+ -- Map the squatter, new owner and victim agents to auth users so the authenticated probes/authz
115
+ -- call resolve a principal via current_agent_id() → auth.uid().
116
+ do $$
117
+ declare
118
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
119
+ v_squatter_user uuid;
120
+ v_new_owner_user uuid;
121
+ v_victim_user uuid;
122
+ begin
123
+ v_squatter_user := public.ensure_github_user('dr-squatter-' || v_sfx, 'dr-squatter-' || v_sfx || '@example.test', 'dr-squatter-' || v_sfx, 'DR Squatter ' || v_sfx);
124
+ v_new_owner_user := public.ensure_github_user('dr-newowner-' || v_sfx, 'dr-newowner-' || v_sfx || '@example.test', 'dr-newowner-' || v_sfx, 'DR New Owner ' || v_sfx);
125
+ v_victim_user := public.ensure_github_user('dr-victim-' || v_sfx, 'dr-victim-' || v_sfx || '@example.test', 'dr-victim-' || v_sfx, 'DR Victim ' || v_sfx);
126
+
127
+ update agent_board.agents set auth_user_id = v_squatter_user where id = (select squatter from _dr_fx);
128
+ update agent_board.agents set auth_user_id = v_new_owner_user where id = (select new_owner from _dr_fx);
129
+ update agent_board.agents set auth_user_id = v_victim_user where id = (select victim from _dr_fx);
130
+
131
+ update _dr_fx set squatter_user = v_squatter_user, new_owner_user = v_new_owner_user, victim_user = v_victim_user;
132
+ end
133
+ $$;
134
+
135
+ ------------------------------------------------------------------------------------------
136
+ -- BEFORE authority probes (rule #2/#3 bracket): register_fleet_machine into dom_core is the
137
+ -- reversible authority lever (requires ftd.owner_agent_id = actor). The squatter (owner) CAN; the
138
+ -- new owner CANNOT — proving both principals resolve, so the AFTER flips below are genuine.
139
+ ------------------------------------------------------------------------------------------
140
+ set local role authenticated;
141
+ select set_config('request.jwt.claim.role', 'authenticated', true);
142
+ select set_config('request.jwt.claim.sub', (select squatter_user::text from _dr_fx), true);
143
+ do $$
144
+ begin
145
+ begin
146
+ perform agent_board.register_fleet_machine((select squatter from _dr_fx), (select dom_core from _dr_fx),
147
+ 'dr-m-sqbefore', 'sha256:dr-m-sqbefore', 'portal', '{"redacted":true}'::jsonb);
148
+ insert into _dr_probe values ('squatter_before', true);
149
+ exception when sqlstate '42501' then insert into _dr_probe values ('squatter_before', false);
150
+ end;
151
+ end $$;
152
+ select set_config('request.jwt.claim.sub', (select new_owner_user::text from _dr_fx), true);
153
+ do $$
154
+ begin
155
+ begin
156
+ perform agent_board.register_fleet_machine((select new_owner from _dr_fx), (select dom_core from _dr_fx),
157
+ 'dr-m-nobefore', 'sha256:dr-m-nobefore', 'portal', '{"redacted":true}'::jsonb);
158
+ insert into _dr_probe values ('new_owner_before', true);
159
+ exception when sqlstate '42501' then insert into _dr_probe values ('new_owner_before', false);
160
+ end;
161
+ end $$;
162
+ reset role;
163
+ select set_config('request.jwt.claim.role', '', true);
164
+ select set_config('request.jwt.claim.sub', '', true);
165
+
166
+ ------------------------------------------------------------------------------------------
167
+ -- CORE: operator reclaims dom_core from the squatter to the new owner.
168
+ ------------------------------------------------------------------------------------------
169
+ -- (2) Reclaim succeeds (allow-side lives_ok + separate state reads, #113).
170
+ select lives_ok(
171
+ $$ create temp table _dr_core as
172
+ select * from agent_board.reclaim_fleet_trust_domain(
173
+ (select dom_core from _dr_fx), (select new_owner from _dr_fx),
174
+ (select operator from _dr_fx), 'operator arbitrated: domain belongs to new owner') $$,
175
+ 'core: service_role reclaim of dom_core succeeds'
176
+ );
177
+
178
+ -- (3) Owner is reassigned. MUTATION PIN: skipping the owner reassignment leaves owner=squatter.
179
+ select is(
180
+ (select owner_agent_id from agent_board.fleet_trust_domains where id = (select dom_core from _dr_fx)),
181
+ (select new_owner from _dr_fx),
182
+ 'core: reclaim reassigns owner_agent_id to the new owner'
183
+ );
184
+
185
+ -- (4) The returned prior owner is the squatter.
186
+ select is(
187
+ (select prior_owner_agent_id from _dr_core),
188
+ (select squatter from _dr_fx),
189
+ 'core: reclaim returns the prior owner (the squatter)'
190
+ );
191
+
192
+ -- (5) Audit APPENDED to metadata.reclaim_history: last record has operator + prior owner.
193
+ select ok(
194
+ (select (metadata -> 'reclaim_history' -> -1 ->> 'reclaimed_by') = (select operator::text from _dr_fx)
195
+ and (metadata -> 'reclaim_history' -> -1 ->> 'prior_owner') = (select squatter::text from _dr_fx)
196
+ from agent_board.fleet_trust_domains where id = (select dom_core from _dr_fx)),
197
+ 'core: reclaim audit (reclaimed_by + prior owner) appended to metadata.reclaim_history'
198
+ );
199
+
200
+ -- (6) The audit records the prior lifecycle (active here) — makes the revoked residual visible.
201
+ select is(
202
+ (select metadata -> 'reclaim_history' -> -1 ->> 'prior_lifecycle_state'
203
+ from agent_board.fleet_trust_domains where id = (select dom_core from _dr_fx)),
204
+ 'active',
205
+ 'core: reclaim audit records prior_lifecycle_state'
206
+ );
207
+
208
+ -- (7) NO trust_domain revocation event is written by reclaim (over-deny divergence rationale).
209
+ -- MUTATION PIN: adding a fleet_revocation_events write to reclaim makes a row appear → this flips.
210
+ select is(
211
+ (select count(*)::integer from agent_board.fleet_revocation_events
212
+ where subject_kind = 'trust_domain' and subject_id = (select dom_core from _dr_fx)),
213
+ 0,
214
+ 'core: reclaim writes NO trust_domain revocation event (no over-deny)'
215
+ );
216
+
217
+ -- (8) NON-GOAL: a machine in dom_core KEEPS owner=squatter (domain reclaim ≠ machine reclaim).
218
+ -- MUTATION PIN: also reassigning machine owners in reclaim flips this to new_owner.
219
+ select is(
220
+ (select owner_agent_id from agent_board.fleet_machines where id = (select m_core from _dr_fx)),
221
+ (select squatter from _dr_fx),
222
+ 'non-goal: a machine in the reclaimed domain keeps owner=squatter'
223
+ );
224
+
225
+ ------------------------------------------------------------------------------------------
226
+ -- AFTER authority probes: register authority on dom_core post-reclaim, then the kill switch.
227
+ ------------------------------------------------------------------------------------------
228
+ set local role authenticated;
229
+ select set_config('request.jwt.claim.role', 'authenticated', true);
230
+ select set_config('request.jwt.claim.sub', (select squatter_user::text from _dr_fx), true);
231
+ do $$
232
+ begin
233
+ begin
234
+ perform agent_board.register_fleet_machine((select squatter from _dr_fx), (select dom_core from _dr_fx),
235
+ 'dr-m-sqafter', 'sha256:dr-m-sqafter', 'portal', '{"redacted":true}'::jsonb);
236
+ insert into _dr_probe values ('squatter_after', true);
237
+ exception when sqlstate '42501' then insert into _dr_probe values ('squatter_after', false);
238
+ end;
239
+ end $$;
240
+ select set_config('request.jwt.claim.sub', (select new_owner_user::text from _dr_fx), true);
241
+ do $$
242
+ begin
243
+ begin
244
+ perform agent_board.register_fleet_machine((select new_owner from _dr_fx), (select dom_core from _dr_fx),
245
+ 'dr-m-noafter', 'sha256:dr-m-noafter', 'portal', '{"redacted":true}'::jsonb);
246
+ insert into _dr_probe values ('new_owner_after', true);
247
+ exception when sqlstate '42501' then insert into _dr_probe values ('new_owner_after', false);
248
+ end;
249
+ end $$;
250
+ -- Kill switch: the squatter (no longer owner) tries revoke_fleet_subject('trust_domain', dom_core).
251
+ select set_config('request.jwt.claim.sub', (select squatter_user::text from _dr_fx), true);
252
+ do $$
253
+ begin
254
+ begin
255
+ perform agent_board.revoke_fleet_subject('trust_domain', (select dom_core from _dr_fx),
256
+ (select squatter from _dr_fx), 'squatter tries the kill switch after losing the domain',
257
+ '{"redacted":true}'::jsonb);
258
+ insert into _dr_probe values ('squatter_killswitch_denied', false); -- reached completion → NOT denied
259
+ exception when sqlstate '42501' then insert into _dr_probe values ('squatter_killswitch_denied', true);
260
+ end;
261
+ end $$;
262
+ reset role;
263
+ select set_config('request.jwt.claim.role', '', true);
264
+ select set_config('request.jwt.claim.sub', '', true);
265
+
266
+ -- (9)(10) Authority lever (register): squatter COULD before (bracket) → CANNOT after (PIN skip reassign).
267
+ select is((select allowed from _dr_probe where label = 'squatter_before'), true,
268
+ 'bracket: the squatter (owner) could register into dom_core before reclaim');
269
+ select is((select allowed from _dr_probe where label = 'squatter_after'), false,
270
+ 'squatter: loses domain authority (cannot register into dom_core) after reclaim');
271
+ -- (11)(12) New owner: CANNOT before (bracket) → CAN after (PIN skip reassign).
272
+ select is((select allowed from _dr_probe where label = 'new_owner_before'), false,
273
+ 'bracket: the new owner could NOT register into dom_core before reclaim');
274
+ select is((select allowed from _dr_probe where label = 'new_owner_after'), true,
275
+ 'new owner: gains domain authority (can register into dom_core) after reclaim');
276
+ -- (13) KILL SWITCH removed: the squatter's revoke('trust_domain') is refused after reclaim.
277
+ -- MUTATION PIN: skipping the owner reassign leaves the squatter owning dom_core → revoke is NOT
278
+ -- refused → denied=false → this flips.
279
+ select is((select allowed from _dr_probe where label = 'squatter_killswitch_denied'), true,
280
+ 'kill-switch: the squatter''s revoke(trust_domain) is refused (42501) after reclaim');
281
+
282
+ ------------------------------------------------------------------------------------------
283
+ -- AUTHZ (dom_authz): operator-only. An authenticated caller (the victim) is refused. The BODY guard
284
+ -- is isolated from the service_role-only grant by a temp grant to authenticated.
285
+ ------------------------------------------------------------------------------------------
286
+ create temp table _dr_authz (denied boolean) on commit drop;
287
+ grant all on table _dr_authz to authenticated, service_role;
288
+
289
+ grant execute on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) to authenticated;
290
+
291
+ set local role authenticated;
292
+ select set_config('request.jwt.claim.role', 'authenticated', true);
293
+ select set_config('request.jwt.claim.sub', (select victim_user::text from _dr_fx), true);
294
+ do $$
295
+ begin
296
+ begin
297
+ perform * from agent_board.reclaim_fleet_trust_domain(
298
+ (select dom_authz from _dr_fx), (select victim from _dr_fx),
299
+ (select victim from _dr_fx), 'victim self-reclaim attempt');
300
+ insert into _dr_authz values (false); -- reached completion → NOT denied
301
+ exception when sqlstate '42501' then insert into _dr_authz values (true);
302
+ end;
303
+ end
304
+ $$;
305
+ reset role;
306
+ select set_config('request.jwt.claim.role', '', true);
307
+ select set_config('request.jwt.claim.sub', '', true);
308
+ revoke execute on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) from authenticated;
309
+
310
+ -- (14) MUTATION PIN: removing the _is_service_role() body guard lets the authenticated call
311
+ -- complete → denied=false → this flips.
312
+ select ok(
313
+ (select denied from _dr_authz),
314
+ 'authz: authenticated caller (the victim) cannot reclaim (42501) — operator-only'
315
+ );
316
+
317
+ ------------------------------------------------------------------------------------------
318
+ -- VALIDATION.
319
+ ------------------------------------------------------------------------------------------
320
+ -- (15) unknown new owner
321
+ select throws_ok(
322
+ format($$ select * from agent_board.reclaim_fleet_trust_domain(%L::uuid, %L::uuid, %L::uuid, 'unknown new owner') $$,
323
+ (select dom_authz from _dr_fx), gen_random_uuid(), (select operator from _dr_fx)),
324
+ '22023', NULL, 'validate: reclaim to an unknown new owner raises'
325
+ );
326
+ -- (16) unknown provenance
327
+ select throws_ok(
328
+ format($$ select * from agent_board.reclaim_fleet_trust_domain(%L::uuid, %L::uuid, %L::uuid, 'unknown provenance') $$,
329
+ (select dom_authz from _dr_fx), (select new_owner from _dr_fx), gen_random_uuid()),
330
+ '22023', NULL, 'validate: reclaim with an unknown reclaimed_by agent raises'
331
+ );
332
+ -- (17) unknown domain
333
+ select throws_ok(
334
+ format($$ select * from agent_board.reclaim_fleet_trust_domain(%L::uuid, %L::uuid, %L::uuid, 'unknown domain') $$,
335
+ gen_random_uuid(), (select new_owner from _dr_fx), (select operator from _dr_fx)),
336
+ '22023', NULL, 'validate: reclaim of a non-existent domain raises'
337
+ );
338
+
339
+ ------------------------------------------------------------------------------------------
340
+ -- REVOKED-DOMAIN RESIDUAL (documented, positive pin): the squatter revokes dom_revoked first; the
341
+ -- operator then reclaims it. Ownership transfers, but the domain STAYS revoked (no un-revoke path).
342
+ ------------------------------------------------------------------------------------------
343
+ do $$
344
+ begin
345
+ perform agent_board.revoke_fleet_subject('trust_domain', (select dom_revoked from _dr_fx),
346
+ (select squatter from _dr_fx), 'squatter revoke before arbitration', '{"redacted":true}'::jsonb);
347
+ end
348
+ $$;
349
+ -- (18)
350
+ select lives_ok(
351
+ $$ select * from agent_board.reclaim_fleet_trust_domain(
352
+ (select dom_revoked from _dr_fx), (select new_owner from _dr_fx),
353
+ (select operator from _dr_fx), 'reclaim a revoked-then-squatted domain') $$,
354
+ 'revoked-residual: reclaiming a revoked domain succeeds'
355
+ );
356
+ -- (19)
357
+ select is(
358
+ (select owner_agent_id from agent_board.fleet_trust_domains where id = (select dom_revoked from _dr_fx)),
359
+ (select new_owner from _dr_fx),
360
+ 'revoked-residual: reclaim of a revoked domain still transfers ownership'
361
+ );
362
+ -- (20) BOUNDARY AS BEHAVIOR: the owner-transfer SUCCEEDED (owner=new_owner) AND the domain is STILL
363
+ -- revoked — one compound assertion so the intentional residual is pinned as behavior, not inferred
364
+ -- across two checks. PIN (mutation H): making reclaim revive the domain (lifecycle='active' on the
365
+ -- revoked branch) flips this; skipping the reassign (owner stays squatter) also flips it.
366
+ select ok(
367
+ (select owner_agent_id = (select new_owner from _dr_fx) and lifecycle_state = 'revoked'
368
+ from agent_board.fleet_trust_domains where id = (select dom_revoked from _dr_fx)),
369
+ 'revoked-residual: after a successful owner-transfer the pre-revoked domain STAYS revoked (intentional boundary)'
370
+ );
371
+
372
+ ------------------------------------------------------------------------------------------
373
+ -- AUDIT APPEND (rule #2): a SECOND reclaim of dom_core APPENDS to reclaim_history.
374
+ ------------------------------------------------------------------------------------------
375
+ do $$
376
+ begin
377
+ perform * from agent_board.reclaim_fleet_trust_domain(
378
+ (select dom_core from _dr_fx), (select victim from _dr_fx),
379
+ (select operator from _dr_fx), 'second arbitration on the same domain');
380
+ end
381
+ $$;
382
+ -- (21) MUTATION PIN: reverting the audit to overwrite (single-element array / flat keys) leaves
383
+ -- reclaim_history at length 1 → this flips.
384
+ select is(
385
+ (select jsonb_array_length(metadata -> 'reclaim_history') from agent_board.fleet_trust_domains where id = (select dom_core from _dr_fx)),
386
+ 2,
387
+ 'audit: a repeated reclaim appends to reclaim_history (prior record preserved)'
388
+ );
389
+
390
+ ------------------------------------------------------------------------------------------
391
+ -- CLASS (rule #1 / EC-4): catalog-derived — reclaim_fleet_trust_domain is the ONLY writer of
392
+ -- fleet_trust_domains.owner_agent_id. A future owner-writer appears here BY NAME. WRITER = a
393
+ -- function whose definition INSERTs owner_agent_id into fleet_trust_domains or UPDATEs
394
+ -- fleet_trust_domains assigning owner_agent_id. Reads (WHERE owner_agent_id = ...) and the lifecycle
395
+ -- -only revoke UPDATE (no owner_agent_id =) do not match. Aggregates excluded (prokind='f').
396
+ ------------------------------------------------------------------------------------------
397
+ -- (22)
398
+ select is(
399
+ (
400
+ select string_agg(proname, ', ' order by proname)
401
+ from (
402
+ select p.proname
403
+ from pg_proc p
404
+ join pg_namespace n on n.oid = p.pronamespace
405
+ where n.nspname = 'agent_board'
406
+ and p.prokind = 'f'
407
+ and (
408
+ pg_get_functiondef(p.oid) ~ 'insert into agent_board\.fleet_trust_domains\s*\([^)]*owner_agent_id'
409
+ or pg_get_functiondef(p.oid) ~ 'update agent_board\.fleet_trust_domains[^;]*owner_agent_id\s*='
410
+ )
411
+ ) writers
412
+ ),
413
+ 'reclaim_fleet_trust_domain',
414
+ 'class: reclaim_fleet_trust_domain is the only writer of fleet_trust_domains.owner_agent_id (a new writer names itself)'
415
+ );
416
+
417
+ select * from finish();
418
+
419
+ rollback;