@consiliency/agent-board-schema 1.10.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,514 @@
1
+ -- #146 operator machine reactivation — supersede-based un-revoke.
2
+ --
3
+ -- #141 (PR #145) correctly made machine revocation TERMINAL via re-register: a
4
+ -- re-enrollment no longer clears lifecycle_state/revoked_at, because revocation
5
+ -- authority lives in the append-only agent_board.fleet_revocation_events ledger that
6
+ -- evaluate_fleet_sender_policy reads. The side effect (issue #146) is that there was
7
+ -- NO authorized path to bring a legitimately-revoked machine back: setting
8
+ -- lifecycle_state='active' alone does not restore authority, because the machine's
9
+ -- revocation EVENT still exists and the policy denies on its mere presence.
10
+ --
11
+ -- Reactivation therefore has to SUPERSEDE the prior revocation event(s) for the machine
12
+ -- rather than delete them (the ledger stays append-only). We stamp a supersession point
13
+ -- on the machine row (reactivated_at) and teach the policy's MACHINE-revocation clause to
14
+ -- ignore machine events that are at-or-before that point. A LATER revocation event
15
+ -- (effective_at > reactivated_at) still denies, so a re-revocation after reactivation is
16
+ -- honored; and reactivation supersedes ONLY the machine subject — a revoked trust_domain
17
+ -- or a revoked sender agent stays denied (those clauses are unchanged).
18
+ --
19
+ -- MAINTAINER DECISION (locked): reactivation is OPERATOR-ONLY (service_role). An owner
20
+ -- cannot self-reactivate — a revocation may be a deliberate security response, so undoing
21
+ -- it is an operator action. reactivate_fleet_machine raises 42501 for any non-service_role
22
+ -- caller (and is granted only to service_role — #118 discipline).
23
+ --
24
+ -- SCOPE: machine reactivation only. The fingerprint-SQUATTING reclaim (a never-owned
25
+ -- fingerprint pre-registered by a squatter → ownership arbitration) is a DIFFERENT
26
+ -- mechanism and remains open #146 work — NOT built here.
27
+
28
+ -- (1) Supersession marker. Nullable: null means "never reactivated" (all prior machine
29
+ -- events apply). Set to now() on each reactivation.
30
+ alter table agent_board.fleet_machines
31
+ add column if not exists reactivated_at timestamptz;
32
+
33
+ comment on column agent_board.fleet_machines.reactivated_at is
34
+ 'Supersession point (#146): machine-revocation events with effective_at <= this are ignored by evaluate_fleet_sender_policy. Set by reactivate_fleet_machine; null means never reactivated.';
35
+
36
+ -- (2) Operator-only reactivation RPC. Reconciles the machine row (active, revoked_at=null,
37
+ -- reactivated_at=now()) so the supersession clause in the policy lets it send again. Does
38
+ -- NOT write to the append-only ledger — supersession is by timestamp, not by a compensating
39
+ -- event (there is no 'reactivation' subject_kind).
40
+ create or replace function agent_board.reactivate_fleet_machine(
41
+ p_machine_id uuid,
42
+ p_reactivated_by_agent_id uuid default null,
43
+ p_reactivation_reason text default null
44
+ )
45
+ returns table(machine_id uuid, lifecycle_state text, reactivated_at timestamptz)
46
+ language plpgsql
47
+ security definer
48
+ set search_path = agent_board, public
49
+ as $$
50
+ declare
51
+ v_machine agent_board.fleet_machines%rowtype;
52
+ v_is_revoked boolean;
53
+ begin
54
+ -- Operator-only: an owner cannot self-reactivate (revocation may be a security response).
55
+ if not agent_board._is_service_role() then
56
+ raise exception 'reactivate_fleet_machine is operator-only (service_role)' using errcode = '42501';
57
+ end if;
58
+
59
+ -- Provenance is required and must name a real agent (audit lands in metadata).
60
+ if p_reactivated_by_agent_id is null then
61
+ raise exception 'p_reactivated_by_agent_id is required' using errcode = '22023';
62
+ end if;
63
+ if not exists (
64
+ select 1 from agent_board.agents a where a.id = p_reactivated_by_agent_id
65
+ ) then
66
+ raise exception 'p_reactivated_by_agent_id is not a known agent' using errcode = '22023';
67
+ end if;
68
+
69
+ -- MUST-FIX 3 (#146 panel, concurrency): lock the machine row so this whole
70
+ -- read-decide-write serializes against a concurrent revoke_fleet_subject (whose
71
+ -- fleet_machines UPDATE also takes a row lock). Without the lock the still-revoked
72
+ -- re-check below could act on a stale snapshot and the unconditional UPDATE could
73
+ -- silently overwrite a revoke that committed mid-operation. With it, a revoke and a
74
+ -- reactivate on the same machine serialize deterministically (last-writer-wins is
75
+ -- coherent) and a revoke cannot be dropped.
76
+ select * into v_machine from agent_board.fleet_machines fm where fm.id = p_machine_id for update;
77
+ if v_machine.id is null then
78
+ raise exception 'fleet machine not found' using errcode = '22023';
79
+ end if;
80
+
81
+ -- Must currently be revoked (re-checked UNDER the row lock): lifecycle_state='revoked'
82
+ -- OR a machine-revocation event newer than the last supersession point. Otherwise there
83
+ -- is nothing to reactivate.
84
+ v_is_revoked := v_machine.lifecycle_state = 'revoked'
85
+ or exists (
86
+ select 1
87
+ from agent_board.fleet_revocation_events fre
88
+ where fre.subject_kind = 'machine'
89
+ and fre.subject_id = v_machine.id
90
+ and fre.effective_at > coalesce(v_machine.reactivated_at, '-infinity'::timestamptz)
91
+ );
92
+
93
+ if not v_is_revoked then
94
+ raise exception 'fleet machine is not revoked; nothing to reactivate' using errcode = '22023';
95
+ end if;
96
+
97
+ update agent_board.fleet_machines fm
98
+ set lifecycle_state = 'active',
99
+ revoked_at = null,
100
+ reactivated_at = now(),
101
+ metadata = coalesce(fm.metadata, '{}'::jsonb)
102
+ || jsonb_build_object(
103
+ 'reactivated_by', p_reactivated_by_agent_id,
104
+ 'reactivation_reason', nullif(trim(coalesce(p_reactivation_reason, '')), ''),
105
+ 'reactivated_at', now()
106
+ )
107
+ || jsonb_build_object('redacted', true, 'metadata-safe', true),
108
+ updated_at = now()
109
+ where fm.id = p_machine_id
110
+ returning * into v_machine;
111
+
112
+ return query select v_machine.id, v_machine.lifecycle_state, v_machine.reactivated_at;
113
+ end;
114
+ $$;
115
+
116
+ -- (3) Policy: reproduce evaluate_fleet_sender_policy VERBATIM except the MACHINE clause of
117
+ -- the revocation existence check, which now honors reactivated_at. The trust_domain and
118
+ -- agent clauses are UNCHANGED — reactivating a machine must NOT un-revoke its domain or the
119
+ -- sender agent. The lifecycle_state='revoked' guard is kept (reactivate sets it active).
120
+ create or replace function agent_board.evaluate_fleet_sender_policy(
121
+ p_sender_agent_id uuid,
122
+ p_recipient_agent_id uuid,
123
+ p_sender_machine_id uuid default null,
124
+ p_recipient_board_id uuid default null,
125
+ p_operation text default 'send_direct_message',
126
+ p_signature_evidence_id uuid default null,
127
+ p_direct_rpc_compatibility boolean default false
128
+ )
129
+ returns table(
130
+ allowed boolean,
131
+ actionable boolean,
132
+ denial_class text,
133
+ receive_policy_id uuid,
134
+ trust_domain_id uuid,
135
+ trust_set_id uuid,
136
+ requires_signature boolean,
137
+ evaluated_at timestamptz
138
+ )
139
+ language plpgsql
140
+ security definer
141
+ set search_path = agent_board, public
142
+ as $$
143
+ declare
144
+ v_machine agent_board.fleet_machines%rowtype;
145
+ v_binding agent_board.fleet_machine_agents%rowtype;
146
+ v_policy agent_board.fleet_receive_policies%rowtype;
147
+ v_trust_set agent_board.fleet_trust_sets%rowtype;
148
+ v_signature agent_board.signature_evidence%rowtype;
149
+ v_actor_agent_id uuid;
150
+ v_action text;
151
+ v_denial text;
152
+ begin
153
+ if not agent_board._is_service_role() then
154
+ v_actor_agent_id := agent_board.current_agent_id();
155
+ if v_actor_agent_id is null
156
+ or (v_actor_agent_id <> p_sender_agent_id and v_actor_agent_id <> p_recipient_agent_id) then
157
+ raise exception 'fleet policy evaluation must involve current_agent_id()' using errcode = '42501';
158
+ end if;
159
+ end if;
160
+
161
+ select fma.*
162
+ into v_binding
163
+ from agent_board.fleet_machine_agents fma
164
+ join agent_board.fleet_machines fm on fm.id = fma.machine_id
165
+ where fma.agent_id = p_sender_agent_id
166
+ and fma.lifecycle_state = 'active'
167
+ and fm.lifecycle_state = 'active'
168
+ and (p_sender_machine_id is null or fma.machine_id = p_sender_machine_id)
169
+ order by fma.updated_at desc
170
+ limit 1;
171
+
172
+ if v_binding.id is not null then
173
+ select * into v_machine from agent_board.fleet_machines where id = v_binding.machine_id;
174
+ elsif p_sender_machine_id is not null then
175
+ select * into v_machine from agent_board.fleet_machines where id = p_sender_machine_id;
176
+ end if;
177
+
178
+ if v_machine.id is null then
179
+ return query select false, false, 'domain_denied'::text, null::uuid, null::uuid, null::uuid, false, now();
180
+ return;
181
+ end if;
182
+
183
+ if v_machine.lifecycle_state = 'revoked'
184
+ or exists (
185
+ select 1 from agent_board.fleet_revocation_events fre
186
+ where (fre.subject_kind = 'machine' and fre.subject_id = v_machine.id
187
+ and fre.effective_at > coalesce(v_machine.reactivated_at, '-infinity'::timestamptz))
188
+ or (fre.subject_kind = 'trust_domain' and fre.subject_id = v_machine.trust_domain_id)
189
+ or (fre.subject_kind = 'agent' and fre.subject_id = p_sender_agent_id)
190
+ ) then
191
+ return query select false, false, 'machine_revoked'::text, null::uuid, v_machine.trust_domain_id, null::uuid, false, now();
192
+ return;
193
+ end if;
194
+
195
+ select frp.*
196
+ into v_policy
197
+ from agent_board.fleet_receive_policies frp
198
+ where frp.lifecycle_state = 'active'
199
+ and (frp.recipient_agent_id is null or frp.recipient_agent_id = p_recipient_agent_id)
200
+ and (frp.board_id is null or frp.board_id = p_recipient_board_id)
201
+ and (frp.trust_domain_id is null or frp.trust_domain_id = v_machine.trust_domain_id)
202
+ order by
203
+ case when frp.board_id is not null then 0 else 1 end,
204
+ case when frp.trust_domain_id is not null then 0 else 1 end,
205
+ frp.updated_at desc
206
+ limit 1;
207
+
208
+ if v_policy.id is null then
209
+ return query select false, false, 'recipient_policy_denied'::text, null::uuid, v_machine.trust_domain_id, null::uuid, false, now();
210
+ return;
211
+ end if;
212
+
213
+ if v_policy.require_signed_sender then
214
+ if p_signature_evidence_id is null then
215
+ return query select false, false, 'signature_required'::text, v_policy.id, v_machine.trust_domain_id, null::uuid, true, now();
216
+ return;
217
+ end if;
218
+
219
+ select * into v_signature from agent_board.signature_evidence where id = p_signature_evidence_id;
220
+ if v_signature.id is null or v_signature.agent_id <> p_sender_agent_id then
221
+ return query select false, false, 'signature_required'::text, v_policy.id, v_machine.trust_domain_id, null::uuid, true, now();
222
+ return;
223
+ end if;
224
+
225
+ if v_signature.failure_class = 'key_revoked'
226
+ or exists (
227
+ select 1
228
+ from agent_board.fleet_revocation_events fre
229
+ where fre.subject_kind = 'signing_key'
230
+ and fre.subject_id = v_signature.signing_key_id
231
+ )
232
+ or exists (
233
+ select 1
234
+ from agent_board.agent_signing_keys ask
235
+ where ask.id = v_signature.signing_key_id
236
+ and ask.status in ('revoked', 'compromised')
237
+ ) then
238
+ return query select false, false, 'key_revoked'::text, v_policy.id, v_machine.trust_domain_id, null::uuid, true, now();
239
+ return;
240
+ end if;
241
+
242
+ if v_signature.verification_status not in ('verified', 'warn', 'observed') then
243
+ return query select false, false, 'signature_required'::text, v_policy.id, v_machine.trust_domain_id, null::uuid, true, now();
244
+ return;
245
+ end if;
246
+ end if;
247
+
248
+ select fts.*
249
+ into v_trust_set
250
+ from agent_board.fleet_trust_sets fts
251
+ where fts.receive_policy_id = v_policy.id
252
+ and fts.lifecycle_state = 'active'
253
+ and (
254
+ fts.trust_domain_id = v_machine.trust_domain_id
255
+ or fts.machine_id = v_machine.id
256
+ or fts.agent_id = p_sender_agent_id
257
+ or (v_binding.repo_scope_id is not null and fts.repo_scope_id = v_binding.repo_scope_id)
258
+ or exists (
259
+ select 1
260
+ from agent_board.fleet_repo_scopes frs
261
+ where frs.id = v_binding.repo_scope_id
262
+ and fts.harness_registration_id = frs.harness_registration_id
263
+ )
264
+ )
265
+ order by
266
+ case fts.effect
267
+ when 'deny' then 0
268
+ when 'quarantine' then 1
269
+ when 'non_actionable' then 2
270
+ else 3
271
+ end,
272
+ fts.updated_at desc
273
+ limit 1;
274
+
275
+ v_action := coalesce(v_trust_set.effect, v_policy.default_action);
276
+ v_denial := case
277
+ when v_action = 'deny' and v_trust_set.id is not null then 'sender_denied'
278
+ when v_action = 'deny' then 'recipient_policy_denied'
279
+ when v_action = 'quarantine' then 'quarantined'
280
+ else null
281
+ end;
282
+
283
+ return query
284
+ select
285
+ v_action in ('allow', 'non_actionable'),
286
+ v_action = 'allow',
287
+ v_denial,
288
+ v_policy.id,
289
+ v_machine.trust_domain_id,
290
+ v_trust_set.id,
291
+ v_policy.require_signed_sender,
292
+ now();
293
+ end;
294
+ $$;
295
+
296
+ -- #118 discipline: recreating a SECURITY DEFINER function grants EXECUTE to PUBLIC by
297
+ -- default. Restate the revokes/grants and the comments in this same migration.
298
+ revoke all on function agent_board.reactivate_fleet_machine(uuid, uuid, text) from public, anon, authenticated;
299
+ grant execute on function agent_board.reactivate_fleet_machine(uuid, uuid, text) to service_role;
300
+
301
+ revoke all on function agent_board.evaluate_fleet_sender_policy(uuid, uuid, uuid, uuid, text, uuid, boolean) from public, anon;
302
+ grant execute on function agent_board.evaluate_fleet_sender_policy(uuid, uuid, uuid, uuid, text, uuid, boolean) to authenticated, service_role;
303
+
304
+ comment on function agent_board.reactivate_fleet_machine(uuid, uuid, text) is
305
+ 'Operator-only (#146): brings a revoked fleet machine back to active by stamping reactivated_at (supersession point) and clearing revoked_at, so evaluate_fleet_sender_policy ignores machine-revocation events at-or-before that point. service_role only (raises 42501 otherwise); never un-revokes the machine''s trust_domain or sender agent; does not write the append-only fleet_revocation_events ledger.';
306
+
307
+ comment on function agent_board.evaluate_fleet_sender_policy(uuid, uuid, uuid, uuid, text, uuid, boolean) is
308
+ 'Fleet sender-policy evaluation. Machine-revocation clause honors fleet_machines.reactivated_at (#146): machine events at-or-before the supersession point are ignored; trust_domain and agent revocation clauses are unaffected by machine reactivation.';
309
+
310
+ -- (4) MUST-FIX 1 (#146 panel): keep the append-only ledger AUTHORITATIVE across
311
+ -- re-revocation. revoke_fleet_subject keeps ONE event row per (subject_kind, subject_id)
312
+ -- and its UPDATE branch previously refreshed reason/metadata but NOT effective_at, so a
313
+ -- revoke AFTER a reactivation left effective_at BEFORE reactivated_at — the machine
314
+ -- supersession clause then read the (real) re-revocation as "not revoked", leaving only the
315
+ -- mutable lifecycle_state guard to deny. That inverts #141's principle (authority in the
316
+ -- ledger, not the row). Reproduced VERBATIM except the UPDATE branch now also sets
317
+ -- `effective_at = now()`, so a re-revocation advances the event past reactivated_at and both
318
+ -- the ledger-derived clause and the lifecycle guard deny (2-of-2 depth). #118 grants/comment
319
+ -- restated.
320
+ create or replace function agent_board.revoke_fleet_subject(
321
+ p_subject_kind text,
322
+ p_subject_id uuid,
323
+ p_revoked_by_agent_id uuid,
324
+ p_revocation_reason text,
325
+ p_metadata jsonb default '{}'::jsonb
326
+ )
327
+ returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
328
+ language plpgsql
329
+ security definer
330
+ set search_path = agent_board, public
331
+ as $$
332
+ declare
333
+ v_event agent_board.fleet_revocation_events%rowtype;
334
+ v_actor_agent_id uuid;
335
+ begin
336
+ if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
337
+ raise exception 'unsupported fleet revocation subject' using errcode = '22023';
338
+ end if;
339
+
340
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
341
+
342
+ if not agent_board._is_service_role() then
343
+ if p_subject_kind = 'trust_domain' and not exists (
344
+ select 1 from agent_board.fleet_trust_domains ftd
345
+ where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
346
+ ) then
347
+ raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
348
+ elsif p_subject_kind = 'machine' and not exists (
349
+ select 1 from agent_board.fleet_machines fm
350
+ where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
351
+ ) then
352
+ raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
353
+ elsif p_subject_kind = 'harness' and not exists (
354
+ select 1
355
+ from agent_board.fleet_harness_registrations fhr
356
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
357
+ where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
358
+ ) then
359
+ raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
360
+ elsif p_subject_kind = 'repo_scope' and not exists (
361
+ select 1
362
+ from agent_board.fleet_repo_scopes frs
363
+ join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
364
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
365
+ where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
366
+ ) then
367
+ raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
368
+ elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
369
+ raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
370
+ elsif p_subject_kind = 'signing_key' and not exists (
371
+ select 1 from agent_board.agent_signing_keys ask
372
+ where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
373
+ ) then
374
+ raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
375
+ elsif p_subject_kind = 'credential' and not exists (
376
+ select 1 from agent_board.agent_credentials ac
377
+ where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
378
+ ) then
379
+ raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
380
+ end if;
381
+ end if;
382
+
383
+ select *
384
+ into v_event
385
+ from agent_board.fleet_revocation_events fre
386
+ where fre.subject_kind = p_subject_kind
387
+ and fre.subject_id = p_subject_id
388
+ limit 1;
389
+
390
+ if v_event.id is null then
391
+ insert into agent_board.fleet_revocation_events(
392
+ subject_kind,
393
+ subject_id,
394
+ trust_domain_id,
395
+ machine_id,
396
+ harness_registration_id,
397
+ repo_scope_id,
398
+ agent_id,
399
+ signing_key_id,
400
+ revoked_by_agent_id,
401
+ revocation_reason,
402
+ metadata
403
+ )
404
+ values (
405
+ p_subject_kind,
406
+ p_subject_id,
407
+ case when p_subject_kind = 'trust_domain' then p_subject_id end,
408
+ case when p_subject_kind = 'machine' then p_subject_id end,
409
+ case when p_subject_kind = 'harness' then p_subject_id end,
410
+ case when p_subject_kind = 'repo_scope' then p_subject_id end,
411
+ case when p_subject_kind = 'agent' then p_subject_id end,
412
+ case when p_subject_kind = 'signing_key' then p_subject_id end,
413
+ p_revoked_by_agent_id,
414
+ trim(coalesce(p_revocation_reason, 'operator revocation')),
415
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
416
+ )
417
+ returning * into v_event;
418
+ else
419
+ update agent_board.fleet_revocation_events fre
420
+ set effective_at = now(),
421
+ revoked_by_agent_id = p_revoked_by_agent_id,
422
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
423
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
424
+ where fre.id = v_event.id
425
+ returning * into v_event;
426
+ end if;
427
+
428
+ if p_subject_kind = 'trust_domain' then
429
+ update agent_board.fleet_trust_domains
430
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
431
+ where id = p_subject_id;
432
+ elsif p_subject_kind = 'machine' then
433
+ update agent_board.fleet_machines
434
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
435
+ where id = p_subject_id;
436
+ elsif p_subject_kind = 'harness' then
437
+ update agent_board.fleet_harness_registrations
438
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
439
+ where id = p_subject_id;
440
+ elsif p_subject_kind = 'repo_scope' then
441
+ update agent_board.fleet_repo_scopes
442
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
443
+ where id = p_subject_id;
444
+ elsif p_subject_kind = 'agent' then
445
+ update agent_board.fleet_machine_agents
446
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
447
+ where agent_id = p_subject_id;
448
+ elsif p_subject_kind = 'signing_key' then
449
+ update agent_board.agent_signing_keys
450
+ set status = case when status = 'compromised' then status else 'revoked' end,
451
+ proof_state = 'revoked',
452
+ revoked_at = coalesce(revoked_at, v_event.effective_at),
453
+ revoked_by_agent_id = p_revoked_by_agent_id,
454
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
455
+ updated_at = now()
456
+ where id = p_subject_id;
457
+ end if;
458
+
459
+ return query select v_event.id, v_event.subject_kind, v_event.effective_at;
460
+ end;
461
+ $$;
462
+
463
+ revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
464
+ grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
465
+
466
+ comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
467
+ '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), so a revocation after a reactivation supersedes the reactivation''s reactivated_at and stays authoritative in the ledger.';
468
+
469
+ -- (5) MUST-FIX 2 (#146 panel): the read model must agree with the policy. v_fleet_machine_status
470
+ -- reported machine_revoked from bare event-existence, so a reactivated machine showed
471
+ -- machine_revoked=true forever while the policy let it send — contradictory state visible to
472
+ -- owners/operators. Apply the SAME supersession predicate the policy uses. Recreated with its
473
+ -- grants restated.
474
+ create or replace view agent_board.v_fleet_machine_status
475
+ with (security_invoker = true)
476
+ as
477
+ select
478
+ fm.id as machine_id,
479
+ fm.trust_domain_id,
480
+ ftd.slug as trust_domain_slug,
481
+ ftd.domain_kind,
482
+ fm.owner_agent_id,
483
+ fm.machine_label,
484
+ fm.machine_fingerprint,
485
+ fm.lifecycle_state as machine_lifecycle_state,
486
+ fm.enrollment_source,
487
+ fm.enrolled_at,
488
+ fm.revoked_at,
489
+ (
490
+ fm.lifecycle_state = 'revoked'
491
+ or exists (
492
+ select 1
493
+ from agent_board.fleet_revocation_events fre
494
+ where fre.subject_kind = 'machine'
495
+ and fre.subject_id = fm.id
496
+ and fre.effective_at > coalesce(fm.reactivated_at, '-infinity'::timestamptz)
497
+ )
498
+ ) as machine_revoked,
499
+ coalesce(active_agents.active_agent_count, 0)::bigint as active_agent_count,
500
+ active_agents.latest_agent_binding_updated_at
501
+ from agent_board.fleet_machines fm
502
+ join agent_board.fleet_trust_domains ftd on ftd.id = fm.trust_domain_id
503
+ left join lateral (
504
+ select
505
+ count(*) filter (where fma.lifecycle_state = 'active') as active_agent_count,
506
+ max(fma.updated_at) as latest_agent_binding_updated_at
507
+ from agent_board.fleet_machine_agents fma
508
+ where fma.machine_id = fm.id
509
+ ) active_agents on true;
510
+
511
+ grant select on agent_board.v_fleet_machine_status to authenticated, service_role;
512
+
513
+ comment on view agent_board.v_fleet_machine_status is
514
+ 'Machine status read model. machine_revoked honors fleet_machines.reactivated_at (#146): a machine reactivated after its revocation reports machine_revoked=false, agreeing with evaluate_fleet_sender_policy.';