@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,389 @@
1
+ -- #153 whole-domain squatting arbitration — reassign a squatted fleet_trust_domain's owner.
2
+ --
3
+ -- #150 (20260829000100) closed the MACHINE-level kill switch: reclaim_fleet_machine can MOVE a
4
+ -- reclaimed machine into a domain the new owner controls, so a squatter who still owns the machine's
5
+ -- ORIGINAL domain can no longer revoke_fleet_subject('trust_domain') to deny it. Two residuals #150
6
+ -- explicitly left open (see its header + issue #153):
7
+ -- (1) the arbitrated new owner may have NO domain to move a reclaimed machine into; and
8
+ -- (2) a squatter can squat a WHOLE domain — there was no operator arbitration path to reassign a
9
+ -- contested domain's OWNER (revoke_fleet_subject('trust_domain') revokes but does not TRANSFER).
10
+ -- This migration adds the domain-level arbitration path for (2), and documents the operator path
11
+ -- for (1) (see the "DOMAINLESS NEW OWNER" note below).
12
+ --
13
+ -- AUTHORITY MODEL (verified against evaluate_fleet_sender_policy / register_fleet_machine /
14
+ -- set_fleet_receive_policy / revoke_fleet_subject). Domain authority is conferred ENTIRELY by
15
+ -- fleet_trust_domains.owner_agent_id — every authz site reads it LIVE:
16
+ -- * revoke_fleet_subject('trust_domain', D): authenticated path requires ftd.owner_agent_id =
17
+ -- current_agent_id() (the squatter's kill switch).
18
+ -- * register_fleet_machine into D: requires ftd.owner_agent_id = actor.
19
+ -- * set_fleet_receive_policy scoped to D: requires ftd.owner_agent_id = actor.
20
+ -- So REASSIGNING owner_agent_id is the exact-and-complete revocation of the squatter's domain
21
+ -- authority: the instant it commits, the squatter fails every one of those checks and the new owner
22
+ -- passes them. There is deliberately NO fleet_revocation_events row written here — mirroring #150's
23
+ -- machine-reclaim divergence rationale: a subject_kind='trust_domain' ledger row OVER-denies (the
24
+ -- policy's trust_domain clause denies EVERY machine in the domain, including the NEW owner's), which
25
+ -- is the opposite of handing the domain over live. The reassignment IS the revocation; the audit
26
+ -- (who/why/prior owner/prior lifecycle) is APPENDED to fleet_trust_domains.metadata.reclaim_history
27
+ -- (same append-not-overwrite shape as #150, so repeated arbitrations do not clobber prior records).
28
+ --
29
+ -- SERIALIZATION CAVEAT (#159 — the claim is NARROWED here, do not overstate it). The ownership
30
+ -- reassignment is atomic AT THE DOMAIN ROW, and reclaim locks that row `for update`. ONE
31
+ -- domain-owner-gated writer is serialized against that lock: revoke_fleet_subject('trust_domain')
32
+ -- takes the same domain-row lock before its authz check (below), so a squatter cannot pass a
33
+ -- stale-owner gate and revoke the just-reclaimed domain. The OTHER two domain-owner-gated writers —
34
+ -- register_fleet_machine and set_fleet_receive_policy — are NOT yet serialized against reclaim: they
35
+ -- authorize on ftd.owner_agent_id without locking the domain row, so a squatter racing an IN-FLIGHT
36
+ -- reclaim can read the pre-commit owner, pass the gate, and land a squatter-owned machine or a
37
+ -- squatter-authored receive policy that SURVIVES the handoff and needs operator cleanup. This is a
38
+ -- narrow, operator-recoverable race, NOT the atomic-transfer-of-all-powers this migration must NOT
39
+ -- claim. Serializing that whole class safely requires re-ordering the merged reclaim_fleet_machine
40
+ -- (its machine-first FOR UPDATE + the implicit FK FOR KEY SHARE on the destination domain from its
41
+ -- trust_domain_id UPDATE make a naive domain-lock on register a machine→domain vs domain→machine
42
+ -- DEADLOCK) and is tracked holistically in #159. Post-commit, all authz reads see the new owner.
43
+ --
44
+ -- OPERATOR-ONLY (arbitration, not self-service). Deciding the rightful owner of a contested domain
45
+ -- requires out-of-band verification only an OPERATOR can perform, so reclaim_fleet_trust_domain is
46
+ -- service_role-only and raises 42501 for any other caller — there is deliberately no owner- or
47
+ -- victim-facing self-reclaim (that would re-create the squatting race in the other direction).
48
+ --
49
+ -- SCOPE / non-goals (matches issue #153's domain-vs-machine split):
50
+ -- * This reassigns the DOMAIN's owner ONLY. It does NOT touch the owner_agent_id of the
51
+ -- constituent fleet_machines — those keep owner=squatter and remain the machine-level
52
+ -- reclaim_fleet_machine (#150) path. Taking a domain does not silently seize every machine in it;
53
+ -- an operator who also wants the machines reclaims them individually. (A squatter left owning
54
+ -- machines in a domain they no longer own can still revoke_fleet_subject('machine', M) on those
55
+ -- machines — a machine-scoped lever, not the domain kill switch this closes.)
56
+ -- * REVOKED-DOMAIN RESIDUAL — a domain the squatter already revoked STAYS revoked after reclaim.
57
+ -- THIS IS INTENTIONAL, NOT AN OVERSIGHT — do not "fix" it by adding a revive here. If a squatter
58
+ -- had ALREADY fired revoke_fleet_subject('trust_domain', D) BEFORE arbitration, D is lifecycle
59
+ -- 'revoked' with a denying fleet_revocation_events row, and the policy's trust_domain clause is a
60
+ -- pure EXISTENCE check with no supersession point (unlike the machine clause's effective_at >
61
+ -- reactivated_at). reclaim reassigns owner REGARDLESS of lifecycle (it does NOT raise on a
62
+ -- revoked domain) and records the prior lifecycle in the audit, but it does NOT revive a
63
+ -- pre-revoked domain. Three reasons this is the correct boundary, not a gap:
64
+ -- (a) SEPARATE OPERATOR ACT. For machines, reactivation (#149, which built the reactivated_at
65
+ -- supersession mechanism as its own phase) and reclaim (#150) are DISTINCT acts — #150 only
66
+ -- bundles revival because #149 shipped the mechanism first. Domains have no such mechanism;
67
+ -- domain-reactivation is the #149-parallel follow-up, tracked separately, NOT this build.
68
+ -- (b) LEDGER AUTHORITY (#141/#149 lesson). The only in-scope ways to revive would be to mutate/
69
+ -- delete the trust_domain revocation event (contradicts append-only ledger authority) or to
70
+ -- add a supersession clause to evaluate_fleet_sender_policy (a change to a load-bearing
71
+ -- security fn). Both are out of scope for an ownership-arbitration build.
72
+ -- (c) SECURITY. Ownership transfer must NOT silently un-revoke a domain that may have been
73
+ -- revoked FOR CAUSE — reviving is a deliberate second decision, not a side effect of who
74
+ -- owns it. The transfer still removes the squatter's ability to fire OR re-fire the kill
75
+ -- switch going forward.
76
+ -- (agent_board_fleet_domain_reclaim.test.sql pins this boundary positively: reclaim of a revoked
77
+ -- domain succeeds + transfers owner + the
78
+ -- domain stays revoked — a known/tested edge, not a silent gap.)
79
+ --
80
+ -- DOMAINLESS NEW OWNER (issue #153 residual 1). No create_fleet_trust_domain RPC exists — trust
81
+ -- domains are seeded by direct table INSERT, and service_role already holds INSERT on
82
+ -- fleet_trust_domains (20260628000004 grant). So the operator path for "new owner has no domain to
83
+ -- move a reclaimed machine into" is: (a) service_role INSERTs an active fleet_trust_domains row owned
84
+ -- by the new owner, then (b) reclaim_fleet_machine(..., p_new_trust_domain_id => <that domain>) moves
85
+ -- the reclaimed machine into it (#150). No redundant RPC is added here — the seed is one INSERT the
86
+ -- operator already has the grant for.
87
+ --
88
+ -- TOCTOU (mirror of #150 MUST-FIX 1). reclaim_fleet_trust_domain reassigns owner under
89
+ -- `select ... for update` on the fleet_trust_domains row. revoke_fleet_subject's trust_domain
90
+ -- ownership check was UNLOCKED, so a squatter's concurrent revoke('trust_domain', D) could pass its
91
+ -- ownership SELECT on the pre-reclaim snapshot and land its lifecycle UPDATE AFTER a concurrent
92
+ -- reclaim reassigned the domain — revoking the domain the operator just handed to the new owner.
93
+ -- revoke_fleet_subject is recreated below to LOCK the owning fleet_trust_domains row BEFORE the
94
+ -- trust_domain ownership check (matching reclaim's lock-first order → they serialize deadlock-free on
95
+ -- the same row). Pinned by the domain_reclaim_revoke_race isolation spec.
96
+ --
97
+ -- #118 discipline: reclaim_fleet_trust_domain (and the recreated revoke_fleet_subject) are SECURITY
98
+ -- DEFINER — restate the revoke-from-public and the grant and the comment in this same migration.
99
+
100
+ create or replace function agent_board.reclaim_fleet_trust_domain(
101
+ p_domain_id uuid,
102
+ p_new_owner_agent_id uuid,
103
+ p_reclaimed_by_agent_id uuid,
104
+ p_reason text default null
105
+ )
106
+ returns table(domain_id uuid, owner_agent_id uuid, lifecycle_state text, prior_owner_agent_id uuid)
107
+ language plpgsql
108
+ security definer
109
+ set search_path = agent_board, public
110
+ as $$
111
+ declare
112
+ v_domain agent_board.fleet_trust_domains%rowtype;
113
+ v_prior_owner uuid;
114
+ v_prior_lifecycle text;
115
+ begin
116
+ -- Operator-only: ownership arbitration for a contested domain is not self-service.
117
+ if not agent_board._is_service_role() then
118
+ raise exception 'reclaim_fleet_trust_domain is operator-only (service_role)' using errcode = '42501';
119
+ end if;
120
+
121
+ -- Provenance is required and must name a real agent (audit lands in metadata).
122
+ if p_reclaimed_by_agent_id is null then
123
+ raise exception 'p_reclaimed_by_agent_id is required' using errcode = '22023';
124
+ end if;
125
+ if not exists (
126
+ select 1 from agent_board.agents a where a.id = p_reclaimed_by_agent_id
127
+ ) then
128
+ raise exception 'p_reclaimed_by_agent_id is not a known agent' using errcode = '22023';
129
+ end if;
130
+
131
+ -- The arbitrated new owner must exist (validate before we reassign anything).
132
+ if p_new_owner_agent_id is null then
133
+ raise exception 'p_new_owner_agent_id is required' using errcode = '22023';
134
+ end if;
135
+ if not exists (
136
+ select 1 from agent_board.agents a where a.id = p_new_owner_agent_id
137
+ ) then
138
+ raise exception 'p_new_owner_agent_id is not a known agent' using errcode = '22023';
139
+ end if;
140
+
141
+ -- Lock the domain row so this read-reassign serializes against a concurrent
142
+ -- revoke_fleet_subject('trust_domain', D) (which, post-#153, also takes a row lock on this same
143
+ -- fleet_trust_domains row before its ownership check). Without it a stale-owner revoke could
144
+ -- interleave and nuke the just-reassigned domain (see the TOCTOU note in the header).
145
+ select * into v_domain from agent_board.fleet_trust_domains ftd where ftd.id = p_domain_id for update;
146
+ if v_domain.id is null then
147
+ raise exception 'fleet trust domain not found' using errcode = '22023';
148
+ end if;
149
+
150
+ v_prior_owner := v_domain.owner_agent_id;
151
+ v_prior_lifecycle := v_domain.lifecycle_state;
152
+
153
+ -- Reassign ownership. This is the ONLY function-writer of fleet_trust_domains.owner_agent_id
154
+ -- (no RPC INSERTs a domain — see the EC-4 class assertion in the test). owner_agent_id is the
155
+ -- domain's authority surface (header AUTHORITY MODEL): post-commit, every authz site reads the new
156
+ -- owner. The reassignment is atomic at THIS row; it is NOT a fully-serialized transfer of every
157
+ -- domain power (register_fleet_machine / set_fleet_receive_policy are not yet locked against an
158
+ -- in-flight reclaim — see header SERIALIZATION CAVEAT / #159).
159
+ -- LIFECYCLE: reclaim does NOT change lifecycle_state / revoked_at — a domain the squatter already
160
+ -- revoked STAYS revoked. This is INTENTIONAL (see header REVOKED-DOMAIN RESIDUAL): domain-
161
+ -- reactivation is a separate #149-parallel act, and ownership transfer must not silently un-revoke
162
+ -- a domain possibly revoked for cause. Do NOT add a revive here. Leaving both untouched also keeps
163
+ -- fleet_trust_domains_revoked_shape satisfied without extra care.
164
+ -- AUDIT: APPEND to metadata.reclaim_history (never overwrite) so repeated arbitrations preserve
165
+ -- prior records; prior_lifecycle is recorded so the revoked-domain residual is visible in audit.
166
+ update agent_board.fleet_trust_domains ftd
167
+ set owner_agent_id = p_new_owner_agent_id,
168
+ metadata = coalesce(ftd.metadata, '{}'::jsonb)
169
+ || jsonb_build_object(
170
+ 'reclaim_history',
171
+ coalesce(ftd.metadata -> 'reclaim_history', '[]'::jsonb)
172
+ || jsonb_build_array(jsonb_build_object(
173
+ 'reclaimed_by', p_reclaimed_by_agent_id,
174
+ 'reclaim_reason', nullif(trim(coalesce(p_reason, '')), ''),
175
+ 'prior_owner', v_prior_owner,
176
+ 'prior_lifecycle_state', v_prior_lifecycle,
177
+ 'reclaimed_at', now()
178
+ ))
179
+ )
180
+ || jsonb_build_object('redacted', true, 'metadata-safe', true),
181
+ updated_at = now()
182
+ where ftd.id = p_domain_id
183
+ returning * into v_domain;
184
+
185
+ return query select v_domain.id, v_domain.owner_agent_id, v_domain.lifecycle_state, v_prior_owner;
186
+ end;
187
+ $$;
188
+
189
+ -- #118 discipline: a SECURITY DEFINER function grants EXECUTE to PUBLIC on create. Restate the
190
+ -- revokes/grant and the comment. Operator-only ⇒ granted to service_role ALONE (not authenticated).
191
+ revoke all on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) from public, anon, authenticated;
192
+ grant execute on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) to service_role;
193
+
194
+ comment on function agent_board.reclaim_fleet_trust_domain(uuid, uuid, uuid, text) is
195
+ 'Operator-only (#153): reclaims a squatted fleet trust domain (whole-domain squatting — the residual #150''s machine-level reclaim could not cover). Reassigns fleet_trust_domains.owner_agent_id to the arbitrated new owner under a row lock — the only function-writer of that column. The reassignment is atomic at the domain row and post-commit every authz site (revoke_fleet_subject(''trust_domain''), register_fleet_machine, set_fleet_receive_policy) reads the new owner. SERIALIZATION CAVEAT (#159): only revoke_fleet_subject(''trust_domain'') is locked against an in-flight reclaim; register_fleet_machine and set_fleet_receive_policy are NOT yet serialized, so a squatter racing an in-flight reclaim may land a machine/policy that survives handoff and needs operator cleanup (tracked in #159). Writes NO fleet_revocation_events row (a trust_domain ledger row over-denies the whole domain incl. the new owner — same divergence rationale as #150); the audit is APPENDED to metadata.reclaim_history. Does NOT reassign the constituent machines'' owners (machine-level reclaim is #150) and INTENTIONALLY does NOT revive a domain the squatter already revoked (a pre-revoked domain STAYS revoked — domain-reactivation is a separate #149-parallel act, and ownership transfer must not silently un-revoke a domain possibly revoked for cause; reassign happens regardless of lifecycle, prior lifecycle is audited). service_role only (raises 42501 otherwise).';
196
+
197
+ -- ============================================================================================
198
+ -- #153 TOCTOU: revoke_fleet_subject('trust_domain') vs reclaim_fleet_trust_domain.
199
+ -- Reproduced VERBATIM from 20260829000100 (#150) EXCEPT: the trust_domain lock block added below,
200
+ -- and the comment. The trust_domain authz branch derives ownership from
201
+ -- fleet_trust_domains.owner_agent_id and previously read it WITHOUT a lock, so a squatter's
202
+ -- revoke('trust_domain', D) could pass its ownership check on the pre-reclaim snapshot and have its
203
+ -- lifecycle UPDATE apply AFTER a concurrent reclaim_fleet_trust_domain reassigned the domain —
204
+ -- nuking the domain the operator just handed to the new owner. We now LOCK the owning
205
+ -- fleet_trust_domains row FIRST (before the authz re-read and before the event write, matching
206
+ -- reclaim's lock-first order → the two serialize deadlock-free on the same row). The machine/harness/
207
+ -- repo_scope lock (#150 MUST-FIX 1) is preserved verbatim. #118 grants/comment restated.
208
+ -- ============================================================================================
209
+ create or replace function agent_board.revoke_fleet_subject(
210
+ p_subject_kind text,
211
+ p_subject_id uuid,
212
+ p_revoked_by_agent_id uuid,
213
+ p_revocation_reason text,
214
+ p_metadata jsonb default '{}'::jsonb
215
+ )
216
+ returns table(revocation_event_id uuid, subject_kind text, effective_at timestamptz)
217
+ language plpgsql
218
+ security definer
219
+ set search_path = agent_board, public
220
+ as $$
221
+ declare
222
+ v_event agent_board.fleet_revocation_events%rowtype;
223
+ v_actor_agent_id uuid;
224
+ begin
225
+ if p_subject_kind not in ('trust_domain', 'machine', 'harness', 'repo_scope', 'agent', 'signing_key', 'credential') then
226
+ raise exception 'unsupported fleet revocation subject' using errcode = '22023';
227
+ end if;
228
+
229
+ -- #150 MUST-FIX 1: lock the owning fleet_machines row for machine-derived kinds BEFORE the
230
+ -- unlocked ownership check below, so a concurrent reclaim_fleet_machine (which holds `for update`
231
+ -- on that row) serializes this call and the authz re-read sees committed ownership. Placed ahead
232
+ -- of the event write; reclaim also locks this row first, so acquisition order matches (no cycle).
233
+ if p_subject_kind in ('machine', 'harness', 'repo_scope') then
234
+ declare
235
+ v_lock_machine_id uuid;
236
+ begin
237
+ v_lock_machine_id := case
238
+ when p_subject_kind = 'machine' then p_subject_id
239
+ when p_subject_kind = 'harness' then (
240
+ select fhr.machine_id from agent_board.fleet_harness_registrations fhr where fhr.id = p_subject_id)
241
+ when p_subject_kind = 'repo_scope' then (
242
+ select fhr.machine_id
243
+ from agent_board.fleet_repo_scopes frs
244
+ join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
245
+ where frs.id = p_subject_id)
246
+ end;
247
+ if v_lock_machine_id is not null then
248
+ perform 1 from agent_board.fleet_machines fm where fm.id = v_lock_machine_id for update;
249
+ end if;
250
+ end;
251
+ end if;
252
+
253
+ -- #153 TOCTOU: lock the owning fleet_trust_domains row for the trust_domain kind BEFORE the
254
+ -- unlocked ownership check below, so a concurrent reclaim_fleet_trust_domain (which holds
255
+ -- `for update` on that row) serializes this call and the authz re-read sees committed ownership —
256
+ -- a stale-owner (squatter) revoke is then refused 42501 rather than nuking a just-reclaimed domain.
257
+ -- reclaim also locks this row first, so acquisition order matches (no cycle).
258
+ if p_subject_kind = 'trust_domain' then
259
+ perform 1 from agent_board.fleet_trust_domains ftd where ftd.id = p_subject_id for update;
260
+ end if;
261
+
262
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_revoked_by_agent_id, 'p_revoked_by_agent_id');
263
+
264
+ if not agent_board._is_service_role() then
265
+ if p_subject_kind = 'trust_domain' and not exists (
266
+ select 1 from agent_board.fleet_trust_domains ftd
267
+ where ftd.id = p_subject_id and ftd.owner_agent_id = v_actor_agent_id
268
+ ) then
269
+ raise exception 'trust domain is not owned by current_agent_id()' using errcode = '42501';
270
+ elsif p_subject_kind = 'machine' and not exists (
271
+ select 1 from agent_board.fleet_machines fm
272
+ where fm.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
273
+ ) then
274
+ raise exception 'machine is not owned by current_agent_id()' using errcode = '42501';
275
+ elsif p_subject_kind = 'harness' and not exists (
276
+ select 1
277
+ from agent_board.fleet_harness_registrations fhr
278
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
279
+ where fhr.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
280
+ ) then
281
+ raise exception 'harness is not owned by current_agent_id()' using errcode = '42501';
282
+ elsif p_subject_kind = 'repo_scope' and not exists (
283
+ select 1
284
+ from agent_board.fleet_repo_scopes frs
285
+ join agent_board.fleet_harness_registrations fhr on fhr.id = frs.harness_registration_id
286
+ join agent_board.fleet_machines fm on fm.id = fhr.machine_id
287
+ where frs.id = p_subject_id and fm.owner_agent_id = v_actor_agent_id
288
+ ) then
289
+ raise exception 'repo scope is not owned by current_agent_id()' using errcode = '42501';
290
+ elsif p_subject_kind = 'agent' and p_subject_id <> v_actor_agent_id then
291
+ raise exception 'agent revocation must target current_agent_id()' using errcode = '42501';
292
+ elsif p_subject_kind = 'signing_key' and not exists (
293
+ select 1 from agent_board.agent_signing_keys ask
294
+ where ask.id = p_subject_id and ask.agent_id = v_actor_agent_id
295
+ ) then
296
+ raise exception 'signing key is not owned by current_agent_id()' using errcode = '42501';
297
+ elsif p_subject_kind = 'credential' and not exists (
298
+ select 1 from agent_board.agent_credentials ac
299
+ where ac.id = p_subject_id and ac.agent_id = v_actor_agent_id
300
+ ) then
301
+ raise exception 'credential is not owned by current_agent_id()' using errcode = '42501';
302
+ end if;
303
+ end if;
304
+
305
+ select *
306
+ into v_event
307
+ from agent_board.fleet_revocation_events fre
308
+ where fre.subject_kind = p_subject_kind
309
+ and fre.subject_id = p_subject_id
310
+ limit 1;
311
+
312
+ if v_event.id is null then
313
+ insert into agent_board.fleet_revocation_events(
314
+ subject_kind,
315
+ subject_id,
316
+ trust_domain_id,
317
+ machine_id,
318
+ harness_registration_id,
319
+ repo_scope_id,
320
+ agent_id,
321
+ signing_key_id,
322
+ revoked_by_agent_id,
323
+ revocation_reason,
324
+ metadata
325
+ )
326
+ values (
327
+ p_subject_kind,
328
+ p_subject_id,
329
+ case when p_subject_kind = 'trust_domain' then p_subject_id end,
330
+ case when p_subject_kind = 'machine' then p_subject_id end,
331
+ case when p_subject_kind = 'harness' then p_subject_id end,
332
+ case when p_subject_kind = 'repo_scope' then p_subject_id end,
333
+ case when p_subject_kind = 'agent' then p_subject_id end,
334
+ case when p_subject_kind = 'signing_key' then p_subject_id end,
335
+ p_revoked_by_agent_id,
336
+ trim(coalesce(p_revocation_reason, 'operator revocation')),
337
+ coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
338
+ )
339
+ returning * into v_event;
340
+ else
341
+ update agent_board.fleet_revocation_events fre
342
+ set effective_at = now(),
343
+ revoked_by_agent_id = p_revoked_by_agent_id,
344
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
345
+ metadata = coalesce(p_metadata, '{}'::jsonb) || jsonb_build_object('redacted', true, 'metadata-safe', true)
346
+ where fre.id = v_event.id
347
+ returning * into v_event;
348
+ end if;
349
+
350
+ if p_subject_kind = 'trust_domain' then
351
+ update agent_board.fleet_trust_domains
352
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
353
+ where id = p_subject_id;
354
+ elsif p_subject_kind = 'machine' then
355
+ update agent_board.fleet_machines
356
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
357
+ where id = p_subject_id;
358
+ elsif p_subject_kind = 'harness' then
359
+ update agent_board.fleet_harness_registrations
360
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
361
+ where id = p_subject_id;
362
+ elsif p_subject_kind = 'repo_scope' then
363
+ update agent_board.fleet_repo_scopes
364
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
365
+ where id = p_subject_id;
366
+ elsif p_subject_kind = 'agent' then
367
+ update agent_board.fleet_machine_agents
368
+ set lifecycle_state = 'revoked', revoked_at = v_event.effective_at, updated_at = now()
369
+ where agent_id = p_subject_id;
370
+ elsif p_subject_kind = 'signing_key' then
371
+ update agent_board.agent_signing_keys
372
+ set status = case when status = 'compromised' then status else 'revoked' end,
373
+ proof_state = 'revoked',
374
+ revoked_at = coalesce(revoked_at, v_event.effective_at),
375
+ revoked_by_agent_id = p_revoked_by_agent_id,
376
+ revocation_reason = trim(coalesce(p_revocation_reason, 'operator revocation')),
377
+ updated_at = now()
378
+ where id = p_subject_id;
379
+ end if;
380
+
381
+ return query select v_event.id, v_event.subject_kind, v_event.effective_at;
382
+ end;
383
+ $$;
384
+
385
+ revoke all on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) from public, anon;
386
+ grant execute on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) to authenticated, service_role;
387
+
388
+ comment on function agent_board.revoke_fleet_subject(text, uuid, uuid, text, jsonb) is
389
+ '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), and for trust_domain it locks the owning fleet_trust_domains row before the ownership check (#153), so a concurrent reclaim_fleet_machine / reclaim_fleet_trust_domain serializes and a stale-owner revoke is refused (42501) rather than nuking a just-reclaimed machine or domain.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.12.0",
3
+ "version": "1.22.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",