@consiliency/agent-board-schema 1.10.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,443 @@
1
+ -- CLAIMGATE Phase 2 — SHADOW (#133): fleet-graph authority predicate + shadow stamping.
2
+ -- BLOCKS NOTHING on authority. Introduces `_can_claim_resource(kind, key)` (IF-0-SHADOW-1,
3
+ -- the frozen signature ENFORCE consumes unchanged), computes it in `acquire_claim`, and stamps
4
+ -- the verdict into `claims.metadata.claimgate_shadow = {kind, authorized}` so we get a live
5
+ -- authorized-vs-denied census BEFORE any enforcement.
6
+ --
7
+ -- This phase carries the OWNERLESS-tail guard (file/tool/custom, which has no fleet authority to
8
+ -- gate it): a per-agent active-claim QUOTA + a max-lease-horizon TTL CAP, in `acquire_claim` (NOT
9
+ -- in the predicate — the predicate consults ONLY the fleet graph; see NEGATIVE RULE below). Per
10
+ -- the design-panel amendment (specs/phase-plans-v10.md:329) the logic lives in SHADOW, but per the
11
+ -- census amendment (:333) it ships GATED OFF: the guard is behind `_claimgate_ownerless_guards_
12
+ -- enabled()`, which DEFAULTS FALSE, so SHADOW literally blocks NOTHING until an operator enables
13
+ -- it after the prod lease-length census confirms the cap is safe. (Maintainer decision 2026-07-31,
14
+ -- resolving codex's must-fix: enforcing 30d/50 unconditionally could reject a legit long-lease
15
+ -- consumer before the census.) The serialization + cap/quota code is in place for the flip.
16
+ --
17
+ -- =====================================================================================
18
+ -- AUTHORITY MODEL (fleet graph ONLY — NEGATIVE RULE: no board-admin fallback, EVER)
19
+ -- =====================================================================================
20
+ -- service_role -> true (operator carve-out; short-circuit).
21
+ -- machine (key = <machine_fingerprint>, bare per IF-0-CLAIMKEY-1)
22
+ -- authorized iff an ACTIVE fleet_machines row with that fingerprint exists in an ACTIVE
23
+ -- trust domain AND the actor either OWNS it (fleet_machines.owner_agent_id) or holds an
24
+ -- ACTIVE fleet_machine_agents binding on it. RATIFIED identity invariant: machine_fingerprint
25
+ -- is GLOBALLY UNIQUE (idx_agent_board_fleet_machines_fingerprint_unique) — so `<fp>` is
26
+ -- unambiguous; no owner-in-key hedge.
27
+ -- repository (key = <repo_fingerprint>) / repo_file (key = <repo_fingerprint>:<path>; the
28
+ -- FIRST ':' splits fp|path — same parse position CLAIMKEY froze)
29
+ -- authorized iff the actor holds an ACTIVE fleet_machine_agents binding whose repo_scope_id
30
+ -- resolves to an ACTIVE fleet_repo_scopes with repo_fingerprint = fp AND scope_role <>
31
+ -- 'observer', AND the FULL chain up from that scope is lifecycle-active at EVERY edge:
32
+ -- repo_scope -> harness_registration -> machine -> trust_domain.
33
+ -- CRITICAL PANEL AMENDMENT (specs/phase-plans-v10.md:323-328): revoke_fleet_subject('harness',…)
34
+ -- does NOT cascade to child repo-scopes, so a harness-only revoke leaves the scope 'active'
35
+ -- yet the harness dead — the chain walk MUST re-check fhr.lifecycle_state='active' or a
36
+ -- harness-revoked actor is wrongly authorized. RATIFIED: repo_fingerprint is NOT globally
37
+ -- unique, so authority over `<fp>` is the UNION of all non-observer scope holders across ALL
38
+ -- trust domains (shared-lock model) — the predicate does NOT scope by domain in the key.
39
+ -- task -> true (governed by acquire_claim's existing task gate).
40
+ -- file / tool / custom -> true (ownerless tail; governed by the TTL cap + quota
41
+ -- in acquire_claim, NOT by fleet authority).
42
+ --
43
+ -- SHADOW SEMANTICS: the predicate returns the HONEST verdict (a null/unresolved principal or a
44
+ -- non-fleet actor on a fleet-backed kind returns FALSE), but acquire_claim only STAMPS it — it
45
+ -- does not block on it. Fail-closed-on-unknown-KIND is an ENFORCE concern; here the catalog is
46
+ -- fully enumerated (7 kinds, all with an arm) and the defensive else returns true (blocks nothing).
47
+ --
48
+ -- #118 class (CLAUDE.md schema-mechanics): every NEWLY-DEFINED SECURITY DEFINER function below
49
+ -- restates `revoke ... from public, anon`, `grant ... to authenticated/service_role`, and a
50
+ -- `comment` in THIS migration. acquire_claim is a create-or-replace of an existing function, so
51
+ -- it preserves its prior ACL/comment and restates only revoke+grant (it carries no historical
52
+ -- comment to restate). Additive, forward-only. schema_version stays 8.
53
+ --
54
+ -- =====================================================================================
55
+ -- OWNERLESS-TAIL CENSUS (one-shot; run against the TARGET DB before relying on the TTL cap)
56
+ -- =====================================================================================
57
+ -- The TTL cap must not silently break a live long-lease consumer. This DB (local replay) has no
58
+ -- live claims; the maintainer runs this against the prod project to confirm the lease-length
59
+ -- distribution sits under the cap. This census GATES ENABLING the flag: only after it confirms the
60
+ -- distribution is safe does an operator set claimgate_enforcement_flags('ownerless_guards', true)
61
+ -- (or ENFORCE flip it with ratified values). Until then the guard is OFF and blocks nothing.
62
+ -- (Committed as the artifact for the SHADOW census exit criterion, mirroring CLAIMKEY's census.)
63
+ --
64
+ -- select resource_kind,
65
+ -- count(*) as active_claims,
66
+ -- min(lease_expires_at - now()) as min_remaining,
67
+ -- max(lease_expires_at - now()) as max_remaining,
68
+ -- percentile_disc(0.5) within group (order by lease_expires_at - now()) as p50_remaining,
69
+ -- percentile_disc(0.95) within group (order by lease_expires_at - now()) as p95_remaining,
70
+ -- count(*) filter (
71
+ -- where lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap()
72
+ -- ) as over_cap
73
+ -- from agent_board.claims
74
+ -- where status = 'active' and released_at is null
75
+ -- and resource_kind in ('file', 'tool', 'custom')
76
+ -- group by resource_kind
77
+ -- order by resource_kind;
78
+ -- =====================================================================================
79
+
80
+ -- 0. Single-source policy constants for the ownerless tail. Immutable so the migration logic
81
+ -- AND the tests/isolation specs reference ONE value; ENFORCE tunes them here (and the max
82
+ -- horizon amendment extends the cap to acquire+renew for all non-task kinds). The chosen
83
+ -- defaults are GENEROUS placeholders pending the prod census above — flagged as a design
84
+ -- question, not a settled policy number.
85
+ create or replace function agent_board._claimgate_ownerless_ttl_cap()
86
+ returns interval
87
+ language sql
88
+ immutable
89
+ set search_path = ''
90
+ as $$ select interval '30 days' $$;
91
+ revoke all on function agent_board._claimgate_ownerless_ttl_cap() from public, anon;
92
+ grant execute on function agent_board._claimgate_ownerless_ttl_cap() to authenticated, service_role;
93
+ comment on function agent_board._claimgate_ownerless_ttl_cap() is
94
+ 'CLAIMGATE/SHADOW (#133): max lease horizon for an ownerless (file/tool/custom) claim at acquire. '
95
+ 'Single source for the migration logic and its tests. GENEROUS placeholder (30d) pending prod census; '
96
+ 'ENFORCE ratifies the final value and extends the horizon to renew for all non-task kinds.';
97
+
98
+ create or replace function agent_board._claimgate_ownerless_quota()
99
+ returns integer
100
+ language sql
101
+ immutable
102
+ set search_path = ''
103
+ as $$ select 50 $$;
104
+ revoke all on function agent_board._claimgate_ownerless_quota() from public, anon;
105
+ grant execute on function agent_board._claimgate_ownerless_quota() to authenticated, service_role;
106
+ comment on function agent_board._claimgate_ownerless_quota() is
107
+ 'CLAIMGATE/SHADOW (#133): max concurrent ACTIVE ownerless (file/tool/custom) claims per agent. '
108
+ 'Single source for the migration logic and its tests. GENEROUS placeholder (50) pending prod census.';
109
+
110
+ -- 0b. Enforcement gate — OFF BY DEFAULT (SHADOW blocks nothing). ------------------------
111
+ -- The roadmap (phase-plans-v10.md:333) requires the live lease-length census BEFORE the TTL cap
112
+ -- is enabled, and the census is operator-owned (prod-only). So the ownerless TTL cap + quota are
113
+ -- gated behind this flag, which DEFAULTS OFF (absent row => disabled), mirroring the send-authz
114
+ -- config-flag pattern (20260718000100, which defaults fail-open). While OFF, acquire_claim does
115
+ -- NOT enforce the cap or quota — SHADOW blocks NOTHING; only the shadow-stamp remains. The
116
+ -- operator flips it ON post-census (or ENFORCE flips it with ratified values); the advisory-lock
117
+ -- serialization and the cap/quota logic below are already in place for that moment.
118
+ create table if not exists agent_board.claimgate_enforcement_flags (
119
+ scope_key text primary key,
120
+ enabled boolean not null default false,
121
+ updated_at timestamptz not null default now()
122
+ );
123
+ comment on table agent_board.claimgate_enforcement_flags is
124
+ 'CLAIMGATE per-scope enforcement flags. Absent row => disabled (SHADOW default: blocks nothing). '
125
+ 'scope_key ''ownerless_guards'' gates the ownerless (file/tool/custom) TTL cap + per-agent quota in '
126
+ 'acquire_claim. Operator-managed (service_role); enabled only after the prod lease-length census.';
127
+ alter table agent_board.claimgate_enforcement_flags enable row level security;
128
+ grant select, insert, update, delete on agent_board.claimgate_enforcement_flags to service_role;
129
+ drop policy if exists agent_board_service_all_claimgate_enforcement_flags on agent_board.claimgate_enforcement_flags;
130
+ create policy agent_board_service_all_claimgate_enforcement_flags
131
+ on agent_board.claimgate_enforcement_flags
132
+ as permissive for all to service_role using (true) with check (true);
133
+
134
+ create or replace function agent_board._claimgate_ownerless_guards_enabled()
135
+ returns boolean
136
+ language sql
137
+ stable
138
+ security definer
139
+ set search_path = ''
140
+ as $$
141
+ select coalesce(
142
+ (select f.enabled
143
+ from agent_board.claimgate_enforcement_flags f
144
+ where f.scope_key = 'ownerless_guards'),
145
+ false)
146
+ $$;
147
+ revoke all on function agent_board._claimgate_ownerless_guards_enabled() from public, anon;
148
+ grant execute on function agent_board._claimgate_ownerless_guards_enabled() to authenticated, service_role;
149
+ comment on function agent_board._claimgate_ownerless_guards_enabled() is
150
+ 'CLAIMGATE/SHADOW (#133): are the ownerless (file/tool/custom) TTL cap + quota ENABLED? '
151
+ 'Reads claimgate_enforcement_flags(''ownerless_guards''); DEFAULTS FALSE (no row) so SHADOW blocks '
152
+ 'nothing until an operator enables it post-census. SECURITY DEFINER; PUBLIC EXECUTE revoked.';
153
+
154
+ -- 1. _can_claim_resource — the frozen SHADOW authority predicate (IF-0-SHADOW-1) -----------
155
+ -- SECURITY DEFINER to break fleet-table RLS recursion (the fleet SELECT policies reference
156
+ -- each other). Session-derived actor via current_agent_id(); the actor-parameterized private
157
+ -- twin (for censusing EXISTING held claims) is an ENFORCE requirement, deferred.
158
+ create or replace function agent_board._can_claim_resource(
159
+ p_resource_kind text,
160
+ p_resource_key text
161
+ )
162
+ returns boolean
163
+ language plpgsql
164
+ stable
165
+ security definer
166
+ set search_path = 'agent_board', 'public'
167
+ as $function$
168
+ declare
169
+ v_actor uuid;
170
+ v_fp text;
171
+ v_sep int;
172
+ begin
173
+ -- Operator carve-out: service_role short-circuits authorized (matches every other gate).
174
+ if agent_board._is_service_role() then
175
+ return true;
176
+ end if;
177
+
178
+ v_actor := agent_board.current_agent_id();
179
+ -- An unresolved principal is honestly UNAUTHORIZED on any fleet-backed kind. (Blocks nothing
180
+ -- in SHADOW — acquire_claim only stamps this verdict.) The ownerless tail + task are not
181
+ -- fleet-gated, so they remain authorized even with a null actor.
182
+ if p_resource_kind = 'task' then
183
+ return true;
184
+ elsif p_resource_kind in ('file', 'tool', 'custom') then
185
+ return true;
186
+ end if;
187
+
188
+ if v_actor is null then
189
+ return false;
190
+ end if;
191
+
192
+ if p_resource_kind = 'machine' then
193
+ v_fp := btrim(p_resource_key);
194
+ if v_fp is null or v_fp = '' then
195
+ return false;
196
+ end if;
197
+ -- machine_fingerprint is globally unique; the machine AND its trust domain must be active,
198
+ -- and the actor must own it OR hold an active binding on it.
199
+ return exists (
200
+ select 1
201
+ from agent_board.fleet_machines fm
202
+ join agent_board.fleet_trust_domains ftd
203
+ on ftd.id = fm.trust_domain_id
204
+ where fm.machine_fingerprint = v_fp
205
+ and fm.lifecycle_state = 'active'
206
+ and ftd.lifecycle_state = 'active'
207
+ and (
208
+ fm.owner_agent_id = v_actor
209
+ or exists (
210
+ select 1
211
+ from agent_board.fleet_machine_agents fma
212
+ where fma.machine_id = fm.id
213
+ and fma.agent_id = v_actor
214
+ and fma.lifecycle_state = 'active'
215
+ )
216
+ )
217
+ );
218
+
219
+ elsif p_resource_kind = 'repository' or p_resource_kind = 'repo_file' then
220
+ if p_resource_kind = 'repo_file' then
221
+ -- FIRST ':' splits fp | path (the exact position CLAIMKEY froze). No colon / empty fp =
222
+ -- not resolvable -> deny (honest verdict; blocks nothing in SHADOW).
223
+ v_sep := position(':' in coalesce(p_resource_key, ''));
224
+ if v_sep <= 1 then
225
+ return false;
226
+ end if;
227
+ v_fp := btrim(substring(p_resource_key from 1 for v_sep - 1));
228
+ else
229
+ v_fp := btrim(p_resource_key);
230
+ end if;
231
+ if v_fp is null or v_fp = '' then
232
+ return false;
233
+ end if;
234
+ -- FULL chain, EVERY edge lifecycle-active (PANEL AMENDMENT: harness revoke does not cascade
235
+ -- to child scopes, so fhr.lifecycle_state='active' is load-bearing here). Anchored on the
236
+ -- actor's active binding + its repo_scope; UNION across trust domains (shared-lock model).
237
+ return exists (
238
+ select 1
239
+ from agent_board.fleet_machine_agents fma
240
+ join agent_board.fleet_repo_scopes frs
241
+ on frs.id = fma.repo_scope_id
242
+ join agent_board.fleet_harness_registrations fhr
243
+ on fhr.id = frs.harness_registration_id
244
+ join agent_board.fleet_machines fm
245
+ on fm.id = fhr.machine_id
246
+ join agent_board.fleet_trust_domains ftd
247
+ on ftd.id = fm.trust_domain_id
248
+ where fma.agent_id = v_actor
249
+ and fma.lifecycle_state = 'active'
250
+ and frs.repo_fingerprint = v_fp
251
+ and frs.scope_role <> 'observer'
252
+ and frs.lifecycle_state = 'active'
253
+ and fhr.lifecycle_state = 'active'
254
+ and fm.lifecycle_state = 'active'
255
+ and ftd.lifecycle_state = 'active'
256
+ );
257
+
258
+ else
259
+ -- Defensive: an unenumerated future kind. SHADOW blocks nothing; the completeness test
260
+ -- enumerates the 7 catalog kinds and asserts each is handled above, so this is unreachable
261
+ -- for today's constraint and never masks a missing arm.
262
+ return true;
263
+ end if;
264
+ end;
265
+ $function$;
266
+ revoke all on function agent_board._can_claim_resource(text, text) from public, anon;
267
+ grant execute on function agent_board._can_claim_resource(text, text) to authenticated, service_role;
268
+ comment on function agent_board._can_claim_resource(text, text) is
269
+ 'CLAIMGATE/SHADOW (#133, IF-0-SHADOW-1): fleet-graph authority predicate ENFORCE consumes unchanged. '
270
+ 'machine = actor owns/bound to the active machine in an active trust domain (fingerprint globally unique); '
271
+ 'repository/repo_file = actor holds an active binding to an active non-observer repo_scope whose FULL chain '
272
+ '(scope->harness->machine->domain) is lifecycle-active (harness revoke does NOT cascade — checked explicitly); '
273
+ 'task + file/tool/custom = true (gated elsewhere). Fleet graph ONLY — NO board-admin fallback. '
274
+ 'service_role short-circuits true. SECURITY DEFINER to break fleet RLS recursion; PUBLIC EXECUTE revoked.';
275
+
276
+ -- 2. acquire_claim — compute + STAMP the shadow verdict; guard the ownerless tail -----------
277
+ -- Reproduced verbatim from 20260821000100 (CLAIMKEY) EXCEPT: (a) compute _can_claim_resource
278
+ -- after canonicalization and before the sweep (predicate-before-side-effects, so ENFORCE can
279
+ -- flip it fail-closed in place); (b) enforce the ownerless TTL cap + serialized quota; (c)
280
+ -- merge metadata.claimgate_shadow into the inserted row. BLOCKS NOTHING on authority.
281
+ create or replace function agent_board.acquire_claim(
282
+ p_agent_id uuid,
283
+ p_resource_kind text,
284
+ p_resource_key text,
285
+ p_lease_expires_at timestamp with time zone,
286
+ p_task_id uuid default null,
287
+ p_thread_id uuid default null,
288
+ p_metadata jsonb default '{}'::jsonb
289
+ )
290
+ 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)
291
+ language plpgsql
292
+ security definer
293
+ set search_path to 'agent_board', 'public'
294
+ as $function$
295
+ declare
296
+ v_thread_id uuid;
297
+ v_claim_id uuid;
298
+ v_actor_agent_id uuid;
299
+ v_shadow_authorized boolean;
300
+ v_ownerless boolean;
301
+ v_active_ownerless int;
302
+ begin
303
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
304
+
305
+ if nullif(trim(p_resource_key), '') is null then
306
+ raise exception 'resource key is required' using errcode = '22023';
307
+ end if;
308
+
309
+ -- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
310
+ -- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
311
+ -- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
312
+ p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
313
+
314
+ if p_task_id is not null then
315
+ select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
316
+ if v_thread_id is null then
317
+ raise exception 'task not found' using errcode = 'P0002';
318
+ end if;
319
+ if not agent_board._current_agent_can_access_task(p_task_id) then
320
+ raise exception 'task not accessible to current agent' using errcode = '42501';
321
+ end if;
322
+ else
323
+ v_thread_id := p_thread_id;
324
+ end if;
325
+
326
+ if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
327
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
328
+ end if;
329
+
330
+ -- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
331
+ -- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
332
+ -- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
333
+ -- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
334
+ -- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
335
+ -- with a uuid-keyed legitimate claim.
336
+ --
337
+ -- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
338
+ -- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
339
+ -- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
340
+ -- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
341
+ -- denied (they cannot access the task). Requiring access too closes that gap and matches the
342
+ -- authorization a task-BOUND acquire already gets through append_task_event.
343
+ if p_resource_kind = 'task'
344
+ 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
345
+ if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
346
+ raise exception 'task not accessible to current agent' using errcode = '42501';
347
+ end if;
348
+ if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
349
+ raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
350
+ using errcode = '42501';
351
+ end if;
352
+ end if;
353
+
354
+ -- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
355
+ -- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
356
+ -- unauthorized caller). SHADOW BLOCKS NOTHING on this verdict — it is only stamped below.
357
+ v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
358
+
359
+ -- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
360
+ -- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
361
+ -- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
362
+ -- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
363
+ v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
364
+ if v_ownerless
365
+ and not agent_board._is_service_role()
366
+ and agent_board._claimgate_ownerless_guards_enabled() then
367
+ -- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
368
+ -- this is a lease-length bound, NOT an authority block.
369
+ if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
370
+ raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
371
+ using errcode = '22023';
372
+ end if;
373
+
374
+ -- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
375
+ -- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
376
+ -- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
377
+ -- at commit; it does not block other actors (the key mixes in the actor id).
378
+ perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
379
+ select count(*) into v_active_ownerless
380
+ from agent_board.claims c
381
+ where c.agent_id = v_actor_agent_id
382
+ and c.resource_kind in ('file', 'tool', 'custom')
383
+ and c.status = 'active'
384
+ and c.released_at is null
385
+ and c.lease_expires_at > now();
386
+ if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
387
+ raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
388
+ using errcode = '54000';
389
+ end if;
390
+ end if;
391
+
392
+ -- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
393
+ -- is already canonical (above); comparing against the CANONICAL form of each existing row's key
394
+ -- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
395
+ -- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
396
+ -- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
397
+ -- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
398
+ -- live census.
399
+ update agent_board.claims c
400
+ set status = 'expired',
401
+ updated_at = now()
402
+ where c.resource_kind = p_resource_kind
403
+ and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
404
+ and c.status = 'active'
405
+ and c.released_at is null
406
+ and c.lease_expires_at <= now();
407
+
408
+ if exists (
409
+ select 1 from agent_board.claims
410
+ where claims.resource_kind = p_resource_kind
411
+ and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
412
+ and claims.status = 'active'
413
+ and claims.released_at is null
414
+ and claims.lease_expires_at > now()
415
+ ) then
416
+ raise exception 'conflicting active claim' using errcode = '23505';
417
+ end if;
418
+
419
+ insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
420
+ values (
421
+ p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
422
+ -- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
423
+ -- is authoritative over a caller-supplied claimgate_shadow key.
424
+ coalesce(p_metadata, '{}'::jsonb)
425
+ || jsonb_build_object(
426
+ 'claimgate_shadow',
427
+ jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
428
+ )
429
+ )
430
+ returning id into v_claim_id;
431
+
432
+ if p_task_id is not null then
433
+ 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));
434
+ end if;
435
+
436
+ return query
437
+ 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
438
+ from agent_board.claims c
439
+ where c.id = v_claim_id;
440
+ end;
441
+ $function$;
442
+ revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
443
+ grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;