@consiliency/agent-board-schema 1.12.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/fixtures/already-applied.json +124 -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/package.json +1 -1
  34. package/schema-manifest.json +282 -3
  35. package/src/tooling.test.mjs +113 -2
  36. package/tests/agent_board_actorspoof.test.sql +349 -0
  37. package/tests/agent_board_agentsdir.test.sql +154 -0
  38. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  39. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  40. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  41. package/tests/agent_board_claimkey.test.sql +229 -0
  42. package/tests/agent_board_consent_backfill.test.sql +48 -0
  43. package/tests/agent_board_consent_emission.test.sql +601 -0
  44. package/tests/agent_board_consent_gating.test.sql +489 -0
  45. package/tests/agent_board_consent_substrate.test.sql +720 -0
  46. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  47. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  48. package/tests/agent_board_delegation_policies.test.sql +107 -1
  49. package/tests/agent_board_fedsession.test.sql +6 -4
  50. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  51. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  52. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  53. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  54. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  55. package/tests/agent_board_fleet_policy.test.sql +2 -2
  56. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  57. package/tests/agent_board_identity_providers.test.sql +9 -0
  58. package/tests/agent_board_inbox_reads.test.sql +29 -12
  59. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  60. package/tests/agent_board_invitepolicy.test.sql +205 -0
  61. package/tests/agent_board_leaveidem.test.sql +194 -0
  62. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  63. package/tests/agent_board_mint_harden.test.sql +303 -0
  64. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  65. package/tests/agent_board_obsgate.test.sql +178 -0
  66. package/tests/agent_board_obsgate2.test.sql +153 -0
  67. package/tests/agent_board_obsgate3.test.sql +170 -0
  68. package/tests/agent_board_obsgate4.test.sql +196 -0
  69. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  70. package/tests/agent_board_pubexec_class.test.sql +53 -0
  71. package/tests/agent_board_rejectsem.test.sql +271 -0
  72. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  73. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  74. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  75. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  76. package/tests/agent_board_rolereset.test.sql +351 -0
  77. package/tests/agent_board_roomdeleg.test.sql +5 -3
  78. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  79. package/tests/agent_board_roomlink.test.sql +25 -9
  80. package/tests/agent_board_rowid.test.sql +1274 -0
  81. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  82. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  83. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  84. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  85. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  86. package/tests/agent_board_signing_store.test.sql +6 -6
  87. package/tests/fixtures/isolation-substrate.sql +93 -0
  88. package/tests/fixtures/test-prereqs.sql +33 -0
  89. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  90. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  91. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  92. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  93. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  94. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  95. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  96. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  97. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  98. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  99. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  100. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  101. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  102. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  103. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  104. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,432 @@
