@consiliency/agent-board-schema 1.45.0 → 1.50.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 (35) hide show
  1. package/fixtures/already-applied.json +24 -0
  2. package/migrations/20260926000100_agent_board_ownerless_quota_census.sql +268 -0
  3. package/migrations/20260927000100_agent_board_fleet_domain_no_downgrade.sql +393 -0
  4. package/migrations/20260928000100_agent_board_ownerless_quota_renew_convergence.sql +562 -0
  5. package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
  6. package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
  7. package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
  8. package/package.json +1 -1
  9. package/schema-manifest.json +57 -3
  10. package/src/tooling.test.mjs +52 -2
  11. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  12. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  13. package/tests/agent_board_claimkey.test.sql +17 -2
  14. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  15. package/tests/agent_board_fleet_machine_hijack.test.sql +270 -1
  16. package/tests/agent_board_fleet_machine_reclaim.test.sql +28 -2
  17. package/tests/agent_board_ownerless_quota_census.test.sql +406 -0
  18. package/tests/agent_board_ownerless_renew_quota.test.sql +580 -0
  19. package/tests/catalog/p_metadata_parameters.json +28 -0
  20. package/tests/fixtures/test-prereqs.sql +186 -0
  21. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  22. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  23. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  24. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  25. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  26. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  27. package/tests/isolation/expected/fleet_domain_no_downgrade_demote_race.out +447 -0
  28. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  29. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  30. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  31. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  32. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  33. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  34. package/tests/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -0
  35. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -572,6 +572,30 @@
572
572
  {
573
573
  "id": "20260925000100_agent_board_ownerless_renew_ttl_cap",
574
574
  "sha256": "da312f43cabf07c6dfd10dbe2a119fb46f49f24155e21adce4f38e291a38103c"
575
+ },
576
+ {
577
+ "id": "20260926000100_agent_board_ownerless_quota_census",
578
+ "sha256": "bbfabcb2d45cbcebd1e6e121032a1045e8406ea8763a784762a92d210e1e1043"
579
+ },
580
+ {
581
+ "id": "20260928000100_agent_board_ownerless_quota_renew_convergence",
582
+ "sha256": "9982efff25378703a744187b15081b5cd53ab38fa6ac7d2950092a471780169e"
583
+ },
584
+ {
585
+ "id": "20260929000100_agent_board_advisory_revert_cap_reset",
586
+ "sha256": "f2bd4e9e06f5c348aba379a004a010d8dba29a84f66b1b4569313fad4a0046ba"
587
+ },
588
+ {
589
+ "id": "20260930000100_agent_board_claim_policy_race",
590
+ "sha256": "1893b57ccb643a8293c0dc801d2d66d20fe1d71a2b7dc95cd93f64ced39b472d"
591
+ },
592
+ {
593
+ "id": "20260927000100_agent_board_fleet_domain_no_downgrade",
594
+ "sha256": "ec5c4d89aff2d6d77d44cb3d00c1096b39010c6064c23d72eb3c5c354f8a5e74"
595
+ },
596
+ {
597
+ "id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
598
+ "sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
575
599
  }
576
600
  ]
577
601
  }
