@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,372 @@
|
|
|
1
|
+
-- CLAIMGATE Phase 1 — CLAIMKEY (#133): a frozen, fleet-fingerprint-carrying canonical
|
|
2
|
+
-- claim-key grammar so the SHADOW/ENFORCE authority predicate has stable identity to
|
|
3
|
+
-- resolve against. KEY FORMAT ONLY — no authorization, no predicate here.
|
|
4
|
+
--
|
|
5
|
+
-- =====================================================================================
|
|
6
|
+
-- IF-0-CLAIMKEY-1 — canonical claim-key grammar (FROZEN; SHADOW/ENFORCE consume it)
|
|
7
|
+
-- =====================================================================================
|
|
8
|
+
-- The active-resource uniqueness index is on (resource_kind, resource_key) — the
|
|
9
|
+
-- resource_kind column already namespaces the key space, so the canonical key MUST NOT
|
|
10
|
+
-- repeat the kind. (This freezes the divergence flagged by the design panel: the North
|
|
11
|
+
-- Star prose writes `machine:<fp>` / `repo_file:<fp>:<path>` with a kind prefix, but the
|
|
12
|
+
-- ratified amendment — "resource_kind already namespaces the uniqueness index, so bare
|
|
13
|
+
-- <fp> suffices" — governs. We take the internally-consistent kind-never-in-key reading.)
|
|
14
|
+
--
|
|
15
|
+
-- resource_kind canonical resource_key example
|
|
16
|
+
-- ------------- ----------------------------- ------------------------------------
|
|
17
|
+
-- machine <machine_fingerprint> fp_9a1c… (bare fp)
|
|
18
|
+
-- repository <repo_fingerprint> fp_repo_7b2… (bare fp)
|
|
19
|
+
-- repo_file <repo_fingerprint>:<path> fp_repo_7b2…:src/db.sql (fp, ':' , path)
|
|
20
|
+
-- task <task uuid> unchanged
|
|
21
|
+
-- file/tool/custom free-form unchanged (VERBATIM — not even trimmed)
|
|
22
|
+
--
|
|
23
|
+
-- repo_file parse rule (SHADOW's future predicate MUST extract fp from the same position):
|
|
24
|
+
-- the FIRST ':' splits the key — everything before it is the repo_fingerprint, everything
|
|
25
|
+
-- after it is the path (which may itself contain ':'). e.g. `fp_repo_7b2…:src/db.sql`
|
|
26
|
+
-- -> fp = `fp_repo_7b2…`, path = `src/db.sql`.
|
|
27
|
+
--
|
|
28
|
+
-- Legacy tolerance: an accidental leading `machine:` / `repository:` / `repo:` /
|
|
29
|
+
-- `repo_file:` prefix is stripped so a pre-CLAIMKEY prefixed key normalizes to the same
|
|
30
|
+
-- canonical value as its bare twin.
|
|
31
|
+
--
|
|
32
|
+
-- =====================================================================================
|
|
33
|
+
-- NORMALIZE-ONLY (design-panel amendment) — this phase NEVER rejects a non-canonical key.
|
|
34
|
+
-- =====================================================================================
|
|
35
|
+
-- Rejecting is a behavior change and belongs to ENFORCE. `_canonical_claim_key` is a pure,
|
|
36
|
+
-- deterministic string transform: it reads NO tables (fingerprint identity is validated by
|
|
37
|
+
-- the authority predicate in SHADOW/ENFORCE, not here). An unmappable key is shadow-logged
|
|
38
|
+
-- (RAISE NOTICE) and returned UNCHANGED, so CLAIMKEY is behavior-preserving for every key
|
|
39
|
+
-- already in flight.
|
|
40
|
+
--
|
|
41
|
+
-- Wired into acquire_claim (normalizes the supplied key before the sweep/insert; its sweep AND
|
|
42
|
+
-- conflict check are ALIAS-AWARE so a legacy row cannot be dual-held — MUST-FIX 3) AND
|
|
43
|
+
-- renew_claim + release_claim (self-heal the STORED key of an existing lease). renew attempts
|
|
44
|
+
-- canonicalization and, on a uniqueness collision (a committed alias twin OR a concurrent
|
|
45
|
+
-- renew/acquire race at READ COMMITTED), retries ONCE keeping the current key — non-raising
|
|
46
|
+
-- UNCONDITIONALLY, not merely "by construction" (MUST-FIX 1; a check-then-act guard is not
|
|
47
|
+
-- race-safe). The surviving legacy key is the deferred dual-key alias ENFORCE closes. release
|
|
48
|
+
-- always canonicalizes — the released tuple leaves the partial unique index, so it can never collide.
|
|
49
|
+
--
|
|
50
|
+
-- resource_key WRITER CLASS (catalog-derived, repo rule #1): at final replay state the only
|
|
51
|
+
-- function that INSERTs resource_key is acquire_claim below; renew_claim/release_claim are
|
|
52
|
+
-- the only functions that UPDATE it (added here). The completeness test asserts this
|
|
53
|
+
-- POSITIVELY from pg_proc (every claims-key writer routes through _canonical_claim_key).
|
|
54
|
+
--
|
|
55
|
+
-- #118 class: _canonical_claim_key is SECURITY DEFINER — revoke PUBLIC + grant + comment in
|
|
56
|
+
-- THIS migration. Additive, forward-only. schema_version stays 8.
|
|
57
|
+
|
|
58
|
+
-- =====================================================================================
|
|
59
|
+
-- 0. Census (one-shot, non-canonical keys per kind) — run against a target DB to confirm
|
|
60
|
+
-- the migration set is bounded before ENFORCE. Committed as the report artifact for the
|
|
61
|
+
-- CLAIMKEY exit criterion; on a fresh local replay it returns zero rows (no legacy keys).
|
|
62
|
+
--
|
|
63
|
+
-- select resource_kind,
|
|
64
|
+
-- count(*) as active_claims,
|
|
65
|
+
-- count(*) filter (
|
|
66
|
+
-- where resource_key
|
|
67
|
+
-- is distinct from agent_board._canonical_claim_key(resource_kind, resource_key)
|
|
68
|
+
-- ) as non_canonical,
|
|
69
|
+
-- array_agg(distinct resource_key) filter (
|
|
70
|
+
-- where resource_key
|
|
71
|
+
-- is distinct from agent_board._canonical_claim_key(resource_kind, resource_key)
|
|
72
|
+
-- ) as sample_non_canonical_keys
|
|
73
|
+
-- from agent_board.claims
|
|
74
|
+
-- where status = 'active' and released_at is null
|
|
75
|
+
-- group by resource_kind
|
|
76
|
+
-- order by resource_kind;
|
|
77
|
+
-- =====================================================================================
|
|
78
|
+
|
|
79
|
+
-- 1. _canonical_claim_key — the sole normalizer (IF-0-CLAIMKEY-1) ---------------------
|
|
80
|
+
create or replace function agent_board._canonical_claim_key(
|
|
81
|
+
p_resource_kind text,
|
|
82
|
+
p_resource_key text
|
|
83
|
+
)
|
|
84
|
+
returns text
|
|
85
|
+
language plpgsql
|
|
86
|
+
security definer
|
|
87
|
+
set search_path to 'agent_board', 'public'
|
|
88
|
+
immutable
|
|
89
|
+
as $function$
|
|
90
|
+
declare
|
|
91
|
+
v_key text;
|
|
92
|
+
v_fp text;
|
|
93
|
+
v_path text;
|
|
94
|
+
v_sep int;
|
|
95
|
+
begin
|
|
96
|
+
-- Pure deterministic string transform. NORMALIZE-ONLY: never raises; an unmappable key is
|
|
97
|
+
-- shadow-logged and returned unchanged (trimmed) so behavior is preserved.
|
|
98
|
+
if p_resource_key is null then
|
|
99
|
+
return p_resource_key;
|
|
100
|
+
end if;
|
|
101
|
+
v_key := btrim(p_resource_key);
|
|
102
|
+
|
|
103
|
+
if p_resource_kind = 'machine' then
|
|
104
|
+
-- canonical: bare <machine_fingerprint>; tolerate a legacy 'machine:' prefix.
|
|
105
|
+
v_key := btrim(regexp_replace(v_key, '^machine:', ''));
|
|
106
|
+
if v_key = '' then
|
|
107
|
+
raise notice 'claimgate/CLAIMKEY: unmappable machine claim key %', p_resource_key;
|
|
108
|
+
return btrim(p_resource_key);
|
|
109
|
+
end if;
|
|
110
|
+
return v_key;
|
|
111
|
+
|
|
112
|
+
elsif p_resource_kind = 'repository' then
|
|
113
|
+
-- canonical: bare <repo_fingerprint>; tolerate a legacy 'repository:'/'repo:' prefix.
|
|
114
|
+
v_key := btrim(regexp_replace(v_key, '^(repository|repo):', ''));
|
|
115
|
+
if v_key = '' then
|
|
116
|
+
raise notice 'claimgate/CLAIMKEY: unmappable repository claim key %', p_resource_key;
|
|
117
|
+
return btrim(p_resource_key);
|
|
118
|
+
end if;
|
|
119
|
+
return v_key;
|
|
120
|
+
|
|
121
|
+
elsif p_resource_kind = 'repo_file' then
|
|
122
|
+
-- canonical: <repo_fingerprint>:<path>; tolerate a legacy 'repo_file:' prefix. The FIRST
|
|
123
|
+
-- ':' splits fp | path (path may contain further ':').
|
|
124
|
+
v_key := btrim(regexp_replace(v_key, '^repo_file:', ''));
|
|
125
|
+
v_sep := position(':' in v_key);
|
|
126
|
+
if v_sep <= 1 or v_sep = length(v_key) then
|
|
127
|
+
-- no fp/path split (or an empty side) — cannot canonicalize; leave intact.
|
|
128
|
+
raise notice 'claimgate/CLAIMKEY: unmappable repo_file claim key %', p_resource_key;
|
|
129
|
+
return btrim(p_resource_key);
|
|
130
|
+
end if;
|
|
131
|
+
v_fp := btrim(substring(v_key from 1 for v_sep - 1));
|
|
132
|
+
v_path := btrim(substring(v_key from v_sep + 1));
|
|
133
|
+
if v_fp = '' or v_path = '' then
|
|
134
|
+
raise notice 'claimgate/CLAIMKEY: unmappable repo_file claim key %', p_resource_key;
|
|
135
|
+
return btrim(p_resource_key);
|
|
136
|
+
end if;
|
|
137
|
+
return v_fp || ':' || v_path;
|
|
138
|
+
|
|
139
|
+
else
|
|
140
|
+
-- task / file / tool / custom: identity intact — pass through VERBATIM (no trim).
|
|
141
|
+
-- Pre-CLAIMKEY acquire stored these keys unmodified, so " x " and "x" are DISTINCT
|
|
142
|
+
-- resources; trimming would silently alias them (SHOULD-FIX 4, codex). Only the
|
|
143
|
+
-- fleet-backed kinds above are canonicalized.
|
|
144
|
+
return p_resource_key;
|
|
145
|
+
end if;
|
|
146
|
+
end;
|
|
147
|
+
$function$;
|
|
148
|
+
revoke all on function agent_board._canonical_claim_key(text, text) from public, anon;
|
|
149
|
+
grant execute on function agent_board._canonical_claim_key(text, text) to authenticated, service_role;
|
|
150
|
+
comment on function agent_board._canonical_claim_key(text, text) is
|
|
151
|
+
'CLAIMGATE/CLAIMKEY (#133, IF-0-CLAIMKEY-1): sole canonical claim-key normalizer. '
|
|
152
|
+
'machine/repository -> bare <fingerprint>; repo_file -> <fp>:<path> (first colon splits); '
|
|
153
|
+
'task/file/tool/custom pass through VERBATIM (no trim). NORMALIZE-ONLY — never rejects; unmappable keys are '
|
|
154
|
+
'shadow-logged (NOTICE) and returned unchanged. Pure string transform, reads no tables.';
|
|
155
|
+
|
|
156
|
+
-- 2. acquire_claim — normalize the supplied key before the sweep/insert ----------------
|
|
157
|
+
-- Reproduced verbatim from 20260817000100 (OBSGATE-4) except the CLAIMKEY normalize line.
|
|
158
|
+
create or replace function agent_board.acquire_claim(
|
|
159
|
+
p_agent_id uuid,
|
|
160
|
+
p_resource_kind text,
|
|
161
|
+
p_resource_key text,
|
|
162
|
+
p_lease_expires_at timestamp with time zone,
|
|
163
|
+
p_task_id uuid default null,
|
|
164
|
+
p_thread_id uuid default null,
|
|
165
|
+
p_metadata jsonb default '{}'::jsonb
|
|
166
|
+
)
|
|
167
|
+
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)
|
|
168
|
+
language plpgsql
|
|
169
|
+
security definer
|
|
170
|
+
set search_path to 'agent_board', 'public'
|
|
171
|
+
as $function$
|
|
172
|
+
declare
|
|
173
|
+
v_thread_id uuid;
|
|
174
|
+
v_claim_id uuid;
|
|
175
|
+
v_actor_agent_id uuid;
|
|
176
|
+
begin
|
|
177
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
178
|
+
|
|
179
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
180
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
181
|
+
end if;
|
|
182
|
+
|
|
183
|
+
-- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
|
|
184
|
+
-- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
|
|
185
|
+
-- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
|
|
186
|
+
p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
|
|
187
|
+
|
|
188
|
+
if p_task_id is not null then
|
|
189
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
190
|
+
if v_thread_id is null then
|
|
191
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
192
|
+
end if;
|
|
193
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
194
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
195
|
+
end if;
|
|
196
|
+
else
|
|
197
|
+
v_thread_id := p_thread_id;
|
|
198
|
+
end if;
|
|
199
|
+
|
|
200
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
201
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
202
|
+
end if;
|
|
203
|
+
|
|
204
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
205
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
206
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
207
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
208
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
209
|
+
-- with a uuid-keyed legitimate claim.
|
|
210
|
+
--
|
|
211
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
212
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
213
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
214
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
215
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
216
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
217
|
+
if p_resource_kind = 'task'
|
|
218
|
+
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
|
|
219
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
220
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
221
|
+
end if;
|
|
222
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
223
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
224
|
+
using errcode = '42501';
|
|
225
|
+
end if;
|
|
226
|
+
end if;
|
|
227
|
+
|
|
228
|
+
-- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
|
|
229
|
+
-- is already canonical (above); comparing against the CANONICAL form of each existing row's key
|
|
230
|
+
-- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
|
|
231
|
+
-- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
|
|
232
|
+
-- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
|
|
233
|
+
-- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
|
|
234
|
+
-- live census.
|
|
235
|
+
update agent_board.claims c
|
|
236
|
+
set status = 'expired',
|
|
237
|
+
updated_at = now()
|
|
238
|
+
where c.resource_kind = p_resource_kind
|
|
239
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
|
|
240
|
+
and c.status = 'active'
|
|
241
|
+
and c.released_at is null
|
|
242
|
+
and c.lease_expires_at <= now();
|
|
243
|
+
|
|
244
|
+
if exists (
|
|
245
|
+
select 1 from agent_board.claims
|
|
246
|
+
where claims.resource_kind = p_resource_kind
|
|
247
|
+
and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
|
|
248
|
+
and claims.status = 'active'
|
|
249
|
+
and claims.released_at is null
|
|
250
|
+
and claims.lease_expires_at > now()
|
|
251
|
+
) then
|
|
252
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
253
|
+
end if;
|
|
254
|
+
|
|
255
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
256
|
+
values (p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at, coalesce(p_metadata, '{}'::jsonb))
|
|
257
|
+
returning id into v_claim_id;
|
|
258
|
+
|
|
259
|
+
if p_task_id is not null then
|
|
260
|
+
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));
|
|
261
|
+
end if;
|
|
262
|
+
|
|
263
|
+
return query
|
|
264
|
+
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
|
|
265
|
+
from agent_board.claims c
|
|
266
|
+
where c.id = v_claim_id;
|
|
267
|
+
end;
|
|
268
|
+
$function$;
|
|
269
|
+
revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
|
|
270
|
+
grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
|
|
271
|
+
|
|
272
|
+
-- 3. renew_claim — self-heal the STORED key, non-raising UNCONDITIONALLY (retry-once) ----
|
|
273
|
+
-- Reproduced verbatim from 20260512000001 except the CLAIMKEY re-canonicalization.
|
|
274
|
+
create or replace function agent_board.renew_claim(p_lease_token uuid, p_lease_expires_at timestamptz)
|
|
275
|
+
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)
|
|
276
|
+
language plpgsql
|
|
277
|
+
security definer
|
|
278
|
+
set search_path = agent_board, public
|
|
279
|
+
as $$
|
|
280
|
+
declare
|
|
281
|
+
v_actor_agent_id uuid;
|
|
282
|
+
begin
|
|
283
|
+
if not agent_board._is_service_role() then
|
|
284
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
|
|
285
|
+
if not exists (
|
|
286
|
+
select 1
|
|
287
|
+
from agent_board.claims c
|
|
288
|
+
where c.lease_token = p_lease_token
|
|
289
|
+
and c.agent_id = v_actor_agent_id
|
|
290
|
+
) then
|
|
291
|
+
raise exception 'claim not accessible to current agent' using errcode = '42501';
|
|
292
|
+
end if;
|
|
293
|
+
end if;
|
|
294
|
+
|
|
295
|
+
-- CLAIMKEY (#133) MUST-FIX 1: re-canonicalize the STORED key as the lease is renewed, but
|
|
296
|
+
-- NEVER propagate a uniqueness collision. A check-then-act guard is NOT race-safe: two aliased
|
|
297
|
+
-- legacy rows renewed concurrently at READ COMMITTED can both observe the canonical key free
|
|
298
|
+
-- and one loses on claims_active_resource_unique (23505). So attempt the canonicalization and,
|
|
299
|
+
-- on unique_violation (deterministic alias OR concurrent race), retry ONCE keeping the CURRENT
|
|
300
|
+
-- key. That makes renew non-raising UNCONDITIONALLY (true normalize-only); the surviving legacy
|
|
301
|
+
-- key is the deferred dual-key alias the ENFORCE phase closes.
|
|
302
|
+
begin
|
|
303
|
+
return query
|
|
304
|
+
update agent_board.claims c
|
|
305
|
+
set lease_expires_at = p_lease_expires_at,
|
|
306
|
+
resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
|
|
307
|
+
updated_at = now()
|
|
308
|
+
where c.lease_token = p_lease_token
|
|
309
|
+
and c.status = 'active'
|
|
310
|
+
and c.released_at is null
|
|
311
|
+
and c.lease_expires_at > now()
|
|
312
|
+
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;
|
|
313
|
+
exception when unique_violation then
|
|
314
|
+
-- the canonical key is already held (by a committed twin or a concurrent renew/acquire):
|
|
315
|
+
-- renew the lease keeping the current key, aliasing deferred to ENFORCE. Only the key change
|
|
316
|
+
-- can raise unique_violation (the lease/timestamp columns are unconstrained), so the retry
|
|
317
|
+
-- cannot itself collide.
|
|
318
|
+
return query
|
|
319
|
+
update agent_board.claims c
|
|
320
|
+
set lease_expires_at = p_lease_expires_at,
|
|
321
|
+
updated_at = now()
|
|
322
|
+
where c.lease_token = p_lease_token
|
|
323
|
+
and c.status = 'active'
|
|
324
|
+
and c.released_at is null
|
|
325
|
+
and c.lease_expires_at > now()
|
|
326
|
+
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;
|
|
327
|
+
end;
|
|
328
|
+
end;
|
|
329
|
+
$$;
|
|
330
|
+
revoke all on function agent_board.renew_claim(uuid, timestamptz) from public, anon;
|
|
331
|
+
grant execute on function agent_board.renew_claim(uuid, timestamptz) to authenticated, service_role;
|
|
332
|
+
|
|
333
|
+
-- 4. release_claim — canonicalize the STORED key (always safe; row leaves the index) ----
|
|
334
|
+
-- Reproduced verbatim from 20260512000001 except the CLAIMKEY re-canonicalization line.
|
|
335
|
+
create or replace function agent_board.release_claim(p_lease_token uuid)
|
|
336
|
+
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)
|
|
337
|
+
language plpgsql
|
|
338
|
+
security definer
|
|
339
|
+
set search_path = agent_board, public
|
|
340
|
+
as $$
|
|
341
|
+
declare
|
|
342
|
+
v_actor_agent_id uuid;
|
|
343
|
+
begin
|
|
344
|
+
if not agent_board._is_service_role() then
|
|
345
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
|
|
346
|
+
if not exists (
|
|
347
|
+
select 1
|
|
348
|
+
from agent_board.claims c
|
|
349
|
+
where c.lease_token = p_lease_token
|
|
350
|
+
and c.agent_id = v_actor_agent_id
|
|
351
|
+
) then
|
|
352
|
+
raise exception 'claim not accessible to current agent' using errcode = '42501';
|
|
353
|
+
end if;
|
|
354
|
+
end if;
|
|
355
|
+
|
|
356
|
+
-- CLAIMKEY (#133): canonicalize the STORED key on release. The tuple transitions to
|
|
357
|
+
-- status='released', leaving the claims_active_resource_unique partial index, so this can
|
|
358
|
+
-- never collide — normalize unconditionally.
|
|
359
|
+
return query
|
|
360
|
+
update agent_board.claims c
|
|
361
|
+
set status = 'released',
|
|
362
|
+
released_at = now(),
|
|
363
|
+
resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
|
|
364
|
+
updated_at = now()
|
|
365
|
+
where c.lease_token = p_lease_token
|
|
366
|
+
and c.status = 'active'
|
|
367
|
+
and c.released_at is null
|
|
368
|
+
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;
|
|
369
|
+
end;
|
|
370
|
+
$$;
|
|
371
|
+
revoke all on function agent_board.release_claim(uuid) from public, anon;
|
|
372
|
+
grant execute on function agent_board.release_claim(uuid) to authenticated, service_role;
|