1
+ -- #146 operator squatting-reclaim — reassign a squatted machine + revoke squatter access.
2
+ --
3
+ -- machine_fingerprint is GLOBALLY unique. #141 (20260823000100) made register_fleet_machine
4
+ -- raise 42501 on a cross-owner fingerprint conflict (ownership is immutable via re-register).
5
+ -- That correctly stops post-registration hijacking, but it also means: if a SQUATTER registers
6
+ -- a victim's fingerprint FIRST, the victim is locked out permanently — the only prior recovery
7
+ -- was service_role table surgery. This migration adds the authorized reclaim path.
8
+ --
9
+ -- Ownership here is ARBITRATION, not self-service: deciding the rightful owner of a contested
10
+ -- fingerprint requires out-of-band verification that only an OPERATOR can perform. So
11
+ -- reclaim_fleet_machine is service_role-only and raises 42501 for any other caller — there is
12
+ -- deliberately no owner-facing or victim-facing self-reclaim (that would just re-create the
13
+ -- squatting race in the other direction).
14
+ --
15
+ -- Reclaim does three things atomically under a row lock:
16
+ -- 1. Reassigns owner_agent_id to the arbitrated new owner. This is the ONLY writer besides
17
+ -- register_fleet_machine that changes owner_agent_id (see the comment at the UPDATE).
18
+ -- 2. Revokes the SQUATTER's access to THIS machine by flipping every ACTIVE
19
+ -- fleet_machine_agents binding on it to 'revoked'. This is SCOPED to the machine — it is
20
+ -- deliberately NOT revoke_fleet_subject('agent', ...), which is GLOBAL (it flips every one
21
+ -- of that agent's bindings on every machine and writes a subject_kind='agent' ledger row
22
+ -- that evaluate_fleet_sender_policy honors WITHOUT machine scope). A squatter can be an
23
+ -- otherwise-legitimate fleet member; reclaiming one machine must not deny them everywhere.
24
+ -- NOTE (divergence from the task suggestion, on correctness grounds): we do NOT write a
25
+ -- fleet_revocation_events row per revoked binding. fleet_revocation_events has no
26
+ -- binding/machine-agent subject_kind (the enum is trust_domain/machine/harness/repo_scope/
27
+ -- agent/signing_key/credential), and every available subject_kind has a policy side effect
28
+ -- that OVER-denies: 'agent' denies the squatter as a sender on ALL their machines;
29
+ -- 'machine' would deny the NEW owner too. For a binding, lifecycle_state IS the authority
30
+ -- by design — BOTH readers that gate the squatter (_can_claim_resource and the policy's
31
+ -- binding lookup) read fma.lifecycle_state='active' directly — so flipping it is the
32
+ -- correct mechanism, not a shortcut around the ledger. The ledger-authority principle from
33
+ -- #141/#146 governs machine/domain/agent subjects, not per-binding access. The reclaim
34
+ -- audit (who/why/prior owner/revoked agents) is recorded on fleet_machines.metadata.
35
+ -- 3. Leaves the machine ACTIVE for the new owner. If the squatted machine had been revoked,
36
+ -- reclaim sets lifecycle_state='active', clears revoked_at, and stamps reactivated_at=now()
37
+ -- so any prior machine-revocation event is superseded (same supersession mechanism as
38
+ -- reactivate_fleet_machine, #146) and evaluate_fleet_sender_policy lets the new owner use
39
+ -- it. reclaim does NOT read machine-revocation events itself.
40
+ --
41
+ -- TRUST-DOMAIN KILL SWITCH (#150 panel MUST-FIX). A reclaimed machine still points at its
42
+ -- original trust_domain_id, which the SQUATTER may still OWN. A trust-domain owner can
43
+ -- revoke_fleet_subject('trust_domain', ...) on their own domain (the authenticated domain-owner
44
+ -- path), and evaluate_fleet_sender_policy's trust_domain clause then denies EVERY machine in that
45
+ -- domain — including the reclaimed one. So if reclaim leaves the machine in the squatter's domain,
46
+ -- the squatter retains a kill switch: the new owner can send only until the squatter revokes the
47
+ -- domain. To close it, reclaim takes an OPTIONAL p_new_trust_domain_id:
48
+ -- * When PROVIDED, it is validated (exists, active, owned by the NEW owner) and the machine is
49
+ -- MOVED into it, so the reclaimed machine sits in a domain the new owner controls — the
50
+ -- squatter's domain revocation can no longer reach it.
51
+ -- * When NULL, the machine STAYS in its current (squatter-owned) domain and the squatter RETAINS
52
+ -- the kill switch described above. This is an accepted, DOCUMENTED residual: reclaiming a whole
53
+ -- squatted DOMAIN (and the case where the new owner has no domain to move into) is broader
54
+ -- ownership arbitration filed as a separate follow-up, out of scope here. Operators reclaiming
55
+ -- into a squatter-owned domain should pass p_new_trust_domain_id.
56
+ --
57
+ -- SCOPE / non-goals (matches the issue): reclaim reassigns the MACHINE (owner, and optionally the
58
+ -- trust domain per above). It does NOT revoke the trust domain itself, and does NOT revoke the
59
+ -- machine subject (the new owner keeps it active). Whole-domain squatting arbitration is a separate
60
+ -- follow-up.
61
+ --
62
+ -- #150 codex red-team MUST-FIXes folded in here:
63
+ -- * MUST-FIX 1 — revoke_fleet_subject TOCTOU: its machine-ownership check was unlocked, so a
64
+ -- squatter's concurrent revoke('machine', M) could pass on the pre-reclaim snapshot and land
65
+ -- its lifecycle UPDATE AFTER reclaim, nuking the just-handed-over machine. revoke_fleet_subject
66
+ -- is recreated below to LOCK the owning fleet_machines row before the ownership check (a
67
+ -- concurrent reclaim serializes it; a stale-owner revoke is then refused 42501). Pinned by the
68
+ -- reclaim_revoke_race isolation spec.
69
+ -- * MUST-FIX 2 — a squatter's ACTIVE machine CLAIM survived reclaim (claims key on the bare
70
+ -- fingerprint and reclaim never touched agent_board.claims), so the new owner's acquire_claim
71
+ -- raised 23505 until lease expiry. reclaim now releases the squatter's active machine claim(s)
72
+ -- on the reclaimed fingerprint (step 2b).
73
+ --
74
+ -- #118 discipline: reclaim_fleet_machine (and the recreated revoke_fleet_subject) are SECURITY
75
+ -- DEFINER — restate the revoke-from-public and the grant and the comment in this same migration.
76
+
77
+ -- reactivate_fleet_machine (20260826000100) introduced fleet_machines.reactivated_at as the
78
+ -- machine-revocation supersession point. reclaim also stamps it (step 3), so widen the column
79
+ -- comment. (Additive: the supersession semantics are unchanged.)
80
+ comment on column agent_board.fleet_machines.reactivated_at is
81
+ 'Supersession point (#146): machine-revocation events with effective_at <= this are ignored by evaluate_fleet_sender_policy and v_fleet_machine_status. Set by reactivate_fleet_machine and reclaim_fleet_machine; null means never reactivated/reclaimed.';
82
+
83
+ create or replace function agent_board.reclaim_fleet_machine(
84
+ p_machine_id uuid,
85
+ p_new_owner_agent_id uuid,
86
+ p_reclaimed_by_agent_id uuid,
87
+ p_reason text default null,
88
+ p_new_trust_domain_id uuid default null
89
+ )
90
+ returns table(machine_id uuid, owner_agent_id uuid, trust_domain_id uuid, lifecycle_state text, revoked_binding_count integer)
91
+ language plpgsql
92
+ security definer
93
+ set search_path = agent_board, public
94
+ as $$
95
+ declare
96
+ v_machine agent_board.fleet_machines%rowtype;
97
+ v_prior_owner uuid;
98
+ v_prior_domain uuid;
99
+ v_revoked_count integer;
100
+ v_revoked_agents uuid[];
101
+ v_released_claims integer;
102
+ begin
103
+ -- Operator-only: ownership arbitration for a contested fingerprint is not self-service.
104
+ if not agent_board._is_service_role() then
105
+ raise exception 'reclaim_fleet_machine is operator-only (service_role)' using errcode = '42501';
106
+ end if;
107
+
108
+ -- Provenance is required and must name a real agent (audit lands in metadata).
109
+ if p_reclaimed_by_agent_id is null then
110
+ raise exception 'p_reclaimed_by_agent_id is required' using errcode = '22023';
111
+ end if;
112
+ if not exists (
113
+ select 1 from agent_board.agents a where a.id = p_reclaimed_by_agent_id
114
+ ) then
115
+ raise exception 'p_reclaimed_by_agent_id is not a known agent' using errcode = '22023';
116
+ end if;
117
+
118
+ -- The arbitrated new owner must exist (validate before we revoke anything).
119
+ if p_new_owner_agent_id is null then
120
+ raise exception 'p_new_owner_agent_id is required' using errcode = '22023';
121
+ end if;
122
+ if not exists (
123
+ select 1 from agent_board.agents a where a.id = p_new_owner_agent_id
124
+ ) then
125
+ raise exception 'p_new_owner_agent_id is not a known agent' using errcode = '22023';
126
+ end if;
127
+
128
+ -- Kill-switch close (#150 panel): if a destination trust domain is given, it must be an ACTIVE
129
+ -- domain OWNED BY THE NEW OWNER — otherwise the move would either park the machine somewhere the
130
+ -- new owner cannot control or leave it in a domain a third party (e.g. the squatter) can revoke,
131
+ -- which is exactly the kill switch this parameter exists to close. Validated before any writes.
132
+ if p_new_trust_domain_id is not null and not exists (
133
+ select 1 from agent_board.fleet_trust_domains ftd
134
+ where ftd.id = p_new_trust_domain_id
135
+ and ftd.lifecycle_state = 'active'
136
+ and ftd.owner_agent_id = p_new_owner_agent_id
137
+ ) then
138
+ raise exception 'p_new_trust_domain_id must be an active trust domain owned by the new owner' using errcode = '42501';
139
+ end if;
140
+
141
+ -- Lock the machine row so this read-revoke-reassign serializes against a concurrent
142
+ -- register_fleet_machine / revoke_fleet_subject / reactivate_fleet_machine (each of which
143
+ -- also takes a row lock on fleet_machines). Without it a concurrent revoke or re-register
144
+ -- could interleave and be lost. (Reuses the reactivation race lesson, #146.)
145
+ select * into v_machine from agent_board.fleet_machines fm where fm.id = p_machine_id for update;
146
+ if v_machine.id is null then
147
+ raise exception 'fleet machine not found' using errcode = '22023';
148
+ end if;
149
+
150
+ v_prior_owner := v_machine.owner_agent_id;
151
+ v_prior_domain := v_machine.trust_domain_id;
152
+
153
+ -- (2) Revoke the squatter's access to THIS machine only: flip every currently-active binding
154
+ -- on it to 'revoked'. All bindings present at reclaim time were established under the prior
155
+ -- (squatter) owner; the new owner starts from a clean machine and creates their own bindings.
156
+ -- lifecycle_state is the binding's authority (read directly by _can_claim_resource and by
157
+ -- evaluate_fleet_sender_policy) so this is the complete access removal — see the header note
158
+ -- on why no fleet_revocation_events row is written per binding. revoked_at is set to satisfy
159
+ -- fleet_machine_agents_revoked_shape. Defensive (#150 codex): exclude the NEW owner, so an
160
+ -- operator reclaiming a machine on which the new owner already holds a binding does not revoke
161
+ -- the new owner's own access as a side effect.
162
+ with revoked as (
163
+ update agent_board.fleet_machine_agents fma
164
+ set lifecycle_state = 'revoked',
165
+ revoked_at = now(),
166
+ updated_at = now()
167
+ where fma.machine_id = p_machine_id
168
+ and fma.lifecycle_state = 'active'
169
+ and fma.agent_id <> p_new_owner_agent_id
170
+ returning fma.agent_id
171
+ )
172
+ select count(*)::integer, coalesce(array_agg(agent_id), '{}'::uuid[])
173
+ into v_revoked_count, v_revoked_agents
174
+ from revoked;
175
+
176
+ -- (2b) MUST-FIX 2 (#150 codex): release the squatter's ACTIVE machine claim(s) on the reclaimed
177
+ -- fingerprint. Machine claims key on the bare globally-unique fingerprint (claimgate/CLAIMKEY),
178
+ -- and reclaim reassigns ownership + revokes bindings but does NOT touch agent_board.claims — so a
179
+ -- squatter holding an active claim keeps the lock and the new owner's acquire_claim raises 23505
180
+ -- until lease expiry. Releasing it (status='released') frees the active-claim uniqueness slot so
181
+ -- the new owner can acquire immediately. Alias-aware match on the canonical key (same helper
182
+ -- acquire_claim's conflict check uses). The new owner's own claim (if any) is preserved.
183
+ with released as (
184
+ update agent_board.claims c
185
+ set status = 'released',
186
+ released_at = now(),
187
+ updated_at = now(),
188
+ metadata = coalesce(c.metadata, '{}'::jsonb)
189
+ || jsonb_build_object('released_by_reclaim', p_reclaimed_by_agent_id, 'released_at', now())
190
+ where c.resource_kind = 'machine'
191
+ and c.status = 'active'
192
+ and c.released_at is null
193
+ and c.agent_id <> p_new_owner_agent_id
194
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key)
195
+ = agent_board._canonical_claim_key('machine', v_machine.machine_fingerprint)
196
+ returning c.id
197
+ )
198
+ select count(*)::integer into v_released_claims from released;
199
+
200
+ -- (1)+(3) Reassign ownership, optionally MOVE the trust domain (kill-switch close), and leave the
201
+ -- machine active for the new owner.
202
+ -- owner_agent_id CHANGE: reclaim_fleet_machine is the ONLY writer besides register_fleet_machine
203
+ -- (INSERT) that assigns owner_agent_id. It is operator-only and is the arbitration escape hatch
204
+ -- for a squatted fingerprint that #141 otherwise locks permanently.
205
+ -- trust_domain_id: moved to p_new_trust_domain_id when provided (validated above); coalesce keeps
206
+ -- the current domain when NULL (documented retained kill switch — see header).
207
+ -- reactivated_at=now() supersedes any prior machine-revocation event so a squatted-then-revoked
208
+ -- machine is usable by the new owner (same mechanism as reactivate_fleet_machine).
209
+ -- AUDIT (#150 panel SHOULD-FIX): APPEND a record to metadata.reclaim_history rather than
210
+ -- overwriting flat keys, so repeated reclaims do not clobber prior audit.
211
+ update agent_board.fleet_machines fm
212
+ set owner_agent_id = p_new_owner_agent_id,
213
+ trust_domain_id = coalesce(p_new_trust_domain_id, fm.trust_domain_id),
214
+ lifecycle_state = 'active',
215
+ revoked_at = null,
216
+ reactivated_at = now(),
217
+ metadata = coalesce(fm.metadata, '{}'::jsonb)
218
+ || jsonb_build_object(
219
+ 'reclaim_history',
220
+ coalesce(fm.metadata -> 'reclaim_history', '[]'::jsonb)
221
+ || jsonb_build_array(jsonb_build_object(
222
+ 'reclaimed_by', p_reclaimed_by_agent_id,
223
+ 'reclaim_reason', nullif(trim(coalesce(p_reason, '')), ''),
224
+ 'prior_owner', v_prior_owner,
225
+ 'prior_trust_domain_id', v_prior_domain,
226
+ 'new_trust_domain_id', coalesce(p_new_trust_domain_id, v_prior_domain),
227
+ 'domain_moved', p_new_trust_domain_id is not null,
228
+ 'reclaimed_at', now(),
229
+ 'revoked_bindings', to_jsonb(v_revoked_agents),
230
+ 'released_claims', v_released_claims
231
+ ))
232
+ )
233
+ || jsonb_build_object('redacted', true, 'metadata-safe', true),
234
+ updated_at = now()
235
+ where fm.id = p_machine_id
236
+ returning * into v_machine;
237
+
238
+ return query select v_machine.id, v_machine.owner_agent_id, v_machine.trust_domain_id, v_machine.lifecycle_state, v_revoked_count;
239
+ end;
240
+ $$;
241
+
242
+ -- #118 discipline: a SECURITY DEFINER function grants EXECUTE to PUBLIC on create. Restate the
243
+ -- revokes/grant and the comment. Operator-only ⇒ granted to service_role ALONE (not authenticated).
244
+ revoke all on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) from public, anon, authenticated;
245
+ grant execute on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) to service_role;
246
+
247
+ comment on function agent_board.reclaim_fleet_machine(uuid, uuid, uuid, text, uuid) is
248
+ 'Operator-only (#146): reclaims a squatted fleet machine (a globally-unique fingerprint pre-registered by someone other than its rightful owner, which #141 otherwise locks permanently). Reassigns owner_agent_id to the arbitrated new owner (the only writer besides register_fleet_machine that changes ownership), revokes every active fleet_machine_agents binding on THIS machine so the squatter loses access (machine-scoped, NOT a global revoke_fleet_subject(''agent'')), and leaves the machine active for the new owner (stamps reactivated_at to supersede any prior machine-revocation event). Optional p_new_trust_domain_id (#150 kill-switch close): when given, validated (active + owned by the new owner) and the machine is MOVED into it so the squatter''s domain-revocation can no longer deny it; when NULL the machine stays in its current domain and the squatter RETAINS a trust-domain kill switch (documented residual; whole-domain squatting arbitration is a separate follow-up). service_role only (raises 42501 otherwise); never revokes the trust domain or the machine subject. Also releases the squatter''s active machine claim(s) on the reclaimed fingerprint so the new owner can acquire immediately (#150). Reclaim audit is APPENDED to fleet_machines.metadata.reclaim_history (repeated reclaims do not clobber prior records).';
249
+
250
+ -- ============================================================================================
251
+ -- #150 MUST-FIX 1 (codex): revoke_fleet_subject TOCTOU vs reclaim_fleet_machine.
252
+ -- Reproduced VERBATIM from 20260826000100 EXCEPT the added row-lock block below. The machine,
253
+ -- harness and repo_scope authz branches derive ownership from fleet_machines.owner_agent_id and
254
+ -- previously read it WITHOUT a lock, so a squatter's revoke('machine', M) could pass its ownership
255
+ -- check on the pre-reclaim snapshot and have its lifecycle UPDATE apply AFTER a concurrent reclaim
256
+ -- reassigned the machine — nuking the machine the operator just handed to the new owner. We now
257
+ -- LOCK the owning fleet_machines row FIRST (before the authz re-read and before the event write,
258
+ -- matching reclaim's lock-first order → the two serialize deadlock-free on the same row). #118
259
+ -- grants/comment restated.
260
+ -- ============================================================================================
261
+ create or replace function agent_board.revoke_fleet_subject(
262
+ p_subject_kind text,
263
+ p_subject_id uuid,
264
+ p_revoked_by_agent_id uuid,
265
+ p_revocation_reason text,
266
+ p_metadata jsonb default '{}'::jsonb
267
+ )
268
+ returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
269
+ language plpgsql
270
+ security definer
271
+ set search_path = agent_board, public
272
+ as $$
273
+ declare
274
+ v_event agent_board.fleet_revocation_events%rowtype;
275
+ v_actor_agent_id uuid;
276
+ begin
277
+ if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
278
+ raise exception 'unsupported fleet revocation subject' using errcode = '22023';
279
+ end if;
280
+
281
+ -- #150 MUST-FIX 1: lock the owning fleet_machines row for machine-derived kinds BEFORE the
282
+ -- unlocked ownership check below, so a concurrent reclaim_fleet_machine (which holds `for update`
283
+ -- on that row) serializes this call and the authz re-read sees committed ownership. Placed ahead
284
+ -- of the event write; reclaim also locks this row first, so acquisition order matches (no cycle).
285
+ if p_subject_kind in ('machine', 'harness', 'repo_scope') then
286
+ declare
287
+ v_lock_machine_id uuid;
288
+ begin
289
+ v_lock_machine_id := case
290
+ when p_subject_kind = 'machine' then p_subject_id
291
+ when p_subject_kind = 'harness' then (
292
+ select fhr.machine_id from agent_board.fleet_harness_registrations fhr where fhr.id = p_subject_id)
293
+ when p_subject_kind = 'repo_scope' then (
294
+ select fhr.machine_id
295
+ from agent_board.fleet_repo_scopes frs
296
+ join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
297
+ where frs.id = p_subject_id)
298
+ end;
299
+ if v_lock_machine_id is not null then
300
+ perform 1 from agent_board.fleet_machines fm where fm.id = v_lock_machine_id for update;
301
+ end if;
302
+ end;
303
+ end if;
304
+
305
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
306
+
307
+ if not agent_board._is_service_role() then
308
+ if p_subject_kind = 'trust_domain' and not exists (
309
+ select 1 from agent_board.fleet_trust_domains ftd
310
+ where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
311
+ ) then
312
+ raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
313
+ elsif p_subject_kind = 'machine' and not exists (
314
+ select 1 from agent_board.fleet_machines fm
315
+ where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
316
+ ) then
317
+ raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
318
+ elsif p_subject_kind = 'harness' and not exists (
319
+ select 1
320
+ from agent_board.fleet_harness_registrations fhr
321
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
322
+ where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
323
+ ) then
324
+ raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
325
+ elsif p_subject_kind = 'repo_scope' and not exists (
326
+ select 1
327
+ from agent_board.fleet_repo_scopes frs
328
+ join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
329
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
330
+ where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
331
+ ) then
332
+ raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
333
+ elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
334
+ raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
335
+ elsif p_subject_kind = 'signing_key' and not exists (
336
+ select 1 from agent_board.agent_signing_keys ask
337
+ where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
338
+ ) then
339
+ raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
340
+ elsif p_subject_kind = 'credential' and not exists (
341
+ select 1 from agent_board.agent_credentials ac
342
+ where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
343
+ ) then
344
+ raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
345
+ end if;
346
+ end if;
347
+
348
+ select *
349
+ into v_event
350
+ from agent_board.fleet_revocation_events fre
351
+ where fre.subject_kind = p_subject_kind
352
+ and fre.subject_id = p_subject_id
353
+ limit 1;
354
+
355
+ if v_event.id is null then
356
+ insert into agent_board.fleet_revocation_events(
357
+ subject_kind,
358
+ subject_id,
359
+ trust_domain_id,
360
+ machine_id,
361
+ harness_registration_id,
362
+ repo_scope_id,
363
+ agent_id,
364
+ signing_key_id,
365
+ revoked_by_agent_id,
366
+ revocation_reason,
367
+ metadata
368
+ )
369
+ values (
370
+ p_subject_kind,
371
+ p_subject_id,
372
+ case when p_subject_kind = 'trust_domain' then p_subject_id end,
373
+ case when p_subject_kind = 'machine' then p_subject_id end,
374
+ case when p_subject_kind = 'harness' then p_subject_id end,
375
+ case when p_subject_kind = 'repo_scope' then p_subject_id end,
376
+ case when p_subject_kind = 'agent' then p_subject_id end,
377
+ case when p_subject_kind = 'signing_key' then p_subject_id end,
378
+ p_revoked_by_agent_id,
379
+ trim(coalesce(p_revocation_reason, 'operator revocation')),
380
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
381
+ )
382
+ returning * into v_event;
383
+ else
384
+ update agent_board.fleet_revocation_events fre
385
+ set effective_at = now(),
386
+ revoked_by_agent_id = p_revoked_by_agent_id,
387
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
388
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
389
+ where fre.id = v_event.id
390
+ returning * into v_event;
391
+ end if;
392
+
393
+ if p_subject_kind = 'trust_domain' then
394
+ update agent_board.fleet_trust_domains
395
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
396
+ where id = p_subject_id;
397
+ elsif p_subject_kind = 'machine' then
398
+ update agent_board.fleet_machines
399
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
400
+ where id = p_subject_id;
401
+ elsif p_subject_kind = 'harness' then
402
+ update agent_board.fleet_harness_registrations
403
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
404
+ where id = p_subject_id;
405
+ elsif p_subject_kind = 'repo_scope' then
406
+ update agent_board.fleet_repo_scopes
407
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
408
+ where id = p_subject_id;
409
+ elsif p_subject_kind = 'agent' then
410
+ update agent_board.fleet_machine_agents
411
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
412
+ where agent_id = p_subject_id;
413
+ elsif p_subject_kind = 'signing_key' then
414
+ update agent_board.agent_signing_keys
415
+ set status = case when status = 'compromised' then status else 'revoked' end,
416
+ proof_state = 'revoked',
417
+ revoked_at = coalesce(revoked_at, v_event.effective_at),
418
+ revoked_by_agent_id = p_revoked_by_agent_id,
419
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
420
+ updated_at = now()
421
+ where id = p_subject_id;
422
+ end if;
423
+
424
+ return query select v_event.id, v_event.subject_kind, v_event.effective_at;
425
+ end;
426
+ $$;
427
+
428
+ revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
429
+ grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
430
+
431
+ comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
432
+ 'Records a fleet revocation into the append-only fleet_revocation_events ledger (one row per subject) and flips the subject''s lifecycle to revoked. Re-revoking a subject advances the event''s effective_at to now() (#146). For machine-derived subject kinds (machine/harness/repo_scope) it locks the owning fleet_machines row before the ownership check (#150), so a concurrent reclaim_fleet_machine serializes and a stale-owner revoke is refused (42501) rather than nuking a just-reclaimed machine.';
@@ -0,0 +1,41 @@
1
+ -- =============================================================================
2
+ -- DELEGSOLEWRITER (#152) — make upsert_delegation_policy the SOLE writer of
3
+ -- agent_board.delegation_policies by revoking service_role's direct INSERT/UPDATE.
4
+ -- =============================================================================
5
+ -- Defense-in-depth follow-up to #142 (20260828000100, operator caps are a durable
6
+ -- ceiling). #142 stores the operator's hard ceiling in operator_max_active_children /
7
+ -- operator_child_ttl_ceiling, which the OPERATOR branch of upsert_delegation_policy
8
+ -- ESTABLISHES and the self-service branch PRESERVES; mint_delegated_child then clamps
9
+ -- the self-service effective cap to least(self, operator ceiling, 25/24h). That closes
10
+ -- the LOCKED threat model (an authenticated root has SELECT-only on the table, so it
11
+ -- cannot loosen the ceiling through the function).
12
+ --
13
+ -- THE RESIDUAL DOOR (operator misuse, not a self-service escalation): service_role still
14
+ -- held a DIRECT insert/update grant (20260717000400:42). An operator using raw SQL could
15
+ -- set delegation_policies.max_active_children / .child_ttl_ceiling WITHOUT populating the
16
+ -- operator_* ceiling columns; a later self-service upsert then PRESERVES the NULL ceiling
17
+ -- while the mint reads NULL as "no operator ceiling" and the root widens back to 25/24h.
18
+ -- The operator's intent is not durable when it bypasses the function.
19
+ --
20
+ -- WHY THE REVOKE IS SAFE (verified against pg_catalog on the live schema):
21
+ -- * upsert_delegation_policy is the ONLY function that writes the table
22
+ -- (INSERT/UPDATE), and it is SECURITY DEFINER OWNED BY the table owner (postgres) —
23
+ -- it runs with the owner's implicit privileges, NOT service_role's grant. Revoking
24
+ -- the grant does not touch it. (renew_delegated_grant and mint_delegated_child only
25
+ -- reference the table in SELECT/lock and write delegation_grants, not this table.)
26
+ -- * The mint-child Edge Function and every supabase/functions/* path use RPCs only —
27
+ -- no raw table write to delegation_policies.
28
+ -- * The pgTAP suite connects as postgres, the table OWNER, so its direct-insert test
29
+ -- seeds keep working via ownership (owner privileges are implicit and unaffected by a
30
+ -- grant revoke); no test seed needed migration to the function.
31
+ --
32
+ -- After this, an operator setting caps MUST go through upsert_delegation_policy, whose
33
+ -- operator branch synchronizes the operator_* ceiling — so the #142 durable ceiling can
34
+ -- never be left NULL by a legitimate operator write. "The writer is THE writer, enforced
35
+ -- not conventional" (CLAUDE.md rule #1).
36
+ --
37
+ -- SELECT is deliberately KEPT (readers/operators still read the table). Additive,
38
+ -- forward-only ACL change; schema_version stays 8.
39
+ -- =============================================================================
40
+
41
+ revoke insert, update on agent_board.delegation_policies from service_role;