@consiliency/agent-board-schema 1.40.0 → 1.42.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 +8 -0
- package/migrations/20260921000100_agent_board_task_event_origin_posture.sql +881 -0
- package/migrations/20260922000100_agent_board_board_claim_policy.sql +376 -0
- package/package.json +1 -1
- package/schema-manifest.json +21 -3
- package/src/tooling.test.mjs +22 -2
- package/tests/agent_board_auth_rls.test.sql +8 -3
- package/tests/agent_board_board_claim_policy.test.sql +228 -0
- package/tests/agent_board_ledger_authority.test.sql +12 -4
- package/tests/agent_board_signer_lifecycle.test.sql +1 -1
- package/tests/agent_board_task_event_origin_posture.test.sql +393 -0
- package/tests/agent_board_task_thread_lock_order.test.sql +10 -6
- package/tests/agent_board_write_enforcement_class.test.sql +2 -1
- package/tests/catalog/p_metadata_parameters.json +12 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
-- Board-settable claim policy: let a board opt OUT of our ownerless claim enforcement.
|
|
2
|
+
--
|
|
3
|
+
-- THE ASK. A board's work may be coordinated by somebody else's locking — a repository lock, a
|
|
4
|
+
-- scheduler lease, an external mutex — and our claim guards must not interfere with it. If their
|
|
5
|
+
-- lock already granted the work, our TTL cap or quota rejecting the acquire is us breaking a
|
|
6
|
+
-- system that was working. So enforcement becomes opt-out, and the board creator owns the switch.
|
|
7
|
+
--
|
|
8
|
+
-- WHAT THIS DOES NOT TOUCH, WHICH IS THE WHOLE SECURITY POINT. `advisory` disables the OWNERLESS
|
|
9
|
+
-- guards only — the TTL cap and per-agent quota on 'file'/'tool'/'custom'. It does NOT and MUST
|
|
10
|
+
-- NOT weaken:
|
|
11
|
+
--
|
|
12
|
+
-- * the fleet-authority gate on 'machine' / 'repository' / 'repo_file' (_can_claim_resource),
|
|
13
|
+
-- which answers "may this agent claim this resource AT ALL" and is an authorization boundary,
|
|
14
|
+
-- not a locking convenience;
|
|
15
|
+
-- * the claim SUBJECT gate (#291), the task/thread access checks, or the uniqueness index that
|
|
16
|
+
-- makes a claim exclusive in the first place.
|
|
17
|
+
--
|
|
18
|
+
-- The distinction is: a board may decline OUR LOCK. A board may not grant itself SOMEONE ELSE'S
|
|
19
|
+
-- RESOURCE. Structurally this holds because the opt-out is inside the `v_ownerless` branch, which
|
|
20
|
+
-- only the three ownerless kinds enter — and the fleet kinds are gated far above it. BCP-6 asserts
|
|
21
|
+
-- it anyway rather than trusting the structure, because "it is in the right branch" is exactly the
|
|
22
|
+
-- kind of reasoning that stops being true when someone moves a line.
|
|
23
|
+
--
|
|
24
|
+
-- WHY THE THREAD IS THE RESOLUTION PATH. `claims` has no board_id — a claim is scoped to an agent
|
|
25
|
+
-- plus an optional task/thread. So the board is reached as claim -> thread -> threads.board_id.
|
|
26
|
+
-- Three cases, all deliberate:
|
|
27
|
+
-- * thread has a board, board says 'advisory' -> guards skipped
|
|
28
|
+
-- * thread has a board, board says 'inherit' -> fleet-wide flags govern (the default)
|
|
29
|
+
-- * no thread, or thread has no board -> NO BOARD TO ASK, fleet-wide flags govern
|
|
30
|
+
-- The last is the important one: a bare ownerless claim cannot opt itself out by omitting a
|
|
31
|
+
-- thread. Opting out requires a board that a human owner deliberately configured. BCP-5 pins it.
|
|
32
|
+
--
|
|
33
|
+
-- DEFAULT IS 'inherit', so every existing board behaves exactly as it does today. This ships no
|
|
34
|
+
-- behaviour change until a board owner asks for one.
|
|
35
|
+
|
|
36
|
+
-- 1. The column.
|
|
37
|
+
alter table agent_board.boards
|
|
38
|
+
add column if not exists claim_policy text not null default 'inherit';
|
|
39
|
+
|
|
40
|
+
alter table agent_board.boards drop constraint if exists boards_claim_policy_check;
|
|
41
|
+
alter table agent_board.boards add constraint boards_claim_policy_check
|
|
42
|
+
check (claim_policy in (
|
|
43
|
+
'inherit', -- follow the fleet-wide claimgate flags (default; today's behaviour)
|
|
44
|
+
'advisory' -- record claims, never enforce them: this board's locking lives elsewhere
|
|
45
|
+
));
|
|
46
|
+
|
|
47
|
+
comment on column agent_board.boards.claim_policy is
|
|
48
|
+
'Board-settable claim enforcement. ''inherit'' follows the fleet-wide claimgate flags. ''advisory'' means this board coordinates through some OTHER locking mechanism, so our ownerless (file/tool/custom) TTL cap and per-agent quota are recorded but not enforced for its claims. It does NOT weaken the fleet-authority gate on machine/repository/repo_file, the subject gate, or claim uniqueness — a board may decline our lock, never grant itself another owner''s resource. Set via agent_board.set_board_claim_policy (board owner only).';
|
|
49
|
+
|
|
50
|
+
-- 2. Resolve a claim's board policy from its thread. STABLE and SECURITY DEFINER so the read is
|
|
51
|
+
-- not subject to the caller's RLS — the policy is a property of the board, not of who is asking,
|
|
52
|
+
-- and a caller who cannot see the board must still be governed by it.
|
|
53
|
+
create or replace function agent_board._claimgate_board_claim_policy(p_thread_id uuid)
|
|
54
|
+
returns text
|
|
55
|
+
language sql
|
|
56
|
+
stable
|
|
57
|
+
security definer
|
|
58
|
+
set search_path = agent_board, public
|
|
59
|
+
as $$
|
|
60
|
+
select coalesce(
|
|
61
|
+
(select b.claim_policy
|
|
62
|
+
from agent_board.threads t
|
|
63
|
+
join agent_board.boards b on b.id = t.board_id
|
|
64
|
+
where t.id = p_thread_id),
|
|
65
|
+
'inherit')
|
|
66
|
+
$$;
|
|
67
|
+
|
|
68
|
+
revoke all on function agent_board._claimgate_board_claim_policy(uuid) from public, anon;
|
|
69
|
+
grant execute on function agent_board._claimgate_board_claim_policy(uuid) to authenticated, service_role;
|
|
70
|
+
|
|
71
|
+
comment on function agent_board._claimgate_board_claim_policy(uuid) is
|
|
72
|
+
'Resolves a claim''s governing board policy through its thread. Returns ''inherit'' when the thread is null, unknown, or not board-linked — so a claim with no board cannot opt itself out by omission; opting out requires a board an owner deliberately configured.';
|
|
73
|
+
-- 3. acquire_claim, with the board opt-out folded into the ownerless condition.
|
|
74
|
+
-- CREATE OR REPLACE with an unchanged signature, so the ACL and COMMENT survive.
|
|
75
|
+
CREATE OR REPLACE FUNCTION agent_board.acquire_claim(p_agent_id uuid, p_resource_kind text, p_resource_key text, p_lease_expires_at timestamp with time zone, p_task_id uuid DEFAULT NULL::uuid, p_thread_id uuid DEFAULT NULL::uuid, p_metadata jsonb DEFAULT '{}'::jsonb)
|
|
76
|
+
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)
|
|
77
|
+
LANGUAGE plpgsql
|
|
78
|
+
SECURITY DEFINER
|
|
79
|
+
SET search_path TO 'agent_board', 'public'
|
|
80
|
+
AS $function$
|
|
81
|
+
declare
|
|
82
|
+
v_thread_id uuid;
|
|
83
|
+
v_claim_id uuid;
|
|
84
|
+
v_actor_agent_id uuid;
|
|
85
|
+
v_shadow_authorized boolean;
|
|
86
|
+
v_ownerless boolean;
|
|
87
|
+
v_active_ownerless int;
|
|
88
|
+
begin
|
|
89
|
+
v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
|
|
90
|
+
|
|
91
|
+
if nullif(trim(p_resource_key), '') is null then
|
|
92
|
+
raise exception 'resource key is required' using errcode = '22023';
|
|
93
|
+
end if;
|
|
94
|
+
|
|
95
|
+
-- CLAIMKEY (#133): normalize the resource key to its canonical, fleet-fingerprint-carrying
|
|
96
|
+
-- form BEFORE any lookup/insert, so the row stored (and the uniqueness index contended)
|
|
97
|
+
-- carry canonical identity. NORMALIZE-ONLY — this never rejects (rejection is ENFORCE).
|
|
98
|
+
p_resource_key := agent_board._canonical_claim_key(p_resource_kind, p_resource_key);
|
|
99
|
+
|
|
100
|
+
if p_task_id is not null then
|
|
101
|
+
select tasks.thread_id into v_thread_id from agent_board.tasks where id = p_task_id;
|
|
102
|
+
if v_thread_id is null then
|
|
103
|
+
raise exception 'task not found' using errcode = 'P0002';
|
|
104
|
+
end if;
|
|
105
|
+
if not agent_board._current_agent_can_access_task(p_task_id) then
|
|
106
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
107
|
+
end if;
|
|
108
|
+
else
|
|
109
|
+
v_thread_id := p_thread_id;
|
|
110
|
+
end if;
|
|
111
|
+
|
|
112
|
+
if v_thread_id is not null and not agent_board._current_agent_can_access_thread(v_thread_id) then
|
|
113
|
+
raise exception 'thread not accessible to current agent' using errcode = '42501';
|
|
114
|
+
end if;
|
|
115
|
+
|
|
116
|
+
-- message-board#291: the SUBJECT gate. The checks above answer "may this SESSION touch the
|
|
117
|
+
-- task/thread" and are short-circuited true for service_role; this one answers "does the
|
|
118
|
+
-- agent the claim is ABOUT already have access". Without it a service_role caller could name
|
|
119
|
+
-- any agent, and the resulting claims row -- itself an access disjunct, and a PERMANENT one
|
|
120
|
+
-- -- would MINT thread access (and, through the thread disjunct of
|
|
121
|
+
-- _current_agent_can_access_task, task access) rather than record a lock on something the
|
|
122
|
+
-- subject could already see.
|
|
123
|
+
--
|
|
124
|
+
-- UNCONDITIONAL IN *WHO*. DO NOT "optimize" this into `if _is_service_role() and not ...` or
|
|
125
|
+
-- `if v_actor_agent_id is distinct from agent_board.current_agent_id()`. For an authenticated
|
|
126
|
+
-- caller v_actor_agent_id IS current_agent_id() and the session check above already
|
|
127
|
+
-- established this same expression, so the redundancy is PROVABLE rather than suspected; it
|
|
128
|
+
-- is the price of an invariant this function states for itself instead of borrowing from
|
|
129
|
+
-- _resolved_actor_agent_id continuing to raise on a subject mismatch.
|
|
130
|
+
--
|
|
131
|
+
-- CONDITIONAL ON *THREAD PRESENCE*, which is not a loophole. A claim bound to neither a task
|
|
132
|
+
-- nor a thread stores thread_id = null, is a disjunct of nothing and mints no access.
|
|
133
|
+
-- _agent_can_access_thread answers false for a null thread, so gating unconditionally would
|
|
134
|
+
-- refuse every machine / repository / repo_file / ownerless claim in the fleet. This covers
|
|
135
|
+
-- exactly the claims that are keys.
|
|
136
|
+
--
|
|
137
|
+
-- PLACED AFTER THE TASK LOOKUP so a nonexistent task still answers P0002, not 42501 -- that
|
|
138
|
+
-- ordering IS test-pinned. Placed BEFORE the sweep only to match the predicate-before-side-
|
|
139
|
+
-- effects convention CLAIMGATE/ENFORCE states below; it is NOT what makes a denial free of
|
|
140
|
+
-- side effects. `raise exception` unwinds the whole call, so a refused caller changes no state
|
|
141
|
+
-- wherever this sits -- measured, by moving the gate below the sweep and observing the test
|
|
142
|
+
-- file stay entirely green.
|
|
143
|
+
if v_thread_id is not null
|
|
144
|
+
and not agent_board._agent_can_access_thread(v_thread_id, v_actor_agent_id) then
|
|
145
|
+
raise exception 'claim subject has no access to thread' using errcode = '42501';
|
|
146
|
+
end if;
|
|
147
|
+
|
|
148
|
+
-- OBSGATE-4 (#127): a claim on a TASK resource is authorized on the LOCKED task = the task
|
|
149
|
+
-- named by RESOURCE_KEY (resource_kind='task'). The active-resource uniqueness is on
|
|
150
|
+
-- (resource_kind, resource_key), so that is the task a squat contends for — gating resource_key
|
|
151
|
+
-- (NOT p_task_id, which only associates the claim/event) prevents locking task B's key while
|
|
152
|
+
-- authorizing on an unrelated task A passed as p_task_id. A non-uuid 'task' key cannot collide
|
|
153
|
+
-- with a uuid-keyed legitimate claim.
|
|
154
|
+
--
|
|
155
|
+
-- The composition MIRRORS append_task_event's: ACCESS then MUTATE. mutate alone is
|
|
156
|
+
-- insufficient — _current_agent_can_mutate_task admits any non-observer board member via
|
|
157
|
+
-- _current_agent_can_post_to_thread (board send-role), which is NOT thread access; on a
|
|
158
|
+
-- RESTRICTED thread a board member lacking thread access would pass mutate but must still be
|
|
159
|
+
-- denied (they cannot access the task). Requiring access too closes that gap and matches the
|
|
160
|
+
-- authorization a task-BOUND acquire already gets through append_task_event.
|
|
161
|
+
if p_resource_kind = 'task'
|
|
162
|
+
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
|
|
163
|
+
if not agent_board._current_agent_can_access_task(p_resource_key::uuid) then
|
|
164
|
+
raise exception 'task not accessible to current agent' using errcode = '42501';
|
|
165
|
+
end if;
|
|
166
|
+
if not agent_board._current_agent_can_mutate_task(p_resource_key::uuid) then
|
|
167
|
+
raise exception 'claiming a task resource requires a non-observer board role, or task assignment/creation'
|
|
168
|
+
using errcode = '42501';
|
|
169
|
+
end if;
|
|
170
|
+
end if;
|
|
171
|
+
|
|
172
|
+
-- CLAIMGATE/SHADOW (#133): compute the fleet-graph authority verdict BEFORE the sweep (so
|
|
173
|
+
-- ENFORCE can flip this same point fail-closed with no observable side effect leaking to an
|
|
174
|
+
-- unauthorized caller). The verdict is stamped below regardless of enforcement.
|
|
175
|
+
v_shadow_authorized := agent_board._can_claim_resource(p_resource_kind, p_resource_key);
|
|
176
|
+
|
|
177
|
+
-- CLAIMGATE/ENFORCE (#133): per-kind GATED DENIAL, BEFORE the sweep (predicate-before-side-
|
|
178
|
+
-- effects). When this fleet-backed kind's flag is ON and the actor is UNAUTHORIZED, DENY —
|
|
179
|
+
-- no state has changed yet (sweep/insert are below). When OFF, fall through to the shadow-
|
|
180
|
+
-- stamp only (SHADOW behavior; blocks nothing). Non-fleet kinds never kind-enforce (resolver
|
|
181
|
+
-- returns FALSE). service_role is authorized by the predicate (wrapper short-circuit), so the
|
|
182
|
+
-- operator carve-out holds. Fail-closed-on-unknown is automatic: an unresolvable fleet resource
|
|
183
|
+
-- makes v_shadow_authorized FALSE, so an enforced acquire of it denies.
|
|
184
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
185
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
186
|
+
and not v_shadow_authorized then
|
|
187
|
+
raise exception 'not authorized to claim this % resource (fleet authority required)', p_resource_kind
|
|
188
|
+
using errcode = '42501';
|
|
189
|
+
end if;
|
|
190
|
+
|
|
191
|
+
-- CLAIMGATE/ENFORCE (#133): max-lease horizon. For an ENFORCED fleet-backed kind, CLAMP the
|
|
192
|
+
-- lease to now()+horizon (default 30d, operator-configurable) — a bound on lease term, NOT an
|
|
193
|
+
-- authority block. Clamp (not reject) so an over-long lease reconciles here / at its next renew.
|
|
194
|
+
-- service_role exempt (operator carve-out). Only reached by an AUTHORIZED acquire (unauthorized
|
|
195
|
+
-- was denied above), or when the flag is OFF (then the guard below is skipped entirely).
|
|
196
|
+
if p_resource_kind in ('machine', 'repository', 'repo_file')
|
|
197
|
+
and not agent_board._is_service_role()
|
|
198
|
+
and agent_board._claimgate_kind_enforced(p_resource_kind)
|
|
199
|
+
and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
|
|
200
|
+
p_lease_expires_at := now() + agent_board._claimgate_max_lease_horizon();
|
|
201
|
+
end if;
|
|
202
|
+
|
|
203
|
+
-- Ownerless tail (file/tool/custom): the ONE guard SHADOW carries — GATED OFF BY DEFAULT.
|
|
204
|
+
-- _claimgate_ownerless_guards_enabled() is false unless an operator flips it post-census, so in
|
|
205
|
+
-- SHADOW this block is skipped entirely and NOTHING is blocked (only the stamp above lands).
|
|
206
|
+
-- service_role is also exempt (operator carve-out, consistent with the predicate short-circuit).
|
|
207
|
+
v_ownerless := p_resource_kind in ('file', 'tool', 'custom');
|
|
208
|
+
if v_ownerless
|
|
209
|
+
and not agent_board._is_service_role()
|
|
210
|
+
and agent_board._claimgate_ownerless_guards_enabled()
|
|
211
|
+
-- BOARD OPT-OUT: a board whose work is coordinated by SOMEONE ELSE'S locking (a repo lock, a
|
|
212
|
+
-- scheduler lease, an external mutex) can set claim_policy='advisory'. Our claims are then
|
|
213
|
+
-- RECORDED for visibility and never ENFORCED, so we cannot reject an acquire that their own
|
|
214
|
+
-- lock already permitted. Resolved from the claim's thread; a claim with no thread, or a
|
|
215
|
+
-- thread with no board, has no board to ask and falls back to the fleet-wide flags.
|
|
216
|
+
and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory' then
|
|
217
|
+
-- TTL CAP: an ownerless lease may not exceed the max horizon. Rejects an over-long acquire;
|
|
218
|
+
-- this is a lease-length bound, NOT an authority block.
|
|
219
|
+
if p_lease_expires_at > now() + agent_board._claimgate_ownerless_ttl_cap() then
|
|
220
|
+
raise exception 'ownerless claim lease horizon exceeds cap (%)', agent_board._claimgate_ownerless_ttl_cap()
|
|
221
|
+
using errcode = '22023';
|
|
222
|
+
end if;
|
|
223
|
+
|
|
224
|
+
-- QUOTA (transactionally serialized): an actor-scoped advisory XACT lock serializes this
|
|
225
|
+
-- actor's concurrent acquisitions (on DIFFERENT keys, which the row-level uniqueness index
|
|
226
|
+
-- does NOT), so a count-before-insert cannot race two claims past the cap. The lock releases
|
|
227
|
+
-- at commit; it does not block other actors (the key mixes in the actor id).
|
|
228
|
+
perform pg_advisory_xact_lock(hashtext('agent_board.claimgate_ownerless_quota'), hashtext(v_actor_agent_id::text));
|
|
229
|
+
select count(*) into v_active_ownerless
|
|
230
|
+
from agent_board.claims c
|
|
231
|
+
where c.agent_id = v_actor_agent_id
|
|
232
|
+
and c.resource_kind in ('file', 'tool', 'custom')
|
|
233
|
+
and c.status = 'active'
|
|
234
|
+
and c.released_at is null
|
|
235
|
+
and c.lease_expires_at > now();
|
|
236
|
+
if v_active_ownerless >= agent_board._claimgate_ownerless_quota() then
|
|
237
|
+
raise exception 'ownerless active-claim quota reached (% per agent)', agent_board._claimgate_ownerless_quota()
|
|
238
|
+
using errcode = '54000';
|
|
239
|
+
end if;
|
|
240
|
+
end if;
|
|
241
|
+
|
|
242
|
+
-- CLAIMKEY (#133) MUST-FIX 3: the sweep AND the conflict check are ALIAS-AWARE. p_resource_key
|
|
243
|
+
-- is already canonical (above); comparing against the CANONICAL form of each existing row's key
|
|
244
|
+
-- matches both a canonical row AND an un-healed LEGACY row for the same physical resource. Without
|
|
245
|
+
-- this, acquire('machine','machine:fpDH') would normalize to 'fpDH', see no exact-'fpDH' conflict
|
|
246
|
+
-- against a still-active legacy 'machine:fpDH' row, and INSERT a second live hold — breaking mutual
|
|
247
|
+
-- exclusion during the legacy window. Closing it here in code does NOT depend on the operator-owned
|
|
248
|
+
-- live census.
|
|
249
|
+
update agent_board.claims c
|
|
250
|
+
set status = 'expired',
|
|
251
|
+
updated_at = now()
|
|
252
|
+
where c.resource_kind = p_resource_kind
|
|
253
|
+
and agent_board._canonical_claim_key(c.resource_kind, c.resource_key) = p_resource_key
|
|
254
|
+
and c.status = 'active'
|
|
255
|
+
and c.released_at is null
|
|
256
|
+
and c.lease_expires_at <= now();
|
|
257
|
+
|
|
258
|
+
if exists (
|
|
259
|
+
select 1 from agent_board.claims
|
|
260
|
+
where claims.resource_kind = p_resource_kind
|
|
261
|
+
and agent_board._canonical_claim_key(claims.resource_kind, claims.resource_key) = p_resource_key
|
|
262
|
+
and claims.status = 'active'
|
|
263
|
+
and claims.released_at is null
|
|
264
|
+
and claims.lease_expires_at > now()
|
|
265
|
+
) then
|
|
266
|
+
raise exception 'conflicting active claim' using errcode = '23505';
|
|
267
|
+
end if;
|
|
268
|
+
|
|
269
|
+
insert into agent_board.claims(task_id, thread_id, agent_id, resource_kind, resource_key, lease_expires_at, metadata)
|
|
270
|
+
values (
|
|
271
|
+
p_task_id, v_thread_id, v_actor_agent_id, p_resource_kind, p_resource_key, p_lease_expires_at,
|
|
272
|
+
-- SHADOW stamp: record the authority verdict alongside any caller metadata. Merged last so it
|
|
273
|
+
-- is authoritative over a caller-supplied claimgate_shadow key.
|
|
274
|
+
coalesce(p_metadata, '{}'::jsonb)
|
|
275
|
+
|| jsonb_build_object(
|
|
276
|
+
'claimgate_shadow',
|
|
277
|
+
jsonb_build_object('kind', p_resource_kind, 'authorized', v_shadow_authorized)
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
returning id into v_claim_id;
|
|
281
|
+
|
|
282
|
+
if p_task_id is not null then
|
|
283
|
+
perform agent_board._append_task_event('unattested:signed-metadata-rpc', 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));
|
|
284
|
+
end if;
|
|
285
|
+
|
|
286
|
+
return query
|
|
287
|
+
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
|
|
288
|
+
from agent_board.claims c
|
|
289
|
+
where c.id = v_claim_id;
|
|
290
|
+
end;
|
|
291
|
+
$function$;
|
|
292
|
+
-- 4. The setter. Owner-gated, mirroring set_board_role_gate rather than inventing a second
|
|
293
|
+
-- authorization shape: the same membership test, the same service_role refusal, and the same
|
|
294
|
+
-- post-UPDATE fleet-revocation assertion.
|
|
295
|
+
--
|
|
296
|
+
-- THE REFUSAL IS A REAL BOUNDARY, not a formality. An earlier draft of this comment said an
|
|
297
|
+
-- operator "can write the column directly with the grant it already holds" — inherited wording,
|
|
298
|
+
-- and it overstates the operator's reach in THIS repo: service_role holds SELECT on boards and
|
|
299
|
+
-- an RLS FOR ALL policy, but NO GRANT UPDATE, so that policy does not write. Changing the
|
|
300
|
+
-- column outside this RPC needs the table owner. Flagged by a review seat that checked the
|
|
301
|
+
-- grants instead of reading the sentence.
|
|
302
|
+
--
|
|
303
|
+
-- The point of the RPC is that a BOARD CREATOR can set this without an operator at all.
|
|
304
|
+
create or replace function agent_board.set_board_claim_policy(
|
|
305
|
+
p_board_id uuid,
|
|
306
|
+
p_claim_policy text
|
|
307
|
+
)
|
|
308
|
+
returns table(
|
|
309
|
+
board_id uuid,
|
|
310
|
+
claim_policy text,
|
|
311
|
+
updated_at timestamptz
|
|
312
|
+
)
|
|
313
|
+
language plpgsql
|
|
314
|
+
security definer
|
|
315
|
+
set search_path = agent_board, public
|
|
316
|
+
as $$
|
|
317
|
+
declare
|
|
318
|
+
v_actor uuid := agent_board.current_agent_id();
|
|
319
|
+
v_row agent_board.boards%rowtype;
|
|
320
|
+
begin
|
|
321
|
+
if p_claim_policy is null then
|
|
322
|
+
raise exception 'p_claim_policy must not be null' using errcode = '22023';
|
|
323
|
+
end if;
|
|
324
|
+
-- Validated HERE as well as by the column CHECK. The CHECK would reject an unknown value
|
|
325
|
+
-- anyway, but with a constraint-violation error naming an internal constraint; a caller asking
|
|
326
|
+
-- for 'off' deserves to be told the vocabulary, not handed 23514.
|
|
327
|
+
if p_claim_policy not in ('inherit', 'advisory') then
|
|
328
|
+
raise exception 'claim policy must be one of inherit, advisory (received %)', p_claim_policy
|
|
329
|
+
using errcode = '22023';
|
|
330
|
+
end if;
|
|
331
|
+
|
|
332
|
+
-- Owner gate: only an active owner-role member may set this. service_role
|
|
333
|
+
-- (current_agent_id() = null) is not an owner and is rejected here.
|
|
334
|
+
if v_actor is null
|
|
335
|
+
or not exists (
|
|
336
|
+
select 1
|
|
337
|
+
from agent_board.board_memberships bm
|
|
338
|
+
where bm.board_id = p_board_id
|
|
339
|
+
and bm.agent_id = v_actor
|
|
340
|
+
and bm.role = 'owner'
|
|
341
|
+
and bm.membership_state in ('joined', 'subscribed')
|
|
342
|
+
) then
|
|
343
|
+
raise exception 'only a board owner may set the claim policy' using errcode = '42501';
|
|
344
|
+
end if;
|
|
345
|
+
|
|
346
|
+
update agent_board.boards b
|
|
347
|
+
set claim_policy = p_claim_policy,
|
|
348
|
+
updated_at = now()
|
|
349
|
+
where b.id = p_board_id
|
|
350
|
+
returning * into v_row;
|
|
351
|
+
|
|
352
|
+
if v_row.id is null then
|
|
353
|
+
raise exception 'board not found' using errcode = 'P0002';
|
|
354
|
+
end if;
|
|
355
|
+
|
|
356
|
+
-- #276, AFTER the boards UPDATE so this function's order is boards -> agents, the canonical one
|
|
357
|
+
-- (#194). Fleet revocation deliberately LEAVES agents.status = 'active' -- the ledger row is the
|
|
358
|
+
-- only authority signal -- so the owner-membership check above passes for a revoked owner and
|
|
359
|
+
-- cannot stand alone. Disabling this board's claim enforcement is an authority write in the same
|
|
360
|
+
-- class as flipping the role gate, which is why the mirror has to include this call and not just
|
|
361
|
+
-- the membership test. Same transaction: the raise rolls the UPDATE back.
|
|
362
|
+
--
|
|
363
|
+
-- This was MISSING in the first version of this function, which claimed in its own commit
|
|
364
|
+
-- message to mirror set_board_role_gate "exactly". A review seat found it by reading both
|
|
365
|
+
-- functions side by side rather than taking that claim.
|
|
366
|
+
perform agent_board._assert_actor_not_fleet_revoked(v_actor, 'set_board_claim_policy');
|
|
367
|
+
|
|
368
|
+
return query select v_row.id, v_row.claim_policy, v_row.updated_at;
|
|
369
|
+
end;
|
|
370
|
+
$$;
|
|
371
|
+
|
|
372
|
+
revoke all on function agent_board.set_board_claim_policy(uuid, text) from public, anon;
|
|
373
|
+
grant execute on function agent_board.set_board_claim_policy(uuid, text) to authenticated, service_role;
|
|
374
|
+
|
|
375
|
+
comment on function agent_board.set_board_claim_policy(uuid, text) is
|
|
376
|
+
'Board-owner-only. Sets boards.claim_policy to ''inherit'' (follow the fleet-wide claimgate flags) or ''advisory'' (this board coordinates through another locking mechanism, so our ownerless file/tool/custom TTL cap and quota are recorded but not enforced). Never weakens the fleet-authority gate on machine/repository/repo_file, the subject gate, or claim uniqueness. Owner gate and service_role refusal mirror set_board_role_gate.';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.42.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.42.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 140,
|
|
50
|
+
"aggregateSha256": "d82adb3013e8a1118ecae11a3aff4d3f0e7b6f7d53f7116d9565d22b2eca1e93",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1290,6 +1290,24 @@
|
|
|
1290
1290
|
"sourceRef": "message-board",
|
|
1291
1291
|
"bytes": 7556,
|
|
1292
1292
|
"sha256": "670d6e97ca84ef74ebd10a6ee102c328005a28aa48f8668ba444ce31b714cf02"
|
|
1293
|
+
},
|
|
1294
|
+
{
|
|
1295
|
+
"order": 138,
|
|
1296
|
+
"id": "20260921000100_agent_board_task_event_origin_posture",
|
|
1297
|
+
"filename": "20260921000100_agent_board_task_event_origin_posture.sql",
|
|
1298
|
+
"origin": "message-board-forward",
|
|
1299
|
+
"sourceRef": "message-board",
|
|
1300
|
+
"bytes": 48237,
|
|
1301
|
+
"sha256": "c515e9cd08b73c118de64125da94735979add5d1128d3ff070ff904af77fef81"
|
|
1302
|
+
},
|
|
1303
|
+
{
|
|
1304
|
+
"order": 139,
|
|
1305
|
+
"id": "20260922000100_agent_board_board_claim_policy",
|
|
1306
|
+
"filename": "20260922000100_agent_board_board_claim_policy.sql",
|
|
1307
|
+
"origin": "message-board-forward",
|
|
1308
|
+
"sourceRef": "message-board",
|
|
1309
|
+
"bytes": 22285,
|
|
1310
|
+
"sha256": "1363a194f50342feb613a1aa1e01321a2a81783d88dfebe2c8f2002170cdc25c"
|
|
1293
1311
|
}
|
|
1294
1312
|
],
|
|
1295
1313
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 =
|
|
41
|
+
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 140)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(140);
|
|
44
44
|
// MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
|
|
45
45
|
// schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
|
|
46
46
|
// enrollment-only additive surface (identity_providers registry, enrollment audit,
|
|
@@ -233,6 +233,26 @@ describe('manifest integrity', () => {
|
|
|
233
233
|
// sources still takes the permissive branch. Definition-only: CREATE OR REPLACE with the
|
|
234
234
|
// identical signature, grants and COMMENT restated in the same migration.
|
|
235
235
|
'20260920000100_agent_board_realtime_credential_claim.sql',
|
|
236
|
+
// #288 ORIGINPOSTURE. v_task_tree.verification_status was NULL for every task_event
|
|
237
|
+
// except the two written by a signed wrapper, and NULL cannot be told apart from
|
|
238
|
+
// "evidence was lost", "the writer forgot" and "unsigned on purpose". The class was
|
|
239
|
+
// derived by CALL CLOSURE (a member iff its closure reaches an INSERT INTO task_events):
|
|
240
|
+
// 12 members, where the issue body said 8 and its own triage comment said 5-6 -- neither
|
|
241
|
+
// naming create_tasking_delivery or signed_create_tasking_delivery. task_events gains
|
|
242
|
+
// origin_posture NOT NULL with NO DEFAULT, so a new direct-INSERT writer cannot insert at
|
|
243
|
+
// all; the funnel body moves behind an ungranted _append_task_event whose posture
|
|
244
|
+
// parameter is FIRST and undefaulted, which is what makes "no default" structural --
|
|
245
|
+
// PostgreSQL refuses a default before non-defaulted parameters. Nothing is DROPped: both
|
|
246
|
+
// public signatures stay byte-identical, so ACLs and COMMENTs survive.
|
|
247
|
+
'20260921000100_agent_board_task_event_origin_posture.sql',
|
|
248
|
+
// BOARDCLAIMPOLICY. A board's work may be coordinated by SOMEONE ELSE'S locking -- a repo
|
|
249
|
+
// lock, a scheduler lease, an external mutex -- and our claim guards must not break a system
|
|
250
|
+
// that was already working. boards.claim_policy lets the board OWNER opt out: 'inherit'
|
|
251
|
+
// (default, today's behaviour) or 'advisory' (record claims, never enforce them). It
|
|
252
|
+
// disables the OWNERLESS file/tool/custom lease cap and quota ONLY, and cannot reach the
|
|
253
|
+
// fleet-authority gate on machine/repository/repo_file -- a board may decline our lock,
|
|
254
|
+
// never grant itself another owner's resource (BCP-6 asserts that from the catalog).
|
|
255
|
+
'20260922000100_agent_board_board_claim_policy.sql',
|
|
236
256
|
]) {
|
|
237
257
|
expect(names.has(f)).toBe(true);
|
|
238
258
|
}
|
|
@@ -72,10 +72,15 @@ begin
|
|
|
72
72
|
(v_task_a, v_thread_a, v_agent_a, v_agent_a, v_task_a, 'Auth RLS Task A', 'medium', 'distributed'),
|
|
73
73
|
(v_task_b, v_thread_b, v_agent_b, v_agent_b, v_task_b, 'Auth RLS Task B', 'medium', 'distributed');
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
-- #288: origin_posture is NOT NULL with no default, so this raw fixture insert must declare
|
|
76
|
+
-- one like any other writer. It emulates create_task's seq-1 'created' event, so it declares
|
|
77
|
+
-- create_task's own posture rather than a test-only placeholder -- a fixture that labelled
|
|
78
|
+
-- itself differently from the writer it stands in for would make OP-15-style invariants
|
|
79
|
+
-- assert against a shape production never produces.
|
|
80
|
+
insert into agent_board.task_events(task_id, thread_id, actor_agent_id, event_sequence, event_type, payload, origin_posture)
|
|
76
81
|
values
|
|
77
|
-
(v_task_a, v_thread_a, v_agent_a, 1, 'created', '{}'::jsonb),
|
|
78
|
-
(v_task_b, v_thread_b, v_agent_b, 1, 'created', '{}'::jsonb);
|
|
82
|
+
(v_task_a, v_thread_a, v_agent_a, 1, 'created', '{}'::jsonb, 'unattested:signed-metadata-rpc'),
|
|
83
|
+
(v_task_b, v_thread_b, v_agent_b, 1, 'created', '{}'::jsonb, 'unattested:signed-metadata-rpc');
|
|
79
84
|
|
|
80
85
|
insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
|
|
81
86
|
values
|