@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.
- package/fixtures/already-applied.json +124 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/package.json +1 -1
- package/schema-manifest.json +282 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_fedsession.test.sql +6 -4
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +29 -12
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +25 -9
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
-- CLAIMGATE Phase 3 — ENFORCE (#133): per-kind GATED fleet-authority DENIAL + renew re-auth
|
|
2
|
+
-- + max-lease horizon. Ships the full enforcement machinery, but every fleet-backed kind's
|
|
3
|
+
-- DENIAL is behind a per-kind flag that DEFAULTS OFF — nothing denies at merge.
|
|
4
|
+
--
|
|
5
|
+
-- =====================================================================================
|
|
6
|
+
-- ROLLOUT — GATED PER-KIND, DEFAULT OFF (maintainer decision 2026-07-31, locked)
|
|
7
|
+
-- =====================================================================================
|
|
8
|
+
-- Reuses the SHADOW claimgate_enforcement_flags table (20260824000100:118) with per-kind
|
|
9
|
+
-- scope_keys 'enforce_machine' / 'enforce_repository' / 'enforce_repo_file', resolved by
|
|
10
|
+
-- _claimgate_kind_enforced(kind) which COALESCES to FALSE when the row is absent (mirrors the
|
|
11
|
+
-- send-authz config-flag pattern 20260718000100 — but note that pattern DEFAULTS FAIL-OPEN;
|
|
12
|
+
-- here absent => not-enforced => SHADOW behavior, i.e. shadow-stamp only). When a kind's flag
|
|
13
|
+
-- is OFF: acquire/renew still just SHADOW-stamp (current behavior, blocks nothing). When ON:
|
|
14
|
+
-- DENY (42501) an unauthorized claim/renew of that kind. Machine first (smallest population,
|
|
15
|
+
-- cleanest identity), then repository/repo_file once each shadow census is clean.
|
|
16
|
+
--
|
|
17
|
+
-- =====================================================================================
|
|
18
|
+
-- MAX-LEASE HORIZON — 30d DEFAULT, operator-configurable; CLAMP (not reject)
|
|
19
|
+
-- =====================================================================================
|
|
20
|
+
-- "Bounded by one lease term" is meaningless without a maximum (a gated kind could meet the
|
|
21
|
+
-- acquire TTL and then renew for years). ENFORCE imposes a frozen maximum horizon on acquire
|
|
22
|
+
-- AND every renew for the fleet-gated kinds (machine/repository/repo_file), ONLY when that
|
|
23
|
+
-- kind's enforcement flag is ON. Stored as _claimgate_max_lease_horizon() reading an optional
|
|
24
|
+
-- claimgate_settings('max_lease_horizon') override row, defaulting 30d (#118 discipline: a
|
|
25
|
+
-- single-source resolver, not a literal sprinkled through the logic).
|
|
26
|
+
--
|
|
27
|
+
-- CLAMP, not reject: the horizon CLAMPS an over-long lease to now()+horizon rather than raising.
|
|
28
|
+
-- This is FORCED by the reconciliation strategy — existing overlong active leases are NOT
|
|
29
|
+
-- bulk-migrated (they are gated OFF until promotion); they reconcile at their NEXT renew, which
|
|
30
|
+
-- only works if renew CLAMPS (a reject would leave the old long expiry in place and just error).
|
|
31
|
+
-- Acquire clamps too, for consistency (a fresh 60d request lands the 30d bound). service_role
|
|
32
|
+
-- is exempt (operator carve-out, consistent with the SHADOW ownerless cap).
|
|
33
|
+
-- NOTE (reviewer-visible divergence): the SHADOW ownerless TTL cap (20260824000100:369)
|
|
34
|
+
-- REJECTS an over-long ownerless lease (22023); this fleet horizon CLAMPS. Different mechanism,
|
|
35
|
+
-- different intent (ownerless quota-squat prevention vs. fleet lease-term bound) — deliberate.
|
|
36
|
+
--
|
|
37
|
+
-- =====================================================================================
|
|
38
|
+
-- ACTOR-PARAMETERIZED PREDICATE (shadow evidence must cover EXISTING claims)
|
|
39
|
+
-- =====================================================================================
|
|
40
|
+
-- SHADOW's session-derived _can_claim_resource(kind,key) can only evaluate the CURRENT actor,
|
|
41
|
+
-- so it cannot census a claim already held by some OTHER agent. ENFORCE splits it:
|
|
42
|
+
-- * _can_claim_resource_for(p_actor, kind, key) — pure authority for an ARBITRARY actor
|
|
43
|
+
-- (NO service_role carve-out — that is a property of the CALLER, not the subject).
|
|
44
|
+
-- * _can_claim_resource(kind, key) — the frozen IF-0-SHADOW-1 wrapper, unchanged
|
|
45
|
+
-- in behavior: service_role short-circuits true, else delegates to _for(current_agent_id()).
|
|
46
|
+
-- Behavior of the frozen signature is IDENTICAL (the 36-assertion predicate suite regression-pins
|
|
47
|
+
-- the refactor). The operator censuses existing rows with _can_claim_resource_for(claim.agent_id,…).
|
|
48
|
+
--
|
|
49
|
+
-- FAIL-CLOSED ON UNKNOWN: automatic. An unknown/unresolvable fleet resource (fingerprint not in
|
|
50
|
+
-- the graph) makes the predicate return FALSE, so an enforced acquire of it DENIES — no special arm.
|
|
51
|
+
--
|
|
52
|
+
-- Renew re-auth CONSULTS THE SAME per-kind flag acquire does (a mutation splitting the flags fails
|
|
53
|
+
-- a test). Task-kind renew AND all release_claim stay OWNERSHIP-ONLY — this SUPERSEDES
|
|
54
|
+
-- 20260817000100:27-28 (maintainer-ratified 2026-07-31); only the 3 predicate-gated kinds re-auth.
|
|
55
|
+
--
|
|
56
|
+
-- Depends on the register_fleet_machine cross-owner-hijack fix (#141 / 20260629000101 hardening,
|
|
57
|
+
-- merged as #145) — fingerprint-ownership must not be stealable once it equals lock authority.
|
|
58
|
+
--
|
|
59
|
+
-- #118 class (CLAUDE.md schema-mechanics): every NEWLY-DEFINED SECURITY DEFINER function below
|
|
60
|
+
-- restates revoke/grant/comment; the new claimgate_settings table restates grant/RLS/policy/
|
|
61
|
+
-- comment (mirroring claimgate_enforcement_flags). acquire_claim / renew_claim / _can_claim_resource
|
|
62
|
+
-- are create-or-replace of existing functions (preserve prior ACL/comment) — they restate only
|
|
63
|
+
-- revoke+grant. Additive, forward-only. schema_version stays 8.
|
|
64
|
+
|
|
65
|
+
-- 0. Per-kind enforcement resolver — reuse claimgate_enforcement_flags, DEFAULT OFF. ---------
|
|
66
|
+
create or replace function agent_board._claimgate_kind_enforced(p_resource_kind text)
|
|
67
|
+
returns boolean
|
|
68
|
+
language sql
|
|
69
|
+
stable
|
|
70
|
+
security definer
|
|
71
|
+
set search_path = ''
|
|
72
|
+
as $$
|
|
73
|
+
select coalesce(
|
|
74
|
+
(select f.enabled
|
|
75
|
+
from agent_board.claimgate_enforcement_flags f
|
|
76
|
+
where f.scope_key = case p_resource_kind
|
|
77
|
+
when 'machine' then 'enforce_machine'
|
|
78
|
+
when 'repository' then 'enforce_repository'
|
|
79
|
+
when 'repo_file' then 'enforce_repo_file'
|
|
80
|
+
else null
|
|
81
|
+
end),
|
|
82
|
+
false)
|
|
83
|
+
$$;
|
|
84
|
+
revoke all on function agent_board._claimgate_kind_enforced(text) from public, anon;
|
|
85
|
+
grant execute on function agent_board._claimgate_kind_enforced(text) to authenticated, service_role;
|
|
86
|
+
comment on function agent_board._claimgate_kind_enforced(text) is
|
|
87
|
+
'CLAIMGATE/ENFORCE (#133): is DENIAL enforced for this fleet-backed kind? Reads '
|
|
88
|
+
'claimgate_enforcement_flags scope_key enforce_machine/enforce_repository/enforce_repo_file; '
|
|
89
|
+
'DEFAULTS FALSE (absent row) so a kind stays SHADOW (stamp-only) until an operator flips it. '
|
|
90
|
+
'Non-fleet kinds (task/file/tool/custom) always resolve FALSE (no scope_key). SECURITY DEFINER; '
|
|
91
|
+
'PUBLIC EXECUTE revoked. acquire AND renew consult THIS resolver (one flag per kind).';
|
|
92
|
+
|
|
93
|
+
-- 1. Max-lease horizon setting + resolver (operator-configurable, default 30d). --------------
|
|
94
|
+
create table if not exists agent_board.claimgate_settings (
|
|
95
|
+
key text primary key,
|
|
96
|
+
value text not null,
|
|
97
|
+
updated_at timestamptz not null default now(),
|
|
98
|
+
-- Validate the max_lease_horizon override at WRITE time, not in the hot path: a malformed or
|
|
99
|
+
-- non-positive value must fail the operator's INSERT/UPDATE rather than raise (22007) or clamp
|
|
100
|
+
-- into the past on EVERY enforced fleet acquire/renew where _claimgate_max_lease_horizon() casts
|
|
101
|
+
-- it (panel should-fix). CASE guards the cast so it only runs for the horizon key (a non-horizon
|
|
102
|
+
-- setting may hold arbitrary text). A malformed literal raises on the cast here; a <= 0 interval
|
|
103
|
+
-- fails the CHECK — both at write time.
|
|
104
|
+
constraint claimgate_settings_max_lease_horizon_valid check (
|
|
105
|
+
case when key = 'max_lease_horizon' then (value::interval) > interval '0' else true end
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
comment on table agent_board.claimgate_settings is
|
|
109
|
+
'CLAIMGATE operator-configurable settings (key/value text). key ''max_lease_horizon'' overrides '
|
|
110
|
+
'the fleet max-lease horizon (an interval literal, e.g. ''14 days''); absent => 30d default. '
|
|
111
|
+
'Operator-managed (service_role); read through _claimgate_max_lease_horizon().';
|
|
112
|
+
alter table agent_board.claimgate_settings enable row level security;
|
|
113
|
+
grant select, insert, update, delete on agent_board.claimgate_settings to service_role;
|
|
114
|
+
drop policy if exists agent_board_service_all_claimgate_settings on agent_board.claimgate_settings;
|
|
115
|
+
create policy agent_board_service_all_claimgate_settings
|
|
116
|
+
on agent_board.claimgate_settings
|
|
117
|
+
as permissive for all to service_role using (true) with check (true);
|
|
118
|
+
|
|
119
|
+
create or replace function agent_board._claimgate_max_lease_horizon()
|
|
120
|
+
returns interval
|
|
121
|
+
language sql
|
|
122
|
+
stable
|
|
123
|
+
security definer
|
|
124
|
+
set search_path = ''
|
|
125
|
+
as $$
|
|
126
|
+
select coalesce(
|
|
127
|
+
(select s.value::interval
|
|
128
|
+
from agent_board.claimgate_settings s
|
|
129
|
+
where s.key = 'max_lease_horizon'),
|
|
130
|
+
interval '30 days')
|
|
131
|
+
$$;
|
|
132
|
+
revoke all on function agent_board._claimgate_max_lease_horizon() from public, anon;
|
|
133
|
+
grant execute on function agent_board._claimgate_max_lease_horizon() to authenticated, service_role;
|
|
134
|
+
comment on function agent_board._claimgate_max_lease_horizon() is
|
|
135
|
+
'CLAIMGATE/ENFORCE (#133): max lease horizon for an ENFORCED fleet-backed (machine/repository/'
|
|
136
|
+
'repo_file) claim, applied on acquire AND every renew (CLAMPS an over-long lease, does not reject). '
|
|
137
|
+
'Reads claimgate_settings(''max_lease_horizon'') override; DEFAULTS 30d. SECURITY DEFINER (reads a '
|
|
138
|
+
'service_role-only table); PUBLIC EXECUTE revoked.';
|
|
139
|
+
|
|
140
|
+
-- 2. _can_claim_resource_for — the ACTOR-PARAMETERIZED authority twin. ----------------------
|
|
141
|
+
-- Body reproduced from _can_claim_resource (20260824000100) EXCEPT: (a) authority is computed
|
|
142
|
+
-- for the PASSED p_actor, not current_agent_id(); (b) NO service_role short-circuit (that is a
|
|
143
|
+
-- caller property, kept in the wrapper below). Lets the operator census EXISTING held claims
|
|
144
|
+
-- for an arbitrary owner. SECURITY DEFINER to break fleet-table RLS recursion (#118).
|
|
145
|
+
create or replace function agent_board._can_claim_resource_for(
|
|
146
|
+
p_actor uuid,
|
|
147
|
+
p_resource_kind text,
|
|
148
|
+
p_resource_key text
|
|
149
|
+
)
|
|
150
|
+
returns boolean
|
|
151
|
+
language plpgsql
|
|
152
|
+
stable
|
|
153
|
+
security definer
|
|
154
|
+
set search_path = 'agent_board', 'public'
|
|
155
|
+
as $function$
|
|
156
|
+
declare
|
|
157
|
+
v_fp text;
|
|
158
|
+
v_sep int;
|
|
159
|
+
begin
|
|
160
|
+
-- The ownerless tail + task are not fleet-gated, so they remain authorized even for a null
|
|
161
|
+
-- actor (governed elsewhere: task gate in acquire_claim; ownerless TTL/quota in acquire_claim).
|
|
162
|
+
if p_resource_kind = 'task' then
|
|
163
|
+
return true;
|
|
164
|
+
elsif p_resource_kind in ('file', 'tool', 'custom') then
|
|
165
|
+
return true;
|
|
166
|
+
end if;
|
|
167
|
+
|
|
168
|
+
-- A null principal is honestly UNAUTHORIZED on any fleet-backed kind (fail-closed-on-unknown).
|
|
169
|
+
if p_actor is null then
|
|
170
|
+
return false;
|
|
171
|
+
end if;
|
|
172
|
+
|
|
173
|
+
if p_resource_kind = 'machine' then
|
|
174
|
+
v_fp := btrim(p_resource_key);
|
|
175
|
+
if v_fp is null or v_fp = '' then
|
|
176
|
+
return false;
|
|
177
|
+
end if;
|
|
178
|
+
-- machine_fingerprint is globally unique; the machine AND its trust domain must be active,
|
|
179
|
+
-- and the actor must own it OR hold an active binding on it.
|
|
180
|
+
return exists (
|
|
181
|
+
select 1
|
|
182
|
+
from agent_board.fleet_machines fm
|
|
183
|
+
join agent_board.fleet_trust_domains ftd
|
|
184
|
+
on ftd.id = fm.trust_domain_id
|
|
185
|
+
where fm.machine_fingerprint = v_fp
|
|
186
|
+
and fm.lifecycle_state = 'active'
|
|
187
|
+
and ftd.lifecycle_state = 'active'
|
|
188
|
+
and (
|
|
189
|
+
fm.owner_agent_id = p_actor
|
|
190
|
+
or exists (
|
|
191
|
+
select 1
|
|
192
|
+
from agent_board.fleet_machine_agents fma
|
|
193
|
+
where fma.machine_id = fm.id
|
|
194
|
+
and fma.agent_id = p_actor
|
|
195
|
+
and fma.lifecycle_state = 'active'
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
elsif p_resource_kind = 'repository' or p_resource_kind = 'repo_file' then
|
|
201
|
+
if p_resource_kind = 'repo_file' then
|
|
202
|
+
-- FIRST ':' splits fp | path (the exact position CLAIMKEY froze). No colon / empty fp =
|
|
203
|
+
-- not resolvable -> deny (honest verdict; fail-closed).
|
|
204
|
+
v_sep := position(':' in coalesce(p_resource_key, ''));
|
|
205
|
+
if v_sep <= 1 then
|
|
206
|
+
return false;
|
|
207
|
+
end if;
|
|
208
|
+
v_fp := btrim(substring(p_resource_key from 1 for v_sep - 1));
|
|
209
|
+
else
|
|
210
|
+
v_fp := btrim(p_resource_key);
|
|
211
|
+
end if;
|
|
212
|
+
if v_fp is null or v_fp = '' then
|
|
213
|
+
return false;
|
|
214
|
+
end if;
|
|
215
|
+
-- FULL chain, EVERY edge lifecycle-active (PANEL AMENDMENT: harness revoke does not cascade
|
|
216
|
+
-- to child scopes, so fhr.lifecycle_state='active' is load-bearing here). Anchored on the
|
|
217
|
+
-- actor's active binding + its repo_scope; UNION across trust domains (shared-lock model).
|
|
218
|
+
return exists (
|
|
219
|
+
select 1
|
|
220
|
+
from agent_board.fleet_machine_agents fma
|
|
221
|
+
join agent_board.fleet_repo_scopes frs
|
|
222
|
+
on frs.id = fma.repo_scope_id
|
|
223
|
+
join agent_board.fleet_harness_registrations fhr
|
|
224
|
+
on fhr.id = frs.harness_registration_id
|
|
225
|
+
join agent_board.fleet_machines fm
|
|
226
|
+
on fm.id = fhr.machine_id
|
|
227
|
+
join agent_board.fleet_trust_domains ftd
|
|
228
|
+
on ftd.id = fm.trust_domain_id
|
|
229
|
+
where fma.agent_id = p_actor
|
|
230
|
+
and fma.lifecycle_state = 'active'
|
|
231
|
+
and frs.repo_fingerprint = v_fp
|
|
232
|
+
and frs.scope_role <> 'observer'
|
|
233
|
+
and frs.lifecycle_state = 'active'
|
|
234
|
+
and fhr.lifecycle_state = 'active'
|
|
235
|
+
and fm.lifecycle_state = 'active'
|
|
236
|
+
and ftd.lifecycle_state = 'active'
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
else
|
|
240
|
+
-- Defensive: an unenumerated future kind. The completeness test enumerates the 7 catalog
|
|
241
|
+
-- kinds and asserts each is handled, so this is unreachable for today's constraint.
|
|
242
|
+
return true;
|
|
243
|
+
end if;
|
|
244
|
+
end;
|
|
245
|
+
$function$;
|
|
246
|
+
-- LEAST-PRIVILEGE (panel must-fix): grant to service_role ONLY — NOT authenticated. This
|
|
247
|
+
-- SECURITY DEFINER function is an RLS-bypassing authority ORACLE: fleet_machine_agents' SELECT
|
|
248
|
+
-- policy (20260629000100:341-355) hides a binding from everyone but its subject/machine-owner,
|
|
249
|
+
-- but _for would let ANY authenticated caller probe an arbitrary (actor, fingerprint) pair and
|
|
250
|
+
-- learn whether that actor is fleet-authorized. The only call-site is the session-bound wrapper
|
|
251
|
+
-- _can_claim_resource (which binds current_agent_id() and is itself SECURITY DEFINER, so it
|
|
252
|
+
-- invokes _for as OWNER regardless of this grant) — no RPC passes caller input as p_actor. So the
|
|
253
|
+
-- authenticated grant buys nothing and only exposes the oracle; revoke it. The operator census
|
|
254
|
+
-- path runs as service_role.
|
|
255
|
+
revoke all on function agent_board._can_claim_resource_for(uuid, text, text) from public, anon;
|
|
256
|
+
grant execute on function agent_board._can_claim_resource_for(uuid, text, text) to service_role;
|
|
257
|
+
comment on function agent_board._can_claim_resource_for(uuid, text, text) is
|
|
258
|
+
'CLAIMGATE/ENFORCE (#133): actor-parameterized fleet-graph authority — computes authority for an '
|
|
259
|
+
'ARBITRARY p_actor (NO service_role carve-out; that is a caller property, kept in the session '
|
|
260
|
+
'wrapper _can_claim_resource). Same fleet chains as SHADOW. Lets the operator (service_role) census '
|
|
261
|
+
'EXISTING held claims (_can_claim_resource_for(claim.agent_id, kind, key)). SECURITY DEFINER; granted '
|
|
262
|
+
'to service_role ONLY (an RLS-bypass authority oracle — NOT exposed to authenticated); PUBLIC revoked.';
|
|
263
|
+
|
|
264
|
+
-- 3. _can_claim_resource — the frozen IF-0-SHADOW-1 wrapper, now delegating to the twin. ----
|
|
265
|
+
-- BEHAVIOR UNCHANGED: service_role short-circuits true; otherwise authority for the SESSION
|
|
266
|
+
-- actor (current_agent_id()). The 36-assertion predicate suite regression-pins this refactor.
|
|
267
|
+
create or replace function agent_board._can_claim_resource(
|
|
268
|
+
p_resource_kind text,
|
|
269
|
+
p_resource_key text
|
|
270
|
+
)
|
|
271
|
+
returns boolean
|
|
272
|
+
language plpgsql
|
|
273
|
+
stable
|
|
274
|
+
security definer
|
|
275
|
+
set search_path = 'agent_board', 'public'
|
|
276
|
+
as $function$
|
|
277
|
+
begin
|
|
278
|
+
-- Operator carve-out: service_role short-circuits authorized (matches every other gate).
|
|
279
|
+
if agent_board._is_service_role() then
|
|
280
|
+
return true;
|
|
281
|
+
end if;
|
|
282
|
+
return agent_board._can_claim_resource_for(
|
|
283
|
+
agent_board.current_agent_id(), p_resource_kind, p_resource_key);
|
|
284
|
+
end;
|
|
285
|
+
$function$;
|
|
286
|
+
revoke all on function agent_board._can_claim_resource(text, text) from public, anon;
|
|
287
|
+
grant execute on function agent_board._can_claim_resource(text, text) to authenticated, service_role;
|
|
288
|
+
comment on function agent_board._can_claim_resource(text, text) is
|
|
289
|
+
'CLAIMGATE (#133, IF-0-SHADOW-1): session-derived fleet-graph authority predicate ENFORCE consumes. '
|
|
290
|
+
'service_role short-circuits true; otherwise delegates to _can_claim_resource_for(current_agent_id()). '
|
|
291
|
+
'Behavior FROZEN and unchanged from SHADOW (the actor-parameterized split is behavior-preserving). '
|
|
292
|
+
'SECURITY DEFINER to break fleet RLS recursion; PUBLIC EXECUTE revoked.';
|
|
293
|
+
|
|
294
|
+
-- 4. acquire_claim — per-kind gated DENIAL + horizon clamp. ---------------------------------
|
|
295
|
+
-- Reproduced VERBATIM from 20260824000100 (SHADOW) EXCEPT the single ENFORCE block inserted
|
|
296
|
+
-- right after the shadow verdict is computed and BEFORE the sweep (predicate-before-side-
|
|
297
|
+
-- effects: a denied caller observes no state change — no swept-expired rows leak the deny).
|
|
298
|
+
create or replace function agent_board.acquire_claim(
|
|
299
|
+
p_agent_id uuid,
|
|
300
|
+
p_resource_kind text,
|
|
301
|
+
p_resource_key text,
|
|
302
|
+
p_lease_expires_at timestamp with time zone,
|
|
303
|
+
p_task_id uuid default null,
|
|
304
|
+
p_thread_id uuid default null,
|
|
305
|
+
p_metadata jsonb default '{}'::jsonb
|
|
306
|
+
)
|
|
307
|
+
returns table(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamp with time zone, released_at timestamp with time zone, created_at timestamp with time zone, updated_at timestamp with time zone)
|
|
308
|
+
language plpgsql
|
|
309
|
+
security definer
|
|
310
|
+
set search_path to 'agent_board', 'public'
|
|
311
|
+
as $function$
|
|
312
|
+
declare
|
|
313
|
+
v_thread_id uuid;
|
|
314
|
+
v_claim_id uuid;
|
|
315
|
+
v_actor_agent_id uuid;
|
|
316
|
+
v_shadow_authorized boolean;
|
|
317
|
+
v_ownerless boolean;
|
|
318
|
+
v_active_ownerless int;
|
|
319
|
+
begin
|
|
320
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
321
|
+
|
|
322
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
323
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
324
|
+
end if;
|
|
325
|
+
|
|
326
|
+
-- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
|
|
327
|
+
-- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
|
|
328
|
+
-- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
|
|
329
|
+
p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
|
|
330
|
+
|
|
331
|
+
if p_task_id is not null then
|
|
332
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
333
|
+
if v_thread_id is null then
|
|
334
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
335
|
+
end if;
|
|
336
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
337
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
338
|
+
end if;
|
|
339
|
+
else
|
|
340
|
+
v_thread_id := p_thread_id;
|
|
341
|
+
end if;
|
|
342
|
+
|
|
343
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
344
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
345
|
+
end if;
|
|
346
|
+
|
|
347
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
348
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
349
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
350
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
351
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
352
|
+
-- with a uuid-keyed legitimate claim.
|
|
353
|
+
--
|
|
354
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
355
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
356
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
357
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
358
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
359
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
360
|
+
if p_resource_kind = 'task'
|
|
361
|
+
and p_resource_key ~ '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' then
|
|
362
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
363
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
364
|
+
end if;
|
|
365
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
366
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
367
|
+
using errcode = '42501';
|
|
368
|
+
end if;
|
|
369
|
+
end if;
|
|
370
|
+
|
|
371
|
+
-- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
|
|
372
|
+
-- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
|
|
373
|
+
-- unauthorized caller). The verdict is stamped below regardless of enforcement.
|
|
374
|
+
v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
|
|
375
|
+
|
|
376
|
+
-- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
|
|
377
|
+
-- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
|
|
378
|
+
-- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
|
|
379
|
+
-- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
|
|
380
|
+
-- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
|
|
381
|
+
-- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
|
|
382
|
+
-- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
|
|
383
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
384
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
385
|
+
and not v_shadow_authorized then
|
|
386
|
+
raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
|
|
387
|
+
using errcode = '42501';
|
|
388
|
+
end if;
|
|
389
|
+
|
|
390
|
+
-- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
|
|
391
|
+
-- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
|
|
392
|
+
-- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
|
|
393
|
+
-- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
|
|
394
|
+
-- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
|
|
395
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
396
|
+
and not agent_board._is_service_role()
|
|
397
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
398
|
+
and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
|
|
399
|
+
p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
|
|
400
|
+
end if;
|
|
401
|
+
|
|
402
|
+
-- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
|
|
403
|
+
-- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
|
|
404
|
+
-- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
|
|
405
|
+
-- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
|
|
406
|
+
v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
|
|
407
|
+
if v_ownerless
|
|
408
|
+
and not agent_board._is_service_role()
|
|
409
|
+
and agent_board._claimgate_ownerless_guards_enabled() then
|
|
410
|
+
-- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
|
|
411
|
+
-- this is a lease-length bound, NOT an authority block.
|
|
412
|
+
if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
|
|
413
|
+
raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
|
|
414
|
+
using errcode = '22023';
|
|
415
|
+
end if;
|
|
416
|
+
|
|
417
|
+
-- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
|
|
418
|
+
-- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
|
|
419
|
+
-- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
|
|
420
|
+
-- at commit; it does not block other actors (the key mixes in the actor id).
|
|
421
|
+
perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
|
|
422
|
+
select count(*) into v_active_ownerless
|
|
423
|
+
from agent_board.claims c
|
|
424
|
+
where c.agent_id = v_actor_agent_id
|
|
425
|
+
and c.resource_kind in ('file', 'tool', 'custom')
|
|
426
|
+
and c.status = 'active'
|
|
427
|
+
and c.released_at is null
|
|
428
|
+
and c.lease_expires_at > now();
|
|
429
|
+
if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
|
|
430
|
+
raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
|
|
431
|
+
using errcode = '54000';
|
|
432
|
+
end if;
|
|
433
|
+
end if;
|
|
434
|
+
|
|
435
|
+
-- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
|
|
436
|
+
-- is already canonical (above); comparing against the CANONICAL form of each existing row's key
|
|
437
|
+
-- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
|
|
438
|
+
-- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
|
|
439
|
+
-- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
|
|
440
|
+
-- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
|
|
441
|
+
-- live census.
|
|
442
|
+
update agent_board.claims c
|
|
443
|
+
set status = 'expired',
|
|
444
|
+
updated_at = now()
|
|
445
|
+
where c.resource_kind = p_resource_kind
|
|
446
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
|
|
447
|
+
and c.status = 'active'
|
|
448
|
+
and c.released_at is null
|
|
449
|
+
and c.lease_expires_at <= now();
|
|
450
|
+
|
|
451
|
+
if exists (
|
|
452
|
+
select 1 from agent_board.claims
|
|
453
|
+
where claims.resource_kind = p_resource_kind
|
|
454
|
+
and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
|
|
455
|
+
and claims.status = 'active'
|
|
456
|
+
and claims.released_at is null
|
|
457
|
+
and claims.lease_expires_at > now()
|
|
458
|
+
) then
|
|
459
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
460
|
+
end if;
|
|
461
|
+
|
|
462
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
463
|
+
values (
|
|
464
|
+
p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
|
|
465
|
+
-- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
|
|
466
|
+
-- is authoritative over a caller-supplied claimgate_shadow key.
|
|
467
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
468
|
+
|| jsonb_build_object(
|
|
469
|
+
'claimgate_shadow',
|
|
470
|
+
jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
returning id into v_claim_id;
|
|
474
|
+
|
|
475
|
+
if p_task_id is not null then
|
|
476
|
+
perform agent_board.append_task_event(p_task_id, v_actor_agent_id, 'claimed', jsonb_build_object('claim_id', v_claim_id, 'resource_kind', p_resource_kind, 'resource_key', p_resource_key));
|
|
477
|
+
end if;
|
|
478
|
+
|
|
479
|
+
return query
|
|
480
|
+
select c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at
|
|
481
|
+
from agent_board.claims c
|
|
482
|
+
where c.id = v_claim_id;
|
|
483
|
+
end;
|
|
484
|
+
$function$;
|
|
485
|
+
revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
|
|
486
|
+
grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
|
|
487
|
+
|
|
488
|
+
-- 5. renew_claim — RE-AUTHORIZE fleet-gated kinds + horizon clamp. --------------------------
|
|
489
|
+
-- Reproduced VERBATIM from 20260821000100 (CLAIMKEY) EXCEPT the ENFORCE block after the
|
|
490
|
+
-- ownership check: (a) re-run the SAME session predicate acquire consults for the 3 predicate-
|
|
491
|
+
-- gated kinds when the kind's flag is ON, so a revoked binding cannot perpetually extend a
|
|
492
|
+
-- lease; (b) clamp the renewed lease to the max horizon. Task-kind renew stays OWNERSHIP-ONLY
|
|
493
|
+
-- (this SUPERSEDES 20260817000100:27-28, maintainer-ratified 2026-07-31): only machine/
|
|
494
|
+
-- repository/repo_file re-auth; task/file/tool/custom renew unchanged, release_claim untouched.
|
|
495
|
+
create or replace function agent_board.renew_claim(p_lease_token uuid, p_lease_expires_at timestamptz)
|
|
496
|
+
returns table(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamptz, released_at timestamptz, created_at timestamptz, updated_at timestamptz)
|
|
497
|
+
language plpgsql
|
|
498
|
+
security definer
|
|
499
|
+
set search_path = agent_board, public
|
|
500
|
+
as $$
|
|
501
|
+
declare
|
|
502
|
+
v_actor_agent_id uuid;
|
|
503
|
+
v_kind text;
|
|
504
|
+
v_key text;
|
|
505
|
+
v_effective_expires timestamptz;
|
|
506
|
+
begin
|
|
507
|
+
if not agent_board._is_service_role() then
|
|
508
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
|
|
509
|
+
if not exists (
|
|
510
|
+
select 1
|
|
511
|
+
from agent_board.claims c
|
|
512
|
+
where c.lease_token = p_lease_token
|
|
513
|
+
and c.agent_id = v_actor_agent_id
|
|
514
|
+
) then
|
|
515
|
+
raise exception 'claim not accessible to current agent' using errcode = '42501';
|
|
516
|
+
end if;
|
|
517
|
+
end if;
|
|
518
|
+
|
|
519
|
+
-- CLAIMGATE/ENFORCE (#133): re-authorize + horizon for the predicate-gated kinds. Read the
|
|
520
|
+
-- live lease's resource identity (SECURITY DEFINER, so RLS does not hide it). A dead/expired
|
|
521
|
+
-- lease yields a null kind and the UPDATE below matches nothing (preserves prior behavior).
|
|
522
|
+
select c.resource_kind, c.resource_key
|
|
523
|
+
into v_kind, v_key
|
|
524
|
+
from agent_board.claims c
|
|
525
|
+
where c.lease_token = p_lease_token
|
|
526
|
+
and c.status = 'active'
|
|
527
|
+
and c.released_at is null
|
|
528
|
+
and c.lease_expires_at > now();
|
|
529
|
+
|
|
530
|
+
-- RE-AUTH: bound revocation latency to one lease term. Uses the SAME session wrapper acquire
|
|
531
|
+
-- consults (_can_claim_resource -> _can_claim_resource_for(current_agent_id())), so service_role
|
|
532
|
+
-- keeps its carve-out and acquire+renew share ONE per-kind flag (a mutation splitting the flags
|
|
533
|
+
-- fails a test). Task-renew is EXCLUDED (ownership-only, ratified supersession).
|
|
534
|
+
if v_kind in ('machine', 'repository', 'repo_file')
|
|
535
|
+
and agent_board._claimgate_kind_enforced(v_kind) then
|
|
536
|
+
if not agent_board._can_claim_resource(v_kind, agent_board._canonical_claim_key(v_kind, v_key)) then
|
|
537
|
+
raise exception 'not authorized to renew this % claim (fleet authority revoked)', v_kind
|
|
538
|
+
using errcode = '42501';
|
|
539
|
+
end if;
|
|
540
|
+
end if;
|
|
541
|
+
|
|
542
|
+
-- HORIZON: clamp the renewed lease to now()+horizon for an ENFORCED fleet-gated kind — this is
|
|
543
|
+
-- where an existing overlong lease reconciles (no bulk-clamp migration; the horizon clamps it
|
|
544
|
+
-- at its next renew). service_role exempt. Same per-kind flag as acquire.
|
|
545
|
+
v_effective_expires := p_lease_expires_at;
|
|
546
|
+
if v_kind in ('machine', 'repository', 'repo_file')
|
|
547
|
+
and not agent_board._is_service_role()
|
|
548
|
+
and agent_board._claimgate_kind_enforced(v_kind)
|
|
549
|
+
and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
|
|
550
|
+
v_effective_expires := now() + agent_board._claimgate_max_lease_horizon();
|
|
551
|
+
end if;
|
|
552
|
+
|
|
553
|
+
-- CLAIMKEY (#133) MUST-FIX 1: re-canonicalize the STORED key as the lease is renewed, but
|
|
554
|
+
-- NEVER propagate a uniqueness collision. A check-then-act guard is NOT race-safe: two aliased
|
|
555
|
+
-- legacy rows renewed concurrently at READ COMMITTED can both observe the canonical key free
|
|
556
|
+
-- and one loses on claims_active_resource_unique (23505). So attempt the canonicalization and,
|
|
557
|
+
-- on unique_violation (deterministic alias OR concurrent race), retry ONCE keeping the CURRENT
|
|
558
|
+
-- key. That makes renew non-raising UNCONDITIONALLY (true normalize-only); the surviving legacy
|
|
559
|
+
-- key is the deferred dual-key alias the ENFORCE phase closes.
|
|
560
|
+
begin
|
|
561
|
+
return query
|
|
562
|
+
update agent_board.claims c
|
|
563
|
+
set lease_expires_at = v_effective_expires,
|
|
564
|
+
resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
|
|
565
|
+
updated_at = now()
|
|
566
|
+
where c.lease_token = p_lease_token
|
|
567
|
+
and c.status = 'active'
|
|
568
|
+
and c.released_at is null
|
|
569
|
+
and c.lease_expires_at > now()
|
|
570
|
+
returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
|
|
571
|
+
exception when unique_violation then
|
|
572
|
+
-- the canonical key is already held (by a committed twin or a concurrent renew/acquire):
|
|
573
|
+
-- renew the lease keeping the current key, aliasing deferred to ENFORCE. Only the key change
|
|
574
|
+
-- can raise unique_violation (the lease/timestamp columns are unconstrained), so the retry
|
|
575
|
+
-- cannot itself collide.
|
|
576
|
+
return query
|
|
577
|
+
update agent_board.claims c
|
|
578
|
+
set lease_expires_at = v_effective_expires,
|
|
579
|
+
updated_at = now()
|
|
580
|
+
where c.lease_token = p_lease_token
|
|
581
|
+
and c.status = 'active'
|
|
582
|
+
and c.released_at is null
|
|
583
|
+
and c.lease_expires_at > now()
|
|
584
|
+
returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
|
|
585
|
+
end;
|
|
586
|
+
end;
|
|
587
|
+
$$;
|
|
588
|
+
revoke all on function agent_board.renew_claim(uuid, timestamptz) from public, anon;
|
|
589
|
+
grant execute on function agent_board.renew_claim(uuid, timestamptz) to authenticated, service_role;
|
|
590
|
+
|
|
591
|
+
-- 6. _claimgate_promotion_blockers — PROMOTION-READINESS gate (panel must-fix, roadmap :346/:352).
|
|
592
|
+
-- Enforcement flags stay OPERATOR-GATED and DEFAULT OFF (there is NO auto-clamp / auto-migrate).
|
|
593
|
+
-- This is the VERIFICATION the roadmap's "reconcile before enforcement" (:346) and "zero
|
|
594
|
+
-- non-canonical fleet claims at flip" (:352) exit criteria require, made checkable WITHOUT
|
|
595
|
+
-- mutating any lease. For a fleet kind it returns every ACTIVE (status='active', unreleased,
|
|
596
|
+
-- unexpired) claim that would BREAK the instant enforce_<kind> flips ON:
|
|
597
|
+
-- (a) unauthorized — the CURRENT holder is no longer fleet-authorized for it, computed with
|
|
598
|
+
-- the actor-parameterized _can_claim_resource_for(agent_id, kind, key) (this is exactly
|
|
599
|
+
-- why that private twin exists — it evaluates an EXISTING holder, not the session actor);
|
|
600
|
+
-- (b) over_horizon — lease_expires_at exceeds now()+_claimgate_max_lease_horizon(), i.e. a
|
|
601
|
+
-- lease that would be clamped (and, for a lease that will never renew, would otherwise run
|
|
602
|
+
-- past the bound forever — the years-long-lease case);
|
|
603
|
+
-- (c) non_canonical — resource_key <> _canonical_claim_key(...), the dual-key aliasing window.
|
|
604
|
+
-- An EMPTY result for a kind == SAFE to flip that kind's flag.
|
|
605
|
+
--
|
|
606
|
+
-- =====================================================================================
|
|
607
|
+
-- PROMOTION RUNBOOK (operator, per fleet kind — machine, then repository, then repo_file)
|
|
608
|
+
-- =====================================================================================
|
|
609
|
+
-- 1. select * from agent_board._claimgate_promotion_blockers('<kind>'); -- as service_role
|
|
610
|
+
-- 2. For EACH row: reconcile it — release_claim / let it expire (unauthorized or over-horizon),
|
|
611
|
+
-- or renew_claim once to self-heal a non_canonical key (CLAIMKEY re-canonicalizes on renew).
|
|
612
|
+
-- 3. Re-run step 1; when it returns ZERO rows the kind is safe.
|
|
613
|
+
-- 4. Only then: insert into agent_board.claimgate_enforcement_flags(scope_key, enabled)
|
|
614
|
+
-- values ('enforce_<kind>', true) on conflict (scope_key) do update set enabled = true;
|
|
615
|
+
-- (The ownerless file/tool/custom guards remain INDEPENDENTLY flag-gated behind
|
|
616
|
+
-- claimgate_enforcement_flags('ownerless_guards') — OFF until the prod lease-length census;
|
|
617
|
+
-- they are NOT co-timed with fleet enforcement, per the maintainer decision, and this gate does
|
|
618
|
+
-- not cover them.)
|
|
619
|
+
--
|
|
620
|
+
-- #118: SECURITY DEFINER (reads all claims + the RLS-recursive fleet graph via _for) — service_role
|
|
621
|
+
-- ONLY (an operator census/oracle, same posture as _can_claim_resource_for); PUBLIC revoked.
|
|
622
|
+
create or replace function agent_board._claimgate_promotion_blockers(p_resource_kind text)
|
|
623
|
+
returns table(
|
|
624
|
+
claim_id uuid,
|
|
625
|
+
agent_id uuid,
|
|
626
|
+
resource_kind text,
|
|
627
|
+
resource_key text,
|
|
628
|
+
lease_expires_at timestamptz,
|
|
629
|
+
unauthorized boolean,
|
|
630
|
+
over_horizon boolean,
|
|
631
|
+
non_canonical boolean
|
|
632
|
+
)
|
|
633
|
+
language sql
|
|
634
|
+
stable
|
|
635
|
+
security definer
|
|
636
|
+
set search_path = ''
|
|
637
|
+
as $$
|
|
638
|
+
select c.id, c.agent_id, c.resource_kind, c.resource_key, c.lease_expires_at,
|
|
639
|
+
not agent_board._can_claim_resource_for(c.agent_id, c.resource_kind, c.resource_key) as unauthorized,
|
|
640
|
+
c.lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() as over_horizon,
|
|
641
|
+
c.resource_key is distinct from agent_board._canonical_claim_key(c.resource_kind, c.resource_key) as non_canonical
|
|
642
|
+
from agent_board.claims c
|
|
643
|
+
where c.resource_kind = p_resource_kind
|
|
644
|
+
and c.status = 'active'
|
|
645
|
+
and c.released_at is null
|
|
646
|
+
and c.lease_expires_at > now()
|
|
647
|
+
and (
|
|
648
|
+
not agent_board._can_claim_resource_for(c.agent_id, c.resource_kind, c.resource_key)
|
|
649
|
+
or c.lease_expires_at > now() + agent_board._claimgate_max_lease_horizon()
|
|
650
|
+
or c.resource_key is distinct from agent_board._canonical_claim_key(c.resource_kind, c.resource_key)
|
|
651
|
+
)
|
|
652
|
+
$$;
|
|
653
|
+
revoke all on function agent_board._claimgate_promotion_blockers(text) from public, anon;
|
|
654
|
+
grant execute on function agent_board._claimgate_promotion_blockers(text) to service_role;
|
|
655
|
+
comment on function agent_board._claimgate_promotion_blockers(text) is
|
|
656
|
+
'CLAIMGATE/ENFORCE (#133, roadmap :346/:352): promotion-readiness gate for a fleet kind. Returns the '
|
|
657
|
+
'ACTIVE claims that would BREAK at flip — unauthorized holder (via _can_claim_resource_for), '
|
|
658
|
+
'over the max-lease horizon, or a non-canonical key. EMPTY == safe to set enforce_<kind>=true. '
|
|
659
|
+
'Verification only (no lease is mutated; enforcement stays operator-gated, default OFF). '
|
|
660
|
+
'SECURITY DEFINER; service_role ONLY (operator census); PUBLIC revoked.';
|