@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,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;