@consiliency/agent-board-schema 1.28.0 → 1.38.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 +40 -0
- package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
- package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
- package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
- package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
- package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
- package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
- package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
- package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
- package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
- package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
- package/package.json +1 -1
- package/schema-manifest.json +93 -3
- package/src/deployment-drift-script.test.mjs +424 -0
- package/src/deployment-drift.test.mjs +301 -0
- package/src/tooling.test.mjs +83 -2
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
- package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
- package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
- package/tests/agent_board_consent_substrate.test.sql +8 -1
- package/tests/agent_board_enroll_resumption.test.sql +7 -1
- package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
- package/tests/agent_board_fedsession.test.sql +17 -2
- package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
- package/tests/agent_board_fk_lock_edges.test.sql +422 -0
- package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
- package/tests/agent_board_ledger_authority.test.sql +142 -71
- package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
- package/tests/agent_board_signed_write_enforced.test.sql +366 -0
- package/tests/agent_board_signer_lifecycle.test.sql +23 -6
- package/tests/agent_board_source_strip_helper.test.sql +256 -1
- package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
- package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
- package/tests/catalog/p_metadata_parameters.json +12 -0
- package/tests/fixtures/isolation-substrate.sql +1 -1
- package/tests/fixtures/test-prereqs.sql +442 -11
- package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
- package/tests/isolation/expected/task_thread_lock_order.out +203 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
- package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
- package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
- package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
-- Consiliency/message-board#291 — a claim is a LOCK, never a KEY. And the key it already
|
|
2
|
+
-- handed out is never taken back, which is now a RULING rather than an accident.
|
|
3
|
+
--
|
|
4
|
+
-- Sibling of #287 (20260912000100), which closed the identical shape on read_receipts. The
|
|
5
|
+
-- owner ruled on both halves of #291 on 2026-08-14; both the outcome and the REJECTED
|
|
6
|
+
-- alternatives are recorded here, because the second half is a decision to LEAVE a surprising
|
|
7
|
+
-- behaviour in place and a future reader must not be able to mistake it for an oversight.
|
|
8
|
+
--
|
|
9
|
+
-- ============================================================================
|
|
10
|
+
-- HALF 1 — THE MEASURED DEFECT (closed here)
|
|
11
|
+
-- ============================================================================
|
|
12
|
+
-- `agent_board.claims` is an access KEY: `_agent_can_access_thread` accepts "there is a claims
|
|
13
|
+
-- row for (thread, agent)" as one of the seven disjuncts that grant access to an
|
|
14
|
+
-- `access_mode = 'inherit'` thread. `acquire_claim` is the ONLY route to such a row —
|
|
15
|
+
-- service_role and authenticated hold SELECT and only SELECT on the table (no INSERT by any
|
|
16
|
+
-- route incl. PUBLIC), and it is the only agent_board function that INSERTs into it, so this
|
|
17
|
+
-- SECURITY DEFINER body is the whole surface.
|
|
18
|
+
--
|
|
19
|
+
-- The subject of the claim is `p_agent_id` and nothing gated it. The checks `acquire_claim`
|
|
20
|
+
-- did apply — `_current_agent_can_access_task(p_task_id)` and
|
|
21
|
+
-- `_current_agent_can_access_thread(v_thread_id)` — are evaluated for the SESSION, and
|
|
22
|
+
-- `_is_service_role()` short-circuits both; `_resolved_actor_agent_id` then returns
|
|
23
|
+
-- `p_agent_id` verbatim for a service_role caller. Measured on a live replay inside
|
|
24
|
+
-- BEGIN … ROLLBACK: an agent B with no relationship to an inherit thread T went
|
|
25
|
+
-- `_agent_can_access_thread(T,B) = false` -> service_role calls
|
|
26
|
+
-- `acquire_claim(B, 'file', k, now()+1h, null, T)` -> `true`, without B calling anything.
|
|
27
|
+
--
|
|
28
|
+
-- IT MINTED TASK ACCESS TOO, which the receipt route did not. `_current_agent_can_access_task`
|
|
29
|
+
-- accepts `_current_agent_can_access_thread(t.thread_id)` as a disjunct, so the same measured
|
|
30
|
+
-- mint flipped `_current_agent_can_access_task(task-on-T)` from false to true for B in the
|
|
31
|
+
-- same call. Closing the thread subject closes both; there is no separate task-key row to gate,
|
|
32
|
+
-- because no function writes a task-access row at all.
|
|
33
|
+
--
|
|
34
|
+
-- Under `authenticated` there was never a gap, and this is the fact that decides the shape of
|
|
35
|
+
-- the fix (see THE FIRST-ACCESS QUESTION). Measured, all three shapes, agent with no access:
|
|
36
|
+
-- acquire_claim(B,'file',k,…, p_thread_id => T) -> 42501 thread not accessible
|
|
37
|
+
-- acquire_claim(B,'task',<task uuid>,…) -> 42501 task not accessible
|
|
38
|
+
-- acquire_claim(B,'file',k,…, p_task_id => <task>) -> 42501 task not accessible
|
|
39
|
+
-- The asymmetry was service_role-only.
|
|
40
|
+
--
|
|
41
|
+
-- ============================================================================
|
|
42
|
+
-- THE FIRST-ACCESS QUESTION — asked before the fix was written, because a wrong answer here
|
|
43
|
+
-- would have made the fix a CAPABILITY REMOVAL rather than a hole closure
|
|
44
|
+
-- ============================================================================
|
|
45
|
+
-- A read receipt is a memo: gating it on prior access is obviously safe, because reading
|
|
46
|
+
-- presupposes access. A CLAIM need not be — if any legitimate flow uses `acquire_claim` to
|
|
47
|
+
-- grant FIRST access (claiming a task from a pool is how a worker comes to be allowed to see
|
|
48
|
+
-- it), then requiring prior access breaks the feature. Three legs, each checked rather than
|
|
49
|
+
-- assumed:
|
|
50
|
+
--
|
|
51
|
+
-- 1. BY CONSTRUCTION on the authenticated path. The session check already evaluates
|
|
52
|
+
-- `_agent_can_access_thread(v_thread_id, current_agent_id())` — that is what
|
|
53
|
+
-- `_current_agent_can_access_thread` reduces to for a non-service_role caller — and
|
|
54
|
+
-- `_resolved_actor_agent_id` forces `v_actor_agent_id = current_agent_id()`. So the new
|
|
55
|
+
-- gate re-evaluates an expression the function has just proved true. Confirmed by the
|
|
56
|
+
-- three measurements above: claim-as-first-access does not exist for authenticated, and
|
|
57
|
+
-- never did.
|
|
58
|
+
-- 2. NO IN-SCHEMA CALLER. A catalog scan of every `agent_board` function body for
|
|
59
|
+
-- `acquire_claim\s*\(` returns ZERO. Enrollment, delegation and task dispatch do not mint
|
|
60
|
+
-- claims on an agent's behalf; there is no server-side flow to break.
|
|
61
|
+
-- 3. NO THIRD-PARTY SHIPPED CALLER. client/CLI/MCP all default `p_agent_id` to the session's
|
|
62
|
+
-- own acting agent (`USER_SCOPED_ACTOR_DEFAULTS` in packages/agent-board-client/src/
|
|
63
|
+
-- client.ts), and a repo-wide search finds no service_role caller naming another agent.
|
|
64
|
+
--
|
|
65
|
+
-- The legitimate dispatch shape SURVIVES, and that is the point: a dispatcher that DELIVERS
|
|
66
|
+
-- first — `delegate_task`, `send_direct_message` — gives the subject an `inbox_items` row,
|
|
67
|
+
-- which is itself a disjunct, after which the claim passes the gate. Delivery grants access;
|
|
68
|
+
-- the claim then records a lock on something already visible. `scripts/client-smoke/
|
|
69
|
+
-- two-agent-claim.ts` is exactly this flow and is untouched: its second agent reaches the
|
|
70
|
+
-- thread through the direct-message inbox item, so its conflicting acquire still answers 23505
|
|
71
|
+
-- rather than 42501.
|
|
72
|
+
--
|
|
73
|
+
-- ============================================================================
|
|
74
|
+
-- THE FIX — gate the SUBJECT, reusing #287's predicate rather than a second copy
|
|
75
|
+
-- ============================================================================
|
|
76
|
+
-- `_agent_can_access_thread(p_thread_id, p_agent_id)` already exists (20260912000100). It is
|
|
77
|
+
-- subject-explicit and carries NO `_is_service_role()` short-circuit — that absence is what
|
|
78
|
+
-- makes a subject gate real, and reintroducing the short-circuit there would make BOTH this
|
|
79
|
+
-- gate and #287's vacuous while every allow-side test stayed green. It is not re-derived here;
|
|
80
|
+
-- there is exactly one definition of the disjunct set and the two gates cannot drift from it.
|
|
81
|
+
--
|
|
82
|
+
-- UNCONDITIONAL IN *WHO*. The gate is not `if _is_service_role()` and not
|
|
83
|
+
-- `if v_actor_agent_id is distinct from current_agent_id()`. A conditional gate is one
|
|
84
|
+
-- `is distinct from` away from being skipped by a caller that presents both a service_role
|
|
85
|
+
-- claim and a `sub`, and it would make this function's invariant depend on
|
|
86
|
+
-- `_resolved_actor_agent_id` continuing to raise on a mismatch — borrowing a property instead
|
|
87
|
+
-- of stating it. The redundancy on the authenticated path is the price of that.
|
|
88
|
+
--
|
|
89
|
+
-- CONDITIONAL ON *THREAD PRESENCE*, and that is not a loophole. `v_thread_id` is null for a
|
|
90
|
+
-- claim bound to neither a task nor a thread — every `machine` / `repository` / `repo_file` /
|
|
91
|
+
-- ownerless `file`|`tool`|`custom` claim in the fleet. Such a row stores `thread_id = null` and
|
|
92
|
+
-- is therefore NOT a disjunct of anything: it mints no access, so there is nothing to gate.
|
|
93
|
+
-- `_agent_can_access_thread(null, a)` is false, so gating unconditionally would refuse every
|
|
94
|
+
-- fleet claim in existence. The gate covers exactly the claims that are keys.
|
|
95
|
+
--
|
|
96
|
+
-- GATE PLACEMENT is deliberate. An earlier draft of this header said it "preserves every
|
|
97
|
+
-- existing error code". THAT WAS FALSE, and it is corrected below rather than softened -- see
|
|
98
|
+
-- ONE ERROR CODE DOES CHANGE. The ladder is:
|
|
99
|
+
-- 22023 empty resource key -> P0002 task not found -> 42501 task not accessible (session)
|
|
100
|
+
-- -> 42501 thread not accessible (session) -> [NEW] 42501 claim subject has no access
|
|
101
|
+
-- -> OBSGATE-4 task-resource gates -> CLAIMGATE shadow/enforce -> ownerless TTL/quota
|
|
102
|
+
-- -> sweep -> 23505 conflicting active claim -> insert
|
|
103
|
+
-- After the task lookup, so a nonexistent task still answers P0002 rather than 42501.
|
|
104
|
+
--
|
|
105
|
+
-- ONE ERROR CODE DOES CHANGE, and it is a decision rather than a side effect. For a
|
|
106
|
+
-- SERVICE_ROLE caller on a THREAD-BOUND claim, everything this gate now refuses used to run all
|
|
107
|
+
-- the way to the INSERT and surface as a foreign-key violation. Measured by restoring the
|
|
108
|
+
-- pre-fix definition (20260825000100) inside a rolled-back transaction and calling both:
|
|
109
|
+
--
|
|
110
|
+
-- PRE-fix POST-fix
|
|
111
|
+
-- nonexistent p_thread_id, valid subject 23503 claims_thread_id_fkey -> 42501
|
|
112
|
+
-- nonexistent subject, valid thread 23503 claims_agent_id_fkey -> 42501
|
|
113
|
+
-- nonexistent subject, THREAD-LESS claim 23503 claims_agent_id_fkey -> 23503 (unchanged)
|
|
114
|
+
--
|
|
115
|
+
-- ONE rule, two observable instances: the gate raises 42501 whenever the named subject fails
|
|
116
|
+
-- the predicate for the bound thread, and a nonexistent thread and a nonexistent agent both
|
|
117
|
+
-- make that predicate false. The third row is the control that the change is confined to
|
|
118
|
+
-- thread-BOUND claims. `authenticated` is unaffected: its session check already answered 42501
|
|
119
|
+
-- for a nonexistent thread.
|
|
120
|
+
--
|
|
121
|
+
-- 23503 IS NOT RESTORED, DELIBERATELY. Restoring it would mean probing thread existence BEFORE
|
|
122
|
+
-- the access gate, which tells a caller with no access whether a thread id exists. 42501 gives
|
|
123
|
+
-- the same answer to "no such thread" and "not your thread", which is the right answer for a
|
|
124
|
+
-- caller entitled to neither. All three rows are pinned (test assertions 61-63): 61/62 by the
|
|
125
|
+
-- gate-deletion mutation, 63 by the make-the-gate-unconditional mutation.
|
|
126
|
+
--
|
|
127
|
+
-- AND BEFORE THE SWEEP -- but NOT for the reason an earlier draft of this header gave. That
|
|
128
|
+
-- draft said "the sweep expires stale rows, so a refused caller must not reach it or the denial
|
|
129
|
+
-- leaks observable state". THAT IS FALSE, and its own mutation is what caught it: moving the
|
|
130
|
+
-- gate BELOW the sweep produced ran=60, notok=0 on the test file. `raise exception` unwinds the
|
|
131
|
+
-- whole function call, so every write made before the raise is rolled back wherever the check
|
|
132
|
+
-- sits. "A refused caller observes no state change" is therefore TRUE BY STATEMENT ATOMICITY
|
|
133
|
+
-- and is not a consequence of placement -- and because it is true either way, no test can pin
|
|
134
|
+
-- the placement through it. The gate is placed before the sweep because that matches the
|
|
135
|
+
-- convention CLAIMGATE/ENFORCE states two blocks below in this same function, not because a
|
|
136
|
+
-- test defends it. The distinction is recorded rather than quietly dropped, because a claim
|
|
137
|
+
-- that cites a reason no assertion supports is exactly what CLAUDE.md rule 2 exists to catch.
|
|
138
|
+
--
|
|
139
|
+
-- ============================================================================
|
|
140
|
+
-- HALF 2 — WHAT IS DELIBERATELY NOT CHANGED: the claim grandfather, RATIFIED
|
|
141
|
+
-- ============================================================================
|
|
142
|
+
-- The claims disjunct filters on `(thread_id, agent_id)` and NOTHING ELSE — no `status`, no
|
|
143
|
+
-- `lease_expires_at`. `release_claim` sets `status='released'` rather than deleting the row, so
|
|
144
|
+
-- A RELEASED OR LONG-EXPIRED CLAIM STILL GRANTS THREAD ACCESS, INDEFINITELY. Measured: after
|
|
145
|
+
-- `release_claim`, `_agent_can_access_thread(T,B)` is still true; deleting the row is what
|
|
146
|
+
-- flips it to false, and nothing deletes it.
|
|
147
|
+
--
|
|
148
|
+
-- THIS IS INTENDED. The owner ratified it on #291 (2026-08-14) as the #287 grandfather
|
|
149
|
+
-- principle extended: the agent legitimately held the thread, so it keeps what it saw. The
|
|
150
|
+
-- disjunct is deliberately NOT filtered on `status` or `lease_expires_at`, and a future
|
|
151
|
+
-- migration that adds such a filter is REVERSING A RULING, not fixing a bug.
|
|
152
|
+
--
|
|
153
|
+
-- Two alternatives were considered and EXPLICITLY REJECTED; they are recorded because "why not
|
|
154
|
+
-- the obvious fix" is the question a future reader will actually have:
|
|
155
|
+
--
|
|
156
|
+
-- (a) ACCESS ENDS WITH THE CLAIM — filter the disjunct on `status='active'` and an unexpired
|
|
157
|
+
-- lease. REJECTED: it reverses the #287 ruling for this one case, and it would remove
|
|
158
|
+
-- access from every principal holding a released or expired claim AT DEPLOY TIME, with
|
|
159
|
+
-- no notice and no way to tell which of them still needed it.
|
|
160
|
+
-- (b) KEEP THE ACCESS BUT RE-MECHANISE IT — record the access separately when the claim is
|
|
161
|
+
-- taken, so a stale claim ROW is not itself the key. ACKNOWLEDGED AS THE MORE HONEST
|
|
162
|
+
-- DESIGN and rejected for now on cost, and because agents holding historical released
|
|
163
|
+
-- claims would need backfilling or would silently lose access.
|
|
164
|
+
--
|
|
165
|
+
-- PERMANENCE IS BY CONSTRUCTION, not by policy, and the test asserts it rather than describing
|
|
166
|
+
-- it: no role holds DELETE or TRUNCATE on `agent_board.claims` by any route (the ACL is
|
|
167
|
+
-- SELECT-only outside the owner), and NO agent_board function deletes or truncates a claims
|
|
168
|
+
-- row. There is no reaper. The three non-minting writers — `release_claim`, `renew_claim`,
|
|
169
|
+
-- `reclaim_fleet_machine` — update `status` / `lease_expires_at` / `resource_key` / `metadata`
|
|
170
|
+
-- and never `agent_id` or `thread_id`, so none of them can re-key an existing row onto a
|
|
171
|
+
-- different subject or thread. Each is asserted POSITIVELY in the test, by call, rather than by
|
|
172
|
+
-- absence of a regex match.
|
|
173
|
+
--
|
|
174
|
+
-- THE GATE AND THE GRANDFATHER ARE COMPLEMENTARY, NOT IN TENSION. The gate decides WHO MAY BE
|
|
175
|
+
-- NAMED in a claim; the grandfather decides WHAT A CLAIM MEANS afterwards. Gating the subject
|
|
176
|
+
-- is what makes the grandfather safe to keep: a key can no longer be conjured for an agent that
|
|
177
|
+
-- never had access, so "permanent" now only ever describes access that was really held.
|
|
178
|
+
--
|
|
179
|
+
-- ============================================================================
|
|
180
|
+
-- WHY THIS DISJUNCTION IS THE WHOLE ACCESS STORY — `access_mode='restricted'` is unreachable
|
|
181
|
+
-- ============================================================================
|
|
182
|
+
-- Carried forward from #291 and RE-VERIFIED on a live replay of this bundle rather than
|
|
183
|
+
-- repeated. `agent_board.threads.access_mode` defaults to `'inherit'` and:
|
|
184
|
+
-- * NO agent_board function so much as mentions `access_mode` except `_agent_can_access_thread`
|
|
185
|
+
-- itself, which READS it (catalog scan over stripped function source against a full replay
|
|
186
|
+
-- of the bundle INCLUDING this migration, and re-asserted on every suite run by
|
|
187
|
+
-- agent_board_claim_subject_gate.test.sql — subject to the prosqlbody blind spot filed as
|
|
188
|
+
-- message-board#293);
|
|
189
|
+
-- * `aclexplode(pg_class.relacl)` on `agent_board.threads` gives `authenticated` and
|
|
190
|
+
-- `service_role` SELECT and nothing else; only the `postgres` owner can write it.
|
|
191
|
+
-- So no shipped path can ever set a thread to `'restricted'`, the `thread_memberships` branch
|
|
192
|
+
-- of the predicate is dead, and the seven-way `inherit` OR governs EVERY thread in production.
|
|
193
|
+
-- That is why this gate matters more than its blast radius suggests: it is not one branch of an
|
|
194
|
+
-- access model, it is the access model.
|
|
195
|
+
--
|
|
196
|
+
-- One nuance, stated rather than acted on: `service_role` DOES hold INSERT and UPDATE on
|
|
197
|
+
-- `agent_board.thread_memberships` (unlike every other table in this story), and no function
|
|
198
|
+
-- writes that table. The branch is dead anyway because `access_mode` itself is unwritable, so
|
|
199
|
+
-- those grants confer nothing today. They are input to the owner's open question about whether
|
|
200
|
+
-- the dormant per-thread ACL deserves a filing of its own; this migration does not touch them.
|
|
201
|
+
--
|
|
202
|
+
-- ============================================================================
|
|
203
|
+
-- MECHANICS
|
|
204
|
+
-- ============================================================================
|
|
205
|
+
-- `create or replace` with UNCHANGED signatures throughout. `drop function` would discard the
|
|
206
|
+
-- ACL **and** the COMMENT, and recreating a SECURITY DEFINER function grants EXECUTE to PUBLIC
|
|
207
|
+
-- by default. `acquire_claim` is reproduced VERBATIM from 20260825000100 (CLAIMGATE/ENFORCE)
|
|
208
|
+
-- except the single gate block; the applied result was diffed against the prior definition to
|
|
209
|
+
-- confirm the gate is the only delta. `acquire_claim` had NO comment before this migration —
|
|
210
|
+
-- the one added below is new, not a rewrite of an earlier record.
|
|
211
|
+
--
|
|
212
|
+
-- Definition-only: every apply-time statement is `create or replace function`, `revoke`,
|
|
213
|
+
-- `grant` or `comment on`. The DML in this file is `acquire_claim`'s own body, inside
|
|
214
|
+
-- `$function$ … $function$`, executed on call and never at apply time. `schema_version` stays 8.
|
|
215
|
+
|
|
216
|
+
-- 1. acquire_claim: gate the NAMED subject ---------------------------------
|
|
217
|
+
-- Reproduced VERBATIM from 20260825000100 (CLAIMGATE/ENFORCE) EXCEPT the single #291 gate
|
|
218
|
+
-- block, inserted after the SESSION thread check and BEFORE the sweep.
|
|
219
|
+
create or replace function agent_board.acquire_claim(
|
|
220
|
+
p_agent_id uuid,
|
|
221
|
+
p_resource_kind text,
|
|
222
|
+
p_resource_key text,
|
|
223
|
+
p_lease_expires_at timestamp with time zone,
|
|
224
|
+
p_task_id uuid default null,
|
|
225
|
+
p_thread_id uuid default null,
|
|
226
|
+
p_metadata jsonb default '{}'::jsonb
|
|
227
|
+
)
|
|
228
|
+
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)
|
|
229
|
+
language plpgsql
|
|
230
|
+
security definer
|
|
231
|
+
set search_path to 'agent_board', 'public'
|
|
232
|
+
as $function$
|
|
233
|
+
declare
|
|
234
|
+
v_thread_id uuid;
|
|
235
|
+
v_claim_id uuid;
|
|
236
|
+
v_actor_agent_id uuid;
|
|
237
|
+
v_shadow_authorized boolean;
|
|
238
|
+
v_ownerless boolean;
|
|
239
|
+
v_active_ownerless int;
|
|
240
|
+
begin
|
|
241
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
242
|
+
|
|
243
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
244
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
245
|
+
end if;
|
|
246
|
+
|
|
247
|
+
-- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
|
|
248
|
+
-- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
|
|
249
|
+
-- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
|
|
250
|
+
p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
|
|
251
|
+
|
|
252
|
+
if p_task_id is not null then
|
|
253
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
254
|
+
if v_thread_id is null then
|
|
255
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
256
|
+
end if;
|
|
257
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
258
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
259
|
+
end if;
|
|
260
|
+
else
|
|
261
|
+
v_thread_id := p_thread_id;
|
|
262
|
+
end if;
|
|
263
|
+
|
|
264
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
265
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
266
|
+
end if;
|
|
267
|
+
|
|
268
|
+
-- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
|
|
269
|
+
-- task/thread" and are short-circuited true for service_role; this one answers "does the
|
|
270
|
+
-- agent the claim is ABOUT already have access". Without it a service_role caller could name
|
|
271
|
+
-- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
|
|
272
|
+
-- -- would MINT thread access (and, through the thread disjunct of
|
|
273
|
+
-- _current_agent_can_access_task, task access) rather than record a lock on something the
|
|
274
|
+
-- subject could already see.
|
|
275
|
+
--
|
|
276
|
+
-- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
|
|
277
|
+
-- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
|
|
278
|
+
-- caller v_actor_agent_id IS current_agent_id() and the session check above already
|
|
279
|
+
-- established this same expression, so the redundancy is PROVABLE rather than suspected; it
|
|
280
|
+
-- is the price of an invariant this function states for itself instead of borrowing from
|
|
281
|
+
-- _resolved_actor_agent_id continuing to raise on a subject mismatch.
|
|
282
|
+
--
|
|
283
|
+
-- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
|
|
284
|
+
-- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
|
|
285
|
+
-- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
|
|
286
|
+
-- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
|
|
287
|
+
-- exactly the claims that are keys.
|
|
288
|
+
--
|
|
289
|
+
-- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
|
|
290
|
+
-- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
|
|
291
|
+
-- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
|
|
292
|
+
-- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
|
|
293
|
+
-- wherever this sits -- measured, by moving the gate below the sweep and observing the test
|
|
294
|
+
-- file stay entirely green.
|
|
295
|
+
if v_thread_id is not null
|
|
296
|
+
and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
|
|
297
|
+
raise exception 'claim subject has no access to thread' using errcode = '42501';
|
|
298
|
+
end if;
|
|
299
|
+
|
|
300
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
301
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
302
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
303
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
304
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
305
|
+
-- with a uuid-keyed legitimate claim.
|
|
306
|
+
--
|
|
307
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
308
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
309
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
310
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
311
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
312
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
313
|
+
if p_resource_kind = 'task'
|
|
314
|
+
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
|
|
315
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
316
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
317
|
+
end if;
|
|
318
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
319
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
320
|
+
using errcode = '42501';
|
|
321
|
+
end if;
|
|
322
|
+
end if;
|
|
323
|
+
|
|
324
|
+
-- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
|
|
325
|
+
-- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
|
|
326
|
+
-- unauthorized caller). The verdict is stamped below regardless of enforcement.
|
|
327
|
+
v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
|
|
328
|
+
|
|
329
|
+
-- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
|
|
330
|
+
-- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
|
|
331
|
+
-- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
|
|
332
|
+
-- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
|
|
333
|
+
-- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
|
|
334
|
+
-- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
|
|
335
|
+
-- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
|
|
336
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
337
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
338
|
+
and not v_shadow_authorized then
|
|
339
|
+
raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
|
|
340
|
+
using errcode = '42501';
|
|
341
|
+
end if;
|
|
342
|
+
|
|
343
|
+
-- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
|
|
344
|
+
-- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
|
|
345
|
+
-- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
|
|
346
|
+
-- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
|
|
347
|
+
-- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
|
|
348
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
349
|
+
and not agent_board._is_service_role()
|
|
350
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
351
|
+
and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
|
|
352
|
+
p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
|
|
353
|
+
end if;
|
|
354
|
+
|
|
355
|
+
-- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
|
|
356
|
+
-- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
|
|
357
|
+
-- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
|
|
358
|
+
-- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
|
|
359
|
+
v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
|
|
360
|
+
if v_ownerless
|
|
361
|
+
and not agent_board._is_service_role()
|
|
362
|
+
and agent_board._claimgate_ownerless_guards_enabled() then
|
|
363
|
+
-- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
|
|
364
|
+
-- this is a lease-length bound, NOT an authority block.
|
|
365
|
+
if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
|
|
366
|
+
raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
|
|
367
|
+
using errcode = '22023';
|
|
368
|
+
end if;
|
|
369
|
+
|
|
370
|
+
-- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
|
|
371
|
+
-- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
|
|
372
|
+
-- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
|
|
373
|
+
-- at commit; it does not block other actors (the key mixes in the actor id).
|
|
374
|
+
perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
|
|
375
|
+
select count(*) into v_active_ownerless
|
|
376
|
+
from agent_board.claims c
|
|
377
|
+
where c.agent_id = v_actor_agent_id
|
|
378
|
+
and c.resource_kind in ('file', 'tool', 'custom')
|
|
379
|
+
and c.status = 'active'
|
|
380
|
+
and c.released_at is null
|
|
381
|
+
and c.lease_expires_at > now();
|
|
382
|
+
if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
|
|
383
|
+
raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
|
|
384
|
+
using errcode = '54000';
|
|
385
|
+
end if;
|
|
386
|
+
end if;
|
|
387
|
+
|
|
388
|
+
-- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
|
|
389
|
+
-- is already canonical (above); comparing against the CANONICAL form of each existing row's key
|
|
390
|
+
-- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
|
|
391
|
+
-- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
|
|
392
|
+
-- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
|
|
393
|
+
-- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
|
|
394
|
+
-- live census.
|
|
395
|
+
update agent_board.claims c
|
|
396
|
+
set status = 'expired',
|
|
397
|
+
updated_at = now()
|
|
398
|
+
where c.resource_kind = p_resource_kind
|
|
399
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
|
|
400
|
+
and c.status = 'active'
|
|
401
|
+
and c.released_at is null
|
|
402
|
+
and c.lease_expires_at <= now();
|
|
403
|
+
|
|
404
|
+
if exists (
|
|
405
|
+
select 1 from agent_board.claims
|
|
406
|
+
where claims.resource_kind = p_resource_kind
|
|
407
|
+
and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
|
|
408
|
+
and claims.status = 'active'
|
|
409
|
+
and claims.released_at is null
|
|
410
|
+
and claims.lease_expires_at > now()
|
|
411
|
+
) then
|
|
412
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
413
|
+
end if;
|
|
414
|
+
|
|
415
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
416
|
+
values (
|
|
417
|
+
p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
|
|
418
|
+
-- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
|
|
419
|
+
-- is authoritative over a caller-supplied claimgate_shadow key.
|
|
420
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
421
|
+
|| jsonb_build_object(
|
|
422
|
+
'claimgate_shadow',
|
|
423
|
+
jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
returning id into v_claim_id;
|
|
427
|
+
|
|
428
|
+
if p_task_id is not null then
|
|
429
|
+
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));
|
|
430
|
+
end if;
|
|
431
|
+
|
|
432
|
+
return query
|
|
433
|
+
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
|
|
434
|
+
from agent_board.claims c
|
|
435
|
+
where c.id = v_claim_id;
|
|
436
|
+
end;
|
|
437
|
+
$function$;
|
|
438
|
+
revoke all on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) from public, anon;
|
|
439
|
+
grant execute on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) to authenticated, service_role;
|
|
440
|
+
|
|
441
|
+
comment on function agent_board.acquire_claim(uuid, text, text, timestamp with time zone, uuid, uuid, jsonb) is
|
|
442
|
+
'Takes an exclusive lease on (resource_kind, resource_key), optionally bound to a task and/or '
|
|
443
|
+
'a thread. The subject is _resolved_actor_agent_id(p_agent_id): authenticated callers are '
|
|
444
|
+
'forced to current_agent_id() and fail closed on mismatch; service_role may claim on behalf '
|
|
445
|
+
'of another agent. '
|
|
446
|
+
'A CLAIM IS A LOCK, NEVER A KEY (message-board#291): because a claims row for '
|
|
447
|
+
'(thread_id, agent_id) is itself a disjunct of _agent_can_access_thread, a claim bound to a '
|
|
448
|
+
'thread requires the NAMED agent to already pass that predicate, or the call raises '
|
|
449
|
+
'42501 ''claim subject has no access to thread''. This closes the only route to a claims row '
|
|
450
|
+
'(no role holds INSERT on agent_board.claims by any route, and acquire_claim is the only '
|
|
451
|
+
'agent_board function that inserts one), and with it the only route by which service_role '
|
|
452
|
+
'could hand an arbitrary agent durable access to an inherit thread -- and, through the thread '
|
|
453
|
+
'disjunct of _current_agent_can_access_task, to its tasks. The gate is UNCONDITIONAL in who '
|
|
454
|
+
'(never branched on _is_service_role() or on the subject differing from the session agent) '
|
|
455
|
+
'and applies only when the claim is bound to a thread: a task-less, thread-less claim stores '
|
|
456
|
+
'thread_id = null, is a disjunct of nothing and mints no access, so gating it would refuse '
|
|
457
|
+
'every machine/repository/repo_file/ownerless claim in the fleet. It sits after the task '
|
|
458
|
+
'lookup, so a nonexistent task still answers P0002; it also sits before the expiry sweep, by '
|
|
459
|
+
'CLAIMGATE convention rather than necessity (a raise unwinds the whole call, so a refused '
|
|
460
|
+
'caller changes no state wherever the check sits). On the authenticated path it is provably a '
|
|
461
|
+
'no-op: the '
|
|
462
|
+
'session check just evaluated the same expression for the same agent. '
|
|
463
|
+
'ONE ERROR CODE CHANGES, deliberately: for a service_role caller on a thread-bound claim, a '
|
|
464
|
+
'nonexistent p_thread_id -- or a nonexistent named subject -- used to reach the insert and '
|
|
465
|
+
'surface as 23503 on the claims foreign keys, and now answers 42501 here instead. That is one '
|
|
466
|
+
'rule with two instances, not two changes, and 23503 is NOT restored: preserving it would '
|
|
467
|
+
'require probing thread existence before the access gate, which leaks existence to a caller '
|
|
468
|
+
'with no access. A THREAD-LESS claim for a nonexistent subject still answers 23503, which is '
|
|
469
|
+
'the boundary. '
|
|
470
|
+
'THE CLAIM IT MINTS IS PERMANENT, AND THAT IS RATIFIED, NOT AN OVERSIGHT -- see the comment '
|
|
471
|
+
'on _current_agent_can_access_thread. Other gates on this function are unchanged: OBSGATE-4 '
|
|
472
|
+
'task-resource authorization (#127), CLAIMKEY canonicalization and CLAIMGATE shadow/enforce '
|
|
473
|
+
'fleet authority (#133).';
|
|
474
|
+
|
|
475
|
+
comment on function agent_board._current_agent_can_access_thread(uuid) is
|
|
476
|
+
'Thread access for the CURRENT session: service_role short-circuits true, otherwise '
|
|
477
|
+
'_agent_can_access_thread(p_thread_id, current_agent_id()). The disjunct set lives in that '
|
|
478
|
+
'helper and is not restated here. '
|
|
479
|
+
'ACCESS ONCE HELD IS NEVER WITHDRAWN, AND THAT IS DELIBERATE (message-board#287, extended and '
|
|
480
|
+
'RATIFIED for claims on message-board#291). The read_receipts, claims and inbox_items '
|
|
481
|
+
'disjuncts are all PERMANENT: no role holds DELETE or TRUNCATE on any of those tables and no '
|
|
482
|
+
'function body removes a row from them, so LEAVING A BOARD, LOSING A TASK ASSIGNMENT, OR '
|
|
483
|
+
'RELEASING A CLAIM DOES NOT REVOKE ACCESS TO A THREAD THE AGENT ALREADY HELD. The rule is '
|
|
484
|
+
'"you keep what you saw". For claims this is sharper than it looks and was ruled on '
|
|
485
|
+
'explicitly: the claims disjunct filters on (thread_id, agent_id) and NOT on status or '
|
|
486
|
+
'lease_expires_at, so a RELEASED or LONG-EXPIRED claim still grants access indefinitely. '
|
|
487
|
+
'Two alternatives were considered and REJECTED on #291: (a) filtering the disjunct on an '
|
|
488
|
+
'active, unexpired lease -- rejected because it reverses the #287 ruling for this one case '
|
|
489
|
+
'and would strip access at deploy time from every holder of a released or expired claim; '
|
|
490
|
+
'(b) recording the access separately when the claim is taken, so a stale row is not itself '
|
|
491
|
+
'the key -- acknowledged as the more honest design, rejected for now on cost and because '
|
|
492
|
+
'agents holding historical released claims would need backfilling or would silently lose '
|
|
493
|
+
'access. A future migration that adds a status or lease filter here is REVERSING A RULING, '
|
|
494
|
+
'not fixing a bug. What IS closed is the mint: mark_read (#287) and acquire_claim (#291) both '
|
|
495
|
+
'gate the NAMED subject through _agent_can_access_thread, so a receipt or a claim can only '
|
|
496
|
+
'ever record access already held. '
|
|
497
|
+
'access_mode = ''restricted'' is UNREACHABLE through the shipped API -- no function writes '
|
|
498
|
+
'threads.access_mode (it defaults to ''inherit'') and authenticated/service_role hold SELECT '
|
|
499
|
+
'and only SELECT on agent_board.threads -- so the inherit disjunction governs every thread in '
|
|
500
|
+
'production and the per-thread ACL added in #27 is dormant.';
|