@@ -0,0 +1,268 @@
1
+ -- #388 — the ownerless quota bounds NEW ACQUISITIONS but never CONVERGES an agent already over it.
2
+ --
3
+ -- THE PROPERTY, stated as the issue states it. `acquire_claim` enforces a per-agent ceiling of
4
+ -- `_claimgate_ownerless_quota()` (50) concurrent active file/tool/custom claims. `renew_claim`
5
+ -- does not, and #387 deliberately did not add it — that PR closed the TTL half only. So the quota
6
+ -- is effective against GROWTH and not against PERSISTENCE: an agent already over it renews every
7
+ -- one of its claims indefinitely and stays over, forever.
8
+ --
9
+ -- THREE WAYS AN AGENT IS ALREADY OVER, all real and none hypothetical:
10
+ -- 1. claims acquired while `ownerless_guards` was OFF — the flag defaults disabled and was
11
+ -- flipped in production only recently (#369), so everything accumulated before the flip is
12
+ -- grandfathered;
13
+ -- 2. claims acquired under a board that was `claim_policy = 'advisory'` and later set back to
14
+ -- `inherit` — the acquire-time guard is skipped for an advisory board (20260922000100), but
15
+ -- the resulting rows are counted by every LATER check;
16
+ -- 3. a quota value lowered after the fact.
17
+ --
18
+ -- ---------------------------------------------------------------------------
19
+ -- WHAT THIS MIGRATION IS, AND THE THREE THINGS IT DELIBERATELY IS NOT
20
+ -- ---------------------------------------------------------------------------
21
+ --
22
+ -- IT IS A READ-ONLY CENSUS. #388 step 3: "which agents are currently over quota on the live
23
+ -- board". Nothing here writes, blocks, clamps or refuses.
24
+ --
25
+ -- IT IS NOT ENFORCEMENT, and that is a decision rather than an omission. Acquire refusing costs
26
+ -- the caller nothing — it asked for a new claim and did not get it. Renew refusing would KILL A
27
+ -- LIVE LEASE the agent is currently relying on, and if an agent is 10 over, the reasonable
28
+ -- behaviour is not "fail 10 renews at random", it is a decision about WHICH claims lose (oldest
29
+ -- first? least recently touched? notify the agent?). That is a product question nobody has
30
+ -- answered. This is the instrument that informs it. Enforcement, if it is chosen, additionally
31
+ -- needs the agent-scoped `pg_advisory_xact_lock` acquire already takes plus a two-session
32
+ -- isolationtester case (#137's harness), because two concurrent renews must not both observe room.
33
+ --
34
+ -- IT IS NOT A SCRIPT, and the alternative was considered rather than skipped. A `scripts/*.mjs`
35
+ -- check with the house 0/1/2 exit contract answers a question about whatever database it is
36
+ -- pointed at; the population that matters is PRODUCTION, and the operator path to production is
37
+ -- `service_role` SQL through the Management API, where a view and a function are directly
38
+ -- readable and a Node script is not runnable at all. A CI invocation would examine an empty
39
+ -- freshly-built database and report "0 of 0" — true, and worthless. And an exit code is a GATE:
40
+ -- 1 means "fail the build", which presumes a policy about what an over-quota agent should cause
41
+ -- to happen. #388 explicitly defers that policy, so there is nothing yet for a non-zero exit to
42
+ -- mean. When the convergence policy exists, a script that gates on this view is the natural next
43
+ -- artifact; it is not this one.
44
+ --
45
+ -- ---------------------------------------------------------------------------
46
+ -- THE NUMERATOR IS ACQUIRE'S OWN COUNT PREDICATE, VERBATIM
47
+ -- ---------------------------------------------------------------------------
48
+ -- A census whose arithmetic differs from the enforcer's is a census about a different question,
49
+ -- and it would be silently wrong rather than visibly wrong. The count below is copied from the
50
+ -- quota block of the CURRENT `acquire_claim` (20260922000100 — note that migration re-creates it
51
+ -- from a `pg_get_functiondef` dump in UPPERCASE, so a lowercase grep for the definer finds
52
+ -- 20260914000100 and reads the wrong body):
53
+ --
54
+ -- where c.agent_id = v_actor_agent_id
55
+ -- and c.resource_kind in ('file', 'tool', 'custom')
56
+ -- and c.status = 'active'
57
+ -- and c.released_at is null
58
+ -- and c.lease_expires_at > now()
59
+ --
60
+ -- NO BOARD-POLICY FILTER, and this is the subtle one. The advisory board opt-out gates WHETHER
61
+ -- the guard runs for the acquisition being attempted; it does not exclude the resulting rows from
62
+ -- the count. So an agent's 60 claims count as 60 even if 20 of them were taken on advisory
63
+ -- boards. Filtering them out here would produce a smaller, friendlier, WRONG number that does not
64
+ -- predict what acquire does. Stated operationally, a row in this view with `acquire_blocked` true
65
+ -- means: this agent's next ownerless acquire, on an `inherit` board, with the guards on, raises
66
+ -- 54000.
67
+ --
68
+ -- `claims_on_advisory_boards` is reported as a SEPARATE column precisely because it is the thing
69
+ -- the convergence decision needs and the thing the headline count must not absorb — refusing a
70
+ -- claim that another board's own lock already granted is us breaking a promise, so an operator
71
+ -- weighing "which claims lose" needs to see that share without it distorting the ceiling test.
72
+ --
73
+ -- OVERAGE AND ACQUIRE_BLOCKED ARE DIFFERENT NUMBERS AND BOTH ARE REPORTED. Acquire refuses at
74
+ -- `count >= quota`, so an agent holding exactly 50 is already frozen while being over by nothing.
75
+ -- * `overage` = greatest(count - quota, 0) — how many claims exceed the ceiling. This is
76
+ -- the convergence question: what would have to be given up.
77
+ -- * `acquire_blocked` = count >= quota — acquire's refusal predicate, verbatim. This is the
78
+ -- "why can my agent not take a lock" question.
79
+ -- Collapsing them into one boolean would answer one of those two questions wrongly.
80
+ --
81
+ -- ---------------------------------------------------------------------------
82
+ -- THE DENOMINATOR IS NOT OPTIONAL
83
+ -- ---------------------------------------------------------------------------
84
+ -- The live answer today is expected to be ZERO agents over quota. A reader that emits "0
85
+ -- offenders" and nothing else is byte-identical whether it examined 400 agents or examined
86
+ -- nothing at all — which is exactly the defect #367 shipped a migration to close, where
87
+ -- `v_ops_presence_heartbeats` returned zero rows about an entirely unmonitored fleet and read as
88
+ -- perfect health. So `ownerless_quota_census()` always emits its population alongside its finding:
89
+ --
90
+ -- 0 over quota, of 12 agents holding ownerless claims, across 47 active agents
91
+ --
92
+ -- and it additionally emits `guards_enabled` and `quota`, so the output states the REGIME it
93
+ -- measured under. "0 over quota" with `guards_enabled = false` is a materially different fact
94
+ -- from the same number with the flag on, and a reader that reports the count without the flag
95
+ -- invites exactly the rule-5 misreading — a true statement about a moment, still asserting itself
96
+ -- after the moment moved.
97
+ --
98
+ -- ---------------------------------------------------------------------------
99
+ -- The view. `security_invoker` matching every other view in agent_board; the grant below is what
100
+ -- actually confines it, and the two are not redundant (see the grant comment).
101
+ -- ---------------------------------------------------------------------------
102
+ create or replace view agent_board.v_ownerless_claim_quota
103
+ with (security_invoker = true)
104
+ as
105
+ with holders as (
106
+ select
107
+ c.agent_id as agent_id,
108
+ count(*)::integer as active_ownerless_claims,
109
+ -- The advisory share, resolved per claim through its OWN thread. A claim with no thread, or a
110
+ -- thread with no board, resolves to 'inherit' by the helper's own contract — so absence never
111
+ -- reads as an opt-out here either.
112
+ count(*) filter (
113
+ where agent_board._claimgate_board_claim_policy(c.thread_id) = 'advisory'
114
+ )::integer as claims_on_advisory_boards,
115
+ count(*) filter (where c.resource_kind = 'file')::integer as file_claims,
116
+ count(*) filter (where c.resource_kind = 'tool')::integer as tool_claims,
117
+ count(*) filter (where c.resource_kind = 'custom')::integer as custom_claims,
118
+ min(c.created_at) as oldest_claim_at,
119
+ max(c.created_at) as newest_claim_at,
120
+ max(c.lease_expires_at) as latest_lease_expires_at
121
+ from agent_board.claims c
122
+ -- ACQUIRE'S PREDICATE, VERBATIM. Every conjunct is load-bearing and each has a near-miss row in
123
+ -- the pgTAP fixture: a released claim, an expired-status claim, a lapsed-lease claim, and an
124
+ -- owned-kind claim all sit next to the counted ones so that dropping any conjunct here fails a
125
+ -- named assertion rather than passing quietly.
126
+ where c.resource_kind in ('file', 'tool', 'custom')
127
+ and c.status = 'active'
128
+ and c.released_at is null
129
+ and c.lease_expires_at > now()
130
+ group by c.agent_id
131
+ )
132
+ select
133
+ h.agent_id,
134
+ -- Identity sufficient to ACT on the row without a second query: who to contact, and whether the
135
+ -- agent is even still in service. `status` is REPORTED, never filtered on — a suspended agent
136
+ -- holding 60 live leases is precisely the row an operator most needs to see, and a
137
+ -- `status = 'active'` filter here would hide it.
138
+ a.handle as agent_handle,
139
+ a.display_name as agent_display_name,
140
+ a.status as agent_status,
141
+ h.active_ownerless_claims,
142
+ agent_board._claimgate_ownerless_quota() as quota,
143
+ greatest(h.active_ownerless_claims - agent_board._claimgate_ownerless_quota(), 0)::integer
144
+ as overage,
145
+ (h.active_ownerless_claims >= agent_board._claimgate_ownerless_quota())
146
+ as acquire_blocked,
147
+ h.claims_on_advisory_boards,
148
+ h.file_claims,
149
+ h.tool_claims,
150
+ h.custom_claims,
151
+ h.oldest_claim_at,
152
+ h.newest_claim_at,
153
+ h.latest_lease_expires_at
154
+ from holders h
155
+ join agent_board.agents a on a.id = h.agent_id;
156
+
157
+ comment on view agent_board.v_ownerless_claim_quota is
158
+ '#388 read-only census: one row per agent HOLDING at least one active ownerless (file/tool/'
159
+ 'custom) claim, with its count, the quota, and the overage. The count is acquire_claim''s own '
160
+ 'quota predicate verbatim (kind in file/tool/custom, status active, released_at null, lease '
161
+ 'unexpired) with NO board-policy filter, because the advisory opt-out gates whether the guard '
162
+ 'RUNS for an acquisition, not whether the resulting rows COUNT — so acquire_blocked (count >= '
163
+ 'quota) predicts acquire''s 54000 exactly. overage (count - quota, floored at 0) is the '
164
+ 'different question of how many claims exceed the ceiling; an agent at exactly the quota is '
165
+ 'blocked while over by nothing. claims_on_advisory_boards is reported separately because it '
166
+ 'informs the convergence decision without distorting the ceiling test. agent_status is reported '
167
+ 'and never filtered: a suspended agent holding live leases is the row that matters most. '
168
+ 'REPORT-ONLY — nothing here refuses, clamps or converges anything; see #388 for why refusing a '
169
+ 'renew is a product decision and not a clamp. Read agent_board.ownerless_quota_census() FIRST '
170
+ 'for the denominator. security_invoker, service_role only.';
171
+
172
+ -- ---------------------------------------------------------------------------
173
+ -- The summary. Its only job is to make an empty result impossible to misread.
174
+ -- ---------------------------------------------------------------------------
175
+ create or replace function agent_board.ownerless_quota_census()
176
+ returns table(
177
+ guards_enabled boolean,
178
+ quota integer,
179
+ agents_active integer,
180
+ agents_holding_ownerless_claims integer,
181
+ agents_over_quota integer,
182
+ agents_acquire_blocked integer,
183
+ total_active_ownerless_claims integer,
184
+ total_overage integer
185
+ )
186
+ language sql
187
+ stable
188
+ security invoker
189
+ set search_path = agent_board, public
190
+ as $$
191
+ select
192
+ -- The REGIME first: a count without the flag that governs it is a rule-5 fact with no
193
+ -- expiry. "0 over quota" means something different when the guards are off.
194
+ agent_board._claimgate_ownerless_guards_enabled() as guards_enabled,
195
+ agent_board._claimgate_ownerless_quota() as quota,
196
+ -- The widest denominator: the fleet. Non-zero even when NOBODY holds an ownerless claim, so
197
+ -- "0 of 0" can still be distinguished from "0 of 0, of 0" — a database this reader cannot
198
+ -- see into at all.
199
+ (select count(*)::integer from agent_board.agents where status = 'active')
200
+ as agents_active,
201
+ -- The population actually examined. THIS is the number that makes a zero finding meaningful.
202
+ -- It is counted from the view directly and NOT derived from the offender set — deriving it
203
+ -- would make it 0 exactly when the finding is 0, which is the vacuous shape this exists to
204
+ -- prevent.
205
+ (select count(*)::integer from agent_board.v_ownerless_claim_quota)
206
+ as agents_holding_ownerless_claims,
207
+ (select count(*) filter (where overage > 0)::integer
208
+ from agent_board.v_ownerless_claim_quota) as agents_over_quota,
209
+ (select count(*) filter (where acquire_blocked)::integer
210
+ from agent_board.v_ownerless_claim_quota) as agents_acquire_blocked,
211
+ (select coalesce(sum(active_ownerless_claims), 0)::integer
212
+ from agent_board.v_ownerless_claim_quota) as total_active_ownerless_claims,
213
+ (select coalesce(sum(overage), 0)::integer
214
+ from agent_board.v_ownerless_claim_quota) as total_overage;
215
+ $$;
216
+
217
+ comment on function agent_board.ownerless_quota_census() is
218
+ '#388 denominator. Returns agents_holding_ownerless_claims and agents_active ALONGSIDE '
219
+ 'agents_over_quota, so "0 over quota" can never be rendered as "all clear" — 0 over, of 0 '
220
+ 'holders, across 47 active agents, is a reader that examined nothing, and it reads as one '
221
+ '(the #367 defect shape). Also emits guards_enabled and quota so the output states the regime '
222
+ 'it measured under. agents_holding_ownerless_claims is counted from the view, NOT derived from '
223
+ 'the offender set: a denominator that collapses to 0 exactly when the finding is 0 proves '
224
+ 'nothing. agents_over_quota counts overage > 0; agents_acquire_blocked counts count >= quota, '
225
+ 'which is acquire_claim''s refusal predicate and is the strictly larger set. '
226
+ 'security invoker + stable: a projection of v_ownerless_claim_quota, inheriting the caller''s '
227
+ 'RLS.';
228
+
229
+ -- ---------------------------------------------------------------------------
230
+ -- SERVICE_ROLE ONLY, and this is the most important statement in the file.
231
+ --
232
+ -- `security_invoker` does not make an `authenticated` grant safe here; it makes it dangerous, and
233
+ -- for the mirror-image reason #367 found. The RLS SELECT policy on `agent_board.claims` is:
234
+ --
235
+ -- agent_id = current_agent_id()
236
+ -- or exists (select 1 from tasks t where t.id = task_id
237
+ -- and (t.created_by_agent_id = current_agent_id()
238
+ -- or t.assigned_agent_id = current_agent_id()))
239
+ --
240
+ -- so an authenticated caller can see essentially ITS OWN claims. Read by that caller this view
241
+ -- returns one row — itself — and the census reports `agents_holding_ownerless_claims = 1`,
242
+ -- `agents_over_quota = 0`. Not an error, not an empty result: a confident, plausible, fleet-shaped
243
+ -- answer that is wrong, and wrong in the reassuring direction. Two agents would get two different
244
+ -- censuses with no way to tell which was right, and the one that mattered — the agent sitting at
245
+ -- 60 claims — would be invisible to everyone except itself.
246
+ --
247
+ -- #367 hit this as OVER-reporting (a LEFT JOIN turned "hidden from you" into an alarm). Here the
248
+ -- join is an inner aggregate, so it is UNDER-reporting, which is worse for a census whose whole
249
+ -- purpose is to find the outliers: over-reporting is noisy and gets investigated, under-reporting
250
+ -- is silence with a denominator attached to make it look diligent.
251
+ --
252
+ -- An authenticated agent cannot answer "who in the fleet is over quota" at all, because it cannot
253
+ -- see anyone else's claims. That is the RLS model working, not a gap here, and the honest response
254
+ -- is to withhold the surface rather than serve a per-caller answer that reads fleet-wide. Quota
255
+ -- convergence is an operations question and this is an operations surface.
256
+ --
257
+ -- `security_invoker` is kept anyway as defence in depth: if a future migration widens the grant,
258
+ -- rows are still RLS-filtered rather than leaked wholesale. OQ-8/OQ-9 pin both the view option and
259
+ -- the grant so widening either fails a test that says why.
260
+ -- ---------------------------------------------------------------------------
261
+ grant select on agent_board.v_ownerless_claim_quota to service_role;
262
+
263
+ -- NOT REDUNDANT — this is the AL-9b trap from #367, which arrives on a plain
264
+ -- `create or replace function` and not only on a DROP. A newly created function is granted
265
+ -- EXECUTE to PUBLIC by default, so `authenticated` inherits it through PUBLIC and granting only
266
+ -- service_role above would change nothing on its own.
267
+ revoke all on function agent_board.ownerless_quota_census() from public;
268
+ grant execute on function agent_board.ownerless_quota_census() to service_role;