@consiliency/agent-board-schema 1.43.0 → 1.44.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 +4 -0
- package/migrations/20260924000100_agent_board_agent_liveness_denominator.sql +169 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +11 -2
- package/tests/agent_board_agent_liveness.test.sql +271 -0
- package/tests/catalog/p_metadata_parameters.json +4 -0
|
@@ -564,6 +564,10 @@
|
|
|
564
564
|
{
|
|
565
565
|
"id": "20260923000100_agent_board_heartbeat_preserve",
|
|
566
566
|
"sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
"id": "20260924000100_agent_board_agent_liveness_denominator",
|
|
570
|
+
"sha256": "deb58ead6c4d7bbda686b85191d5eb2f2771ae2a405933ffbfbaabd7ad0117d9"
|
|
567
571
|
}
|
|
568
572
|
]
|
|
569
573
|
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
-- #367 — a liveness reader that cannot report "all clear" about a fleet it is not watching.
|
|
2
|
+
--
|
|
3
|
+
-- THE DEFECT THIS CLOSES. `v_ops_presence_heartbeats` selects FROM `agent_board.presence`:
|
|
4
|
+
--
|
|
5
|
+
-- from agent_board.presence p
|
|
6
|
+
-- join agent_board.agents a on a.id = p.agent_id
|
|
7
|
+
-- where p.expires_at < now();
|
|
8
|
+
--
|
|
9
|
+
-- `presence` is an upsert keyed by `agent_id`, written only by `heartbeat`. An agent that has
|
|
10
|
+
-- NEVER heartbeated has no row, and a row that does not exist cannot be expired. So that view
|
|
11
|
+
-- detects exactly one thing: "was reporting, then stopped". It is structurally blind to
|
|
12
|
+
-- "never reported."
|
|
13
|
+
--
|
|
14
|
+
-- MEASURED, not theorised. On the canonical project on 2026-08-29, immediately after #366 shipped
|
|
15
|
+
-- the writer half: `presence` held 0 rows while 8 agents were `status = 'active'`. Every one of
|
|
16
|
+
-- them was unmonitored, and `select * from v_ops_presence_heartbeats` returned zero rows — output
|
|
17
|
+
-- byte-identical to a fleet in perfect health. That is an absence of signal carrying an alarm's
|
|
18
|
+
-- authority, which is worse than no reader at all, because a reader is something people trust.
|
|
19
|
+
--
|
|
20
|
+
-- THE FIX IS THE JOIN DIRECTION. This view selects FROM `agents` and LEFT JOINs `presence`, so a
|
|
21
|
+
-- never-reporting agent is NAMED rather than missing. That is the whole idea; everything else here
|
|
22
|
+
-- follows from it.
|
|
23
|
+
--
|
|
24
|
+
-- WHAT THIS DELIBERATELY DOES NOT CLAIM. #367 offers two shapes: (1) scope the claim to what the
|
|
25
|
+
-- data supports, and (2) establish an expectation so that never-reporting is itself an anomaly.
|
|
26
|
+
-- This is (1). `never_reported` is a REPORTED STATE, not a judgement — the view says "this agent
|
|
27
|
+
-- has no presence row", never "this agent is stalled". Shape (2) would require deciding what
|
|
28
|
+
-- "expected to be alive" means for an agent that is idle by design, and #366 is deliberate that an
|
|
29
|
+
-- agent waiting on a human is indistinguishable from a stalled one from outside the process.
|
|
30
|
+
-- Naming the state without asserting it is abnormal is how this stays honest while still being
|
|
31
|
+
-- strictly more informative than the view it complements.
|
|
32
|
+
--
|
|
33
|
+
-- WHY THE SUMMARY FUNCTION EXISTS AT ALL. A caller that reads only rows still cannot tell
|
|
34
|
+
-- "0 stale agents" from "nobody reports". The summary always emits `active_agents` alongside
|
|
35
|
+
-- `reporting_agents`, so an empty stale set is reported as `0 stale of 3 reporting, of 8 active`
|
|
36
|
+
-- and can never be rendered as "all clear". #367's first acceptance criterion is that the
|
|
37
|
+
-- denominator appears in the output; putting it in a function rather than in a comment is what
|
|
38
|
+
-- makes that mechanical.
|
|
39
|
+
|
|
40
|
+
-- ---------------------------------------------------------------------------
|
|
41
|
+
-- The view. `security_invoker` so RLS on `agents`/`presence` applies to the READER, matching
|
|
42
|
+
-- every other view in agent_board — a definer view here would hand every caller a fleet-wide
|
|
43
|
+
-- roster regardless of what they may see.
|
|
44
|
+
-- ---------------------------------------------------------------------------
|
|
45
|
+
create or replace view agent_board.v_agent_liveness
|
|
46
|
+
with (security_invoker = true)
|
|
47
|
+
as
|
|
48
|
+
select
|
|
49
|
+
a.id as agent_id,
|
|
50
|
+
a.handle as agent_handle,
|
|
51
|
+
-- The vocabulary is closed and total: every active agent lands in exactly one of these.
|
|
52
|
+
-- `never_reported` is FIRST because it is the case the view exists to make visible.
|
|
53
|
+
case
|
|
54
|
+
when p.agent_id is null then 'never_reported'
|
|
55
|
+
when p.expires_at < now() then 'stale'
|
|
56
|
+
else 'fresh'
|
|
57
|
+
end as liveness_state,
|
|
58
|
+
p.availability,
|
|
59
|
+
p.thread_id,
|
|
60
|
+
p.activity_summary,
|
|
61
|
+
p.last_heartbeat_at,
|
|
62
|
+
p.expires_at,
|
|
63
|
+
-- NULL rather than a number for a never-reporting agent. A zero here would read as
|
|
64
|
+
-- "reported just now", which is the opposite of the truth.
|
|
65
|
+
case
|
|
66
|
+
when p.last_heartbeat_at is null then null
|
|
67
|
+
else round(extract(epoch from (now() - p.last_heartbeat_at)) / 60.0, 2)
|
|
68
|
+
end as heartbeat_lag_minutes
|
|
69
|
+
from agent_board.agents a
|
|
70
|
+
left join agent_board.presence p on p.agent_id = a.id
|
|
71
|
+
where a.status = 'active';
|
|
72
|
+
|
|
73
|
+
comment on view agent_board.v_agent_liveness is
|
|
74
|
+
'#367 liveness reader. Selects FROM agents and LEFT JOINs presence, so an agent that has NEVER '
|
|
75
|
+
'heartbeated is NAMED with liveness_state = never_reported rather than being absent — the blind '
|
|
76
|
+
'spot in v_ops_presence_heartbeats, which selects from presence and therefore cannot see an '
|
|
77
|
+
'agent with no row. liveness_state is one of never_reported / stale / fresh and is a REPORTED '
|
|
78
|
+
'STATE, not a judgement: never_reported means "no presence row", NOT "stalled". Deciding that '
|
|
79
|
+
'idleness is abnormal is #367 shape (2) and is deliberately not done here. security_invoker, so '
|
|
80
|
+
'RLS applies to the reader.';
|
|
81
|
+
|
|
82
|
+
-- ---------------------------------------------------------------------------
|
|
83
|
+
-- The summary. Its ONLY job is to make an empty result impossible to misread.
|
|
84
|
+
-- ---------------------------------------------------------------------------
|
|
85
|
+
create or replace function agent_board.agent_liveness_summary()
|
|
86
|
+
returns table(
|
|
87
|
+
active_agents integer,
|
|
88
|
+
reporting_agents integer,
|
|
89
|
+
fresh integer,
|
|
90
|
+
stale integer,
|
|
91
|
+
never_reported integer
|
|
92
|
+
)
|
|
93
|
+
language sql
|
|
94
|
+
stable
|
|
95
|
+
security invoker
|
|
96
|
+
set search_path = agent_board, public
|
|
97
|
+
as $$
|
|
98
|
+
select
|
|
99
|
+
count(*)::integer as active_agents,
|
|
100
|
+
count(*) filter (where liveness_state <> 'never_reported')::integer as reporting_agents,
|
|
101
|
+
count(*) filter (where liveness_state = 'fresh')::integer as fresh,
|
|
102
|
+
count(*) filter (where liveness_state = 'stale')::integer as stale,
|
|
103
|
+
count(*) filter (where liveness_state = 'never_reported')::integer as never_reported
|
|
104
|
+
from agent_board.v_agent_liveness;
|
|
105
|
+
$$;
|
|
106
|
+
|
|
107
|
+
comment on function agent_board.agent_liveness_summary() is
|
|
108
|
+
'#367 denominator. Returns active_agents and reporting_agents ALONGSIDE the stale count, so a '
|
|
109
|
+
'caller can never render "0 stale" as "all clear" — 0 stale of 0 reporting, of 8 active, is an '
|
|
110
|
+
'unmonitored fleet, and it reads as one. The four state counts sum to active_agents by '
|
|
111
|
+
'construction because v_agent_liveness assigns every active agent exactly one state. '
|
|
112
|
+
'security invoker + stable: it is a projection of the view and inherits the caller''s RLS.';
|
|
113
|
+
|
|
114
|
+
-- ---------------------------------------------------------------------------
|
|
115
|
+
-- The OLD view is left in service deliberately — it answers a narrower question correctly and
|
|
116
|
+
-- operators' saved queries reference it — but it now carries its own blind spot in the catalog.
|
|
117
|
+
-- Two review seats independently made the same point: shipping a fix while the DOCUMENTED alarm
|
|
118
|
+
-- path still has the defect closes an issue without closing the exposure. `specs/operations.md`
|
|
119
|
+
-- was repointed in the same change; this comment is for whoever reads the catalog instead.
|
|
120
|
+
-- ---------------------------------------------------------------------------
|
|
121
|
+
comment on view agent_board.v_ops_presence_heartbeats is
|
|
122
|
+
'Expired presence leases. BLIND SPOT, by construction: it selects FROM presence, and presence '
|
|
123
|
+
'has no row for an agent that has NEVER heartbeated — so this detects only "was reporting, then '
|
|
124
|
+
'stopped" and an empty result is byte-identical to a healthy fleet. Measured 2026-08-29 on the '
|
|
125
|
+
'canonical project: 0 presence rows, 8 active agents, this view returned nothing. For "who in '
|
|
126
|
+
'the fleet is alive" use agent_board.v_agent_liveness and agent_board.agent_liveness_summary() '
|
|
127
|
+
'(#367), which select FROM agents and state their denominator. This view remains correct for '
|
|
128
|
+
'the narrower question it actually answers.';
|
|
129
|
+
|
|
130
|
+
-- ---------------------------------------------------------------------------
|
|
131
|
+
-- SERVICE_ROLE ONLY, and this is the most important line in the file.
|
|
132
|
+
--
|
|
133
|
+
-- The first version of this migration granted both to `authenticated` as well, reasoning that
|
|
134
|
+
-- `security_invoker` made that safe because RLS would apply to the reader. That reasoning was
|
|
135
|
+
-- exactly backwards, and a red-team review seat found it. RLS does not merely LIMIT what an
|
|
136
|
+
-- authenticated caller sees here — it CHANGES WHAT THE ANSWER MEANS:
|
|
137
|
+
--
|
|
138
|
+
-- * `agents` is `using (current_agent_id() is not null)` → an agent sees EVERY agent row.
|
|
139
|
+
-- * `presence` is `using (agent_id = current_agent_id())` → an agent sees ONLY ITS OWN row.
|
|
140
|
+
--
|
|
141
|
+
-- So for an authenticated caller the LEFT JOIN converts "this row is invisible to you" into
|
|
142
|
+
-- `p.agent_id is null`, which this view then labels `never_reported`. Measured on a local cluster
|
|
143
|
+
-- with TWO genuinely healthy agents, both with fresh presence rows:
|
|
144
|
+
--
|
|
145
|
+
-- as service_role → fresh=2 never_reported=0 (true)
|
|
146
|
+
-- as authenticated agent one → fresh=1 never_reported=1 (FABRICATED)
|
|
147
|
+
--
|
|
148
|
+
-- The reader would invent the exact alarm it exists to report, and it would do so confidently and
|
|
149
|
+
-- per-caller, so two readers would disagree with no way to tell which was right. That is strictly
|
|
150
|
+
-- worse than the blind spot this migration closes: `v_ops_presence_heartbeats` UNDER-reports
|
|
151
|
+
-- (silence), and this would OVER-report (a false positive with an agent's name on it).
|
|
152
|
+
--
|
|
153
|
+
-- An authenticated agent cannot answer "who in the fleet is alive" at all, because it cannot see
|
|
154
|
+
-- anyone else's presence. That is a property of the RLS model, not a gap in this view, and the
|
|
155
|
+
-- honest response is to withhold the surface rather than to serve a per-caller answer that reads
|
|
156
|
+
-- like a fleet-wide one. Liveness is an operations question and this is an operations surface.
|
|
157
|
+
--
|
|
158
|
+
-- `security_invoker` is KEPT even though only service_role is granted: it is defence in depth, so
|
|
159
|
+
-- that if a future migration widens the grant the rows are still RLS-filtered rather than leaked
|
|
160
|
+
-- wholesale. AL-9 pins the grant so widening it fails a test that says why.
|
|
161
|
+
-- ---------------------------------------------------------------------------
|
|
162
|
+
grant select on agent_board.v_agent_liveness to service_role;
|
|
163
|
+
|
|
164
|
+
-- The REVOKE is not redundant, and AL-9b caught it missing. A newly created function is granted
|
|
165
|
+
-- EXECUTE to PUBLIC by default, so `authenticated` inherits it through PUBLIC and granting only
|
|
166
|
+
-- service_role above changes nothing on its own. This is the schema-mechanics trap AGENTS.md
|
|
167
|
+
-- names, arriving on a plain `create or replace function` rather than on a DROP.
|
|
168
|
+
revoke all on function agent_board.agent_liveness_summary() from public;
|
|
169
|
+
grant execute on function agent_board.agent_liveness_summary() to service_role;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.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.44.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": 142,
|
|
50
|
+
"aggregateSha256": "e608fdc22d910cb43f3d31cf32cb5a3316da2d46404150547c9b2b54a74f585a",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1317,6 +1317,15 @@
|
|
|
1317
1317
|
"sourceRef": "message-board",
|
|
1318
1318
|
"bytes": 7731,
|
|
1319
1319
|
"sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
|
|
1320
|
+
},
|
|
1321
|
+
{
|
|
1322
|
+
"order": 141,
|
|
1323
|
+
"id": "20260924000100_agent_board_agent_liveness_denominator",
|
|
1324
|
+
"filename": "20260924000100_agent_board_agent_liveness_denominator.sql",
|
|
1325
|
+
"origin": "message-board-forward",
|
|
1326
|
+
"sourceRef": "message-board",
|
|
1327
|
+
"bytes": 10530,
|
|
1328
|
+
"sha256": "deb58ead6c4d7bbda686b85191d5eb2f2771ae2a405933ffbfbaabd7ad0117d9"
|
|
1320
1329
|
}
|
|
1321
1330
|
],
|
|
1322
1331
|
"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 + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE 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 + 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 + one HEARTBEATPRESERVE + one LIVENESSDENOM 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 + 1 + 1 = 142)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(142);
|
|
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,
|
|
@@ -262,6 +262,15 @@ describe('manifest integrity', () => {
|
|
|
262
262
|
// existing row; a first heartbeat still lands 'ready'/'{}'. Signature unchanged (parameter
|
|
263
263
|
// defaults are not part of function identity), so the ACL and COMMENT survive.
|
|
264
264
|
'20260923000100_agent_board_heartbeat_preserve.sql',
|
|
265
|
+
// #367 LIVENESSDENOM. v_ops_presence_heartbeats selects FROM presence, and presence has no
|
|
266
|
+
// row for an agent that never heartbeated -- so it detects only "was reporting, then
|
|
267
|
+
// stopped" and is structurally blind to "never reported". Measured on the canonical project
|
|
268
|
+
// the day #366 shipped the writer half: 0 presence rows, 8 active agents, and the view
|
|
269
|
+
// returned zero rows, which is byte-identical to a healthy fleet. v_agent_liveness selects
|
|
270
|
+
// FROM agents and LEFT JOINs presence so a never-reporting agent is NAMED, and
|
|
271
|
+
// agent_liveness_summary() always emits reporting_agents beside the stale count so a caller
|
|
272
|
+
// cannot render "0 stale" as "all clear".
|
|
273
|
+
'20260924000100_agent_board_agent_liveness_denominator.sql',
|
|
265
274
|
]) {
|
|
266
275
|
expect(names.has(f)).toBe(true);
|
|
267
276
|
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
-- #367 — the reader must not be able to say "all clear" about a fleet it is not watching.
|
|
2
|
+
--
|
|
3
|
+
-- AL-3 IS THE ASSERTION THIS FILE EXISTS FOR, and it is written as a DIFFERENTIAL against the
|
|
4
|
+
-- view it complements. Asserting only "v_agent_liveness names the never-reporting agent" would
|
|
5
|
+
-- pass against a view that names everything and prove nothing about the defect; asserting only
|
|
6
|
+
-- "v_ops_presence_heartbeats misses it" would be a test of code this migration does not change.
|
|
7
|
+
-- Scoring BOTH surfaces on the SAME agent in the SAME transaction is what makes the assertion
|
|
8
|
+
-- about the difference rather than about either one.
|
|
9
|
+
--
|
|
10
|
+
-- AL-6 IS THE OTHER HALF, and it is the one that would have caught the real incident. It builds
|
|
11
|
+
-- the exact production shape — active agents, ZERO presence rows — and asserts the summary reports
|
|
12
|
+
-- `0 stale of 0 reporting, of N active`. A reader that returned "0 stale" alone is byte-identical
|
|
13
|
+
-- to a healthy fleet, which is what made the blind spot dangerous rather than merely incomplete.
|
|
14
|
+
--
|
|
15
|
+
-- SCOPING. Every assertion is scoped to THIS test's agents by handle prefix. An unscoped count
|
|
16
|
+
-- over agent_board.agents is polluted by every fixture any other test left behind and fails
|
|
17
|
+
-- looking like a real defect.
|
|
18
|
+
create extension if not exists pgtap with schema extensions;
|
|
19
|
+
set search_path = extensions, public, agent_board;
|
|
20
|
+
|
|
21
|
+
begin;
|
|
22
|
+
|
|
23
|
+
select plan(17);
|
|
24
|
+
|
|
25
|
+
-- ---------------------------------------------------------------------------
|
|
26
|
+
-- Fixture: three ACTIVE agents in three distinct liveness states, plus one INACTIVE agent that
|
|
27
|
+
-- must not appear at all.
|
|
28
|
+
-- fresh — heartbeated, lease in the future
|
|
29
|
+
-- stale — heartbeated, lease in the past
|
|
30
|
+
-- never — no presence row, which is the case the view exists to surface
|
|
31
|
+
-- retired — status <> 'active'; a liveness reader must not name a decommissioned agent
|
|
32
|
+
-- ---------------------------------------------------------------------------
|
|
33
|
+
create temp table _al (sfx text, fresh uuid, stale uuid, never uuid, retired uuid) on commit drop;
|
|
34
|
+
|
|
35
|
+
do $$
|
|
36
|
+
declare
|
|
37
|
+
v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
|
|
38
|
+
v_fresh uuid := gen_random_uuid();
|
|
39
|
+
v_stale uuid := gen_random_uuid();
|
|
40
|
+
v_never uuid := gen_random_uuid();
|
|
41
|
+
v_retired uuid := gen_random_uuid();
|
|
42
|
+
v_u uuid;
|
|
43
|
+
begin
|
|
44
|
+
foreach v_u in array array[v_fresh, v_stale, v_never, v_retired] loop
|
|
45
|
+
null;
|
|
46
|
+
end loop;
|
|
47
|
+
|
|
48
|
+
insert into agent_board.agents(id, agent_type, handle, display_name, status)
|
|
49
|
+
values (v_fresh, 'agent', 'al-fresh-' || v_sfx, 'AL Fresh', 'active'),
|
|
50
|
+
(v_stale, 'agent', 'al-stale-' || v_sfx, 'AL Stale', 'active'),
|
|
51
|
+
(v_never, 'agent', 'al-never-' || v_sfx, 'AL Never', 'active'),
|
|
52
|
+
(v_retired, 'agent', 'al-retired-' || v_sfx, 'AL Retired', 'inactive');
|
|
53
|
+
|
|
54
|
+
-- Presence rows written DIRECTLY rather than through heartbeat(): this file is about the
|
|
55
|
+
-- reader, and going through the RPC would couple it to the actor-resolution path #366 owns.
|
|
56
|
+
insert into agent_board.presence(agent_id, availability, last_heartbeat_at, expires_at, metadata, updated_at)
|
|
57
|
+
values (v_fresh, 'busy', now() - interval '1 minute', now() + interval '20 minutes', '{}'::jsonb, now()),
|
|
58
|
+
(v_stale, 'away', now() - interval '2 hours', now() - interval '90 minutes', '{}'::jsonb, now());
|
|
59
|
+
|
|
60
|
+
insert into _al values (v_sfx, v_fresh, v_stale, v_never, v_retired);
|
|
61
|
+
end $$;
|
|
62
|
+
|
|
63
|
+
-- ---------------------------------------------------------------------------
|
|
64
|
+
-- AL-1..3: the three states, and the differential that names the defect.
|
|
65
|
+
-- ---------------------------------------------------------------------------
|
|
66
|
+
select is(
|
|
67
|
+
(select liveness_state from agent_board.v_agent_liveness where agent_id = (select fresh from _al)),
|
|
68
|
+
'fresh',
|
|
69
|
+
'AL-1: an agent with an unexpired lease reads fresh');
|
|
70
|
+
|
|
71
|
+
select is(
|
|
72
|
+
(select liveness_state from agent_board.v_agent_liveness where agent_id = (select stale from _al)),
|
|
73
|
+
'stale',
|
|
74
|
+
'AL-2: an agent whose lease has passed reads stale');
|
|
75
|
+
|
|
76
|
+
select is(
|
|
77
|
+
(select liveness_state from agent_board.v_agent_liveness where agent_id = (select never from _al)),
|
|
78
|
+
'never_reported',
|
|
79
|
+
'AL-3 THE POINT: an agent with NO presence row is NAMED never_reported rather than being absent');
|
|
80
|
+
|
|
81
|
+
-- The differential. Same agent, same transaction, the pre-existing surface — which selects FROM
|
|
82
|
+
-- presence and therefore cannot see it. If this ever returns a row, the blind spot closed
|
|
83
|
+
-- somewhere else and AL-3 has stopped being about anything.
|
|
84
|
+
select is(
|
|
85
|
+
(select count(*)::int from agent_board.v_ops_presence_heartbeats where agent_id = (select never from _al)),
|
|
86
|
+
0,
|
|
87
|
+
'AL-3b DIFFERENTIAL: v_ops_presence_heartbeats CANNOT see that same agent — which is why AL-3 is a fix and not a restatement');
|
|
88
|
+
|
|
89
|
+
-- ---------------------------------------------------------------------------
|
|
90
|
+
-- AL-4: a decommissioned agent is not a liveness subject. Without the status filter every agent
|
|
91
|
+
-- ever created accumulates in the never_reported bucket and the signal drowns.
|
|
92
|
+
-- ---------------------------------------------------------------------------
|
|
93
|
+
select is(
|
|
94
|
+
(select count(*)::int from agent_board.v_agent_liveness where agent_id = (select retired from _al)),
|
|
95
|
+
0,
|
|
96
|
+
'AL-4: an inactive agent does not appear at all — a liveness reader must not name a decommissioned agent');
|
|
97
|
+
|
|
98
|
+
-- ---------------------------------------------------------------------------
|
|
99
|
+
-- AL-5: the lag column. NULL for a never-reporting agent, because 0 would read as
|
|
100
|
+
-- "reported just now" — the exact inversion of the truth.
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
select is(
|
|
103
|
+
(select heartbeat_lag_minutes from agent_board.v_agent_liveness where agent_id = (select never from _al)),
|
|
104
|
+
null,
|
|
105
|
+
'AL-5: heartbeat_lag_minutes is NULL for a never-reporting agent, never 0 — a zero would read as "reported just now"');
|
|
106
|
+
|
|
107
|
+
select ok(
|
|
108
|
+
(select heartbeat_lag_minutes > 100 from agent_board.v_agent_liveness where agent_id = (select stale from _al)),
|
|
109
|
+
'AL-5b: and it is a real elapsed figure for one that did report (2h ago reads as >100 minutes)');
|
|
110
|
+
|
|
111
|
+
-- ---------------------------------------------------------------------------
|
|
112
|
+
-- AL-6: THE DENOMINATOR, built as the production shape that motivated the issue.
|
|
113
|
+
--
|
|
114
|
+
-- Scored on a scoped recomputation rather than on the global summary, because the summary counts
|
|
115
|
+
-- every active agent in the database and other fixtures pollute it. The PROPERTY under test is
|
|
116
|
+
-- "the reader states reporting alongside stale", and it holds per-subset exactly as it holds
|
|
117
|
+
-- globally.
|
|
118
|
+
-- ---------------------------------------------------------------------------
|
|
119
|
+
select is(
|
|
120
|
+
(select
|
|
121
|
+
count(*) filter (where liveness_state <> 'never_reported')::text || ' reporting of ' ||
|
|
122
|
+
count(*)::text || ' active, ' ||
|
|
123
|
+
count(*) filter (where liveness_state = 'stale')::text || ' stale'
|
|
124
|
+
from agent_board.v_agent_liveness
|
|
125
|
+
where agent_handle like 'al-%' || (select sfx from _al)),
|
|
126
|
+
'2 reporting of 3 active, 1 stale',
|
|
127
|
+
'AL-6: the reader states REPORTING alongside STALE, so a count can never be read without its denominator');
|
|
128
|
+
|
|
129
|
+
-- The real incident shape: active agents, nothing reporting. Deleting this fixture's presence
|
|
130
|
+
-- rows reproduces `presence_rows = 0, active_agents = 8` from the canonical project.
|
|
131
|
+
delete from agent_board.presence
|
|
132
|
+
where agent_id in (select fresh from _al union all select stale from _al);
|
|
133
|
+
|
|
134
|
+
select is(
|
|
135
|
+
(select
|
|
136
|
+
count(*) filter (where liveness_state = 'stale')::text || ' stale of ' ||
|
|
137
|
+
count(*) filter (where liveness_state <> 'never_reported')::text || ' reporting, of ' ||
|
|
138
|
+
count(*)::text || ' active'
|
|
139
|
+
from agent_board.v_agent_liveness
|
|
140
|
+
where agent_handle like 'al-%' || (select sfx from _al)),
|
|
141
|
+
'0 stale of 0 reporting, of 3 active',
|
|
142
|
+
'AL-6b THE INCIDENT SHAPE: with nothing reporting, the output says so — "0 stale" alone would be byte-identical to a healthy fleet');
|
|
143
|
+
|
|
144
|
+
-- ---------------------------------------------------------------------------
|
|
145
|
+
-- THE ASYMMETRY BELOW IS LOAD-BEARING FOR AL-7b AND AL-10, and neither had it at first.
|
|
146
|
+
-- AL-6b has just deleted this fixture's presence rows, and the persistent seeds carry active
|
|
147
|
+
-- agents with NO presence rows, so at this point the whole database is fresh=0 and stale=0. Every
|
|
148
|
+
-- comparison downstream then scores 0-against-0 and reads as agreement: AL-7b reduced to
|
|
149
|
+
-- `reporting_agents = 0` (so a `reporting_agents` hardcoded to 0 passed it), and AL-10 compared
|
|
150
|
+
-- '0/0' to '0/0' (so swapping the fresh/stale predicates passed it). Two review seats found the
|
|
151
|
+
-- first; the mutation battery found the second. Restoring exactly ONE fresh row is the minimum
|
|
152
|
+
-- state in which either mutation is observable at all.
|
|
153
|
+
insert into agent_board.presence(agent_id, availability, last_heartbeat_at, expires_at, metadata, updated_at)
|
|
154
|
+
values ((select never from _al), 'ready', now(), now() + interval '20 minutes', '{}'::jsonb, now());
|
|
155
|
+
|
|
156
|
+
-- AL-7: the summary function is TOTAL — the four state counts sum to active_agents. If a fourth
|
|
157
|
+
-- state is ever added to the view's CASE without being added to the function, this fails.
|
|
158
|
+
-- ---------------------------------------------------------------------------
|
|
159
|
+
select is(
|
|
160
|
+
(select fresh + stale + never_reported from agent_board.agent_liveness_summary()),
|
|
161
|
+
(select active_agents from agent_board.agent_liveness_summary()),
|
|
162
|
+
'AL-7: the state counts sum to active_agents — a new state that the summary does not count fails here');
|
|
163
|
+
|
|
164
|
+
-- ---------------------------------------------------------------------------
|
|
165
|
+
-- AL-7b: THE SUMMARY'S OWN reporting_agents, and this assertion exists because the mutation
|
|
166
|
+
-- battery found it missing. Corrupting `reporting_agents` to `count(*)` — so an entirely
|
|
167
|
+
-- unmonitored fleet reports full participation — produced NO failure anywhere in this file.
|
|
168
|
+
-- AL-6/AL-6b recompute the denominator FROM THE VIEW and never call the summary at all, and AL-7's
|
|
169
|
+
-- sum is unaffected by that field. So the one column that makes the denominator mechanical was
|
|
170
|
+
-- unasserted, while the migration's comment claimed it was the point of the function.
|
|
171
|
+
--
|
|
172
|
+
-- Scored as a RELATIONSHIP rather than a literal, because the summary is global and every other
|
|
173
|
+
-- test's fixtures are in it: `reporting_agents` must equal `fresh + stale`. That holds under any
|
|
174
|
+
-- amount of pollution, and it is discriminating here precisely because this file's fixture
|
|
175
|
+
-- guarantees at least one never-reporting agent exists — so active_agents is strictly greater,
|
|
176
|
+
-- and a reporting_agents that counted everyone fails.
|
|
177
|
+
-- ---------------------------------------------------------------------------
|
|
178
|
+
select is(
|
|
179
|
+
(select reporting_agents from agent_board.agent_liveness_summary()),
|
|
180
|
+
(select fresh + stale from agent_board.agent_liveness_summary()),
|
|
181
|
+
'AL-7b: the summary''s OWN reporting_agents equals fresh + stale — a denominator that counted never-reporting agents as reporting would say an unmonitored fleet was fully participating');
|
|
182
|
+
|
|
183
|
+
-- ---------------------------------------------------------------------------
|
|
184
|
+
-- AL-8: security_invoker, asserted from the catalog. A definer view here would hand every caller
|
|
185
|
+
-- a fleet-wide roster regardless of what RLS says they may see.
|
|
186
|
+
-- ---------------------------------------------------------------------------
|
|
187
|
+
select ok(
|
|
188
|
+
(select 'security_invoker=true' = any(c.reloptions)
|
|
189
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
190
|
+
where n.nspname = 'agent_board' and c.relname = 'v_agent_liveness'),
|
|
191
|
+
'AL-8: the view is security_invoker — RLS applies to the reader, not to the view owner');
|
|
192
|
+
|
|
193
|
+
select ok(
|
|
194
|
+
(select not p.prosecdef
|
|
195
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
196
|
+
where n.nspname = 'agent_board' and p.proname = 'agent_liveness_summary'),
|
|
197
|
+
'AL-8b: the SUMMARY is not SECURITY DEFINER either — AL-8 inspects only the view, so a definer function over an invoker view would silently restore the fleet-wide read this design refuses');
|
|
198
|
+
|
|
199
|
+
-- ---------------------------------------------------------------------------
|
|
200
|
+
-- AL-9: SERVICE_ROLE ONLY. The blocking finding of this PR's review.
|
|
201
|
+
--
|
|
202
|
+
-- The first version granted both surfaces to `authenticated` too, on the reasoning that
|
|
203
|
+
-- security_invoker made it safe. RLS does not just limit what an authenticated caller sees here —
|
|
204
|
+
-- it changes what the answer MEANS. `agents` is readable by any agent; `presence` is own-row only.
|
|
205
|
+
-- So the LEFT JOIN turns "invisible to you" into `never_reported`, and the view FABRICATES the
|
|
206
|
+
-- alarm it exists to report. Measured with two genuinely healthy agents: service_role saw
|
|
207
|
+
-- fresh=2/never=0, an authenticated caller saw fresh=1/never=1.
|
|
208
|
+
--
|
|
209
|
+
-- Asserted via has_*_privilege rather than by reading pg_class.relacl, because that resolves
|
|
210
|
+
-- inherited and PUBLIC-default grants which a direct ACL read cannot see.
|
|
211
|
+
-- ---------------------------------------------------------------------------
|
|
212
|
+
select is(
|
|
213
|
+
(select coalesce(string_agg(r, ', ' order by r), '')
|
|
214
|
+
from unnest(array['anon','authenticated','service_role']) r
|
|
215
|
+
where has_table_privilege(r, 'agent_board.v_agent_liveness', 'select')),
|
|
216
|
+
'service_role',
|
|
217
|
+
'AL-9 SECURITY: only service_role may read the view — granted to authenticated it would report never_reported for every agent whose presence row RLS hides, fabricating alarms about healthy agents');
|
|
218
|
+
|
|
219
|
+
select is(
|
|
220
|
+
(select coalesce(string_agg(r, ', ' order by r), '')
|
|
221
|
+
from unnest(array['anon','authenticated','service_role']) r
|
|
222
|
+
where has_function_privilege(r, 'agent_board.agent_liveness_summary()', 'execute')),
|
|
223
|
+
'service_role',
|
|
224
|
+
'AL-9b: and the summary is service_role only for the same reason — its denominator is per-caller under RLS, so two readers would disagree with no way to say which was right');
|
|
225
|
+
|
|
226
|
+
-- ---------------------------------------------------------------------------
|
|
227
|
+
-- AL-10: the summary's fresh/stale are the VIEW's fresh/stale. AL-7/7b assert only SUMS, and a
|
|
228
|
+
-- review seat pointed out that swapping the two predicates inside agent_liveness_summary() leaves
|
|
229
|
+
-- every one of them green: both identities still hold and nothing checked the values themselves.
|
|
230
|
+
-- Scored as an identity against the view so it is immune to other fixtures' pollution.
|
|
231
|
+
--
|
|
232
|
+
-- THE ASYMMETRY BELOW IS LOAD-BEARING, and the first version of this assertion did not have it.
|
|
233
|
+
-- AL-6b has just deleted this fixture's presence rows, so at this point fresh = 0 and stale = 0 --
|
|
234
|
+
-- and '0/0' equals '0/0' whichever way round the two predicates are. The assertion scored a
|
|
235
|
+
-- VACUOUS MATCH and read as agreement; the swap mutation ran clean against it. Restoring exactly
|
|
236
|
+
-- ONE fresh row makes the two counts differ, which is the minimum condition under which a swap is
|
|
237
|
+
-- observable at all. Measured, not reasoned: the mutation fails this line now and did not before.
|
|
238
|
+
-- ---------------------------------------------------------------------------
|
|
239
|
+
select is(
|
|
240
|
+
(select fresh || '/' || stale from agent_board.agent_liveness_summary()),
|
|
241
|
+
(select
|
|
242
|
+
(count(*) filter (where liveness_state = 'fresh'))::text || '/' ||
|
|
243
|
+
(count(*) filter (where liveness_state = 'stale'))::text
|
|
244
|
+
from agent_board.v_agent_liveness),
|
|
245
|
+
'AL-10: the summary''s fresh and stale ARE the view''s fresh and stale — swapping the two predicates inside the function keeps every sum identity true and is invisible without this');
|
|
246
|
+
|
|
247
|
+
-- ---------------------------------------------------------------------------
|
|
248
|
+
-- AL-11: THE CLASS, from the catalog rather than from a list written here (rule 1).
|
|
249
|
+
--
|
|
250
|
+
-- The claim this migration makes is "v_ops_presence_heartbeats is the surface with the blind
|
|
251
|
+
-- spot". That was checked by enumerating pg_views rather than by reading the diff, and the answer
|
|
252
|
+
-- was yes — but a claim checked once is a claim about the moment it was checked. This asserts the
|
|
253
|
+
-- membership so a NEW view over presence fails here and NAMES ITSELF, forcing whoever adds it to
|
|
254
|
+
-- state its join direction.
|
|
255
|
+
--
|
|
256
|
+
-- Each member's claim is asserted POSITIVELY in the message rather than by absence from a list:
|
|
257
|
+
-- v_agent_dashboard FROM agents LEFT JOIN presence — correct direction, no blind spot
|
|
258
|
+
-- v_agent_liveness FROM agents LEFT JOIN presence — this migration
|
|
259
|
+
-- v_ops_presence_heartbeats FROM presence JOIN agents — the blind spot, kept deliberately
|
|
260
|
+
-- v_ops_table_growth names 'presence' as a row-count subject, not a liveness reader
|
|
261
|
+
-- ---------------------------------------------------------------------------
|
|
262
|
+
select is(
|
|
263
|
+
(select string_agg(c.relname, ', ' order by c.relname)
|
|
264
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
265
|
+
where n.nspname = 'agent_board' and c.relkind = 'v'
|
|
266
|
+
and pg_get_viewdef(c.oid) ~ 'presence'),
|
|
267
|
+
'v_agent_dashboard, v_agent_liveness, v_ops_presence_heartbeats, v_ops_table_growth',
|
|
268
|
+
'AL-11 CLASS: exactly these four agent_board views read presence — a new one fails here and names itself, so its join direction gets stated instead of assumed');
|
|
269
|
+
|
|
270
|
+
select * from finish();
|
|
271
|
+
rollback;
|
|
@@ -321,6 +321,10 @@
|
|
|
321
321
|
"signature": "add_artifact(p_created_by_agent_id uuid, p_uri text, p_artifact_type text, p_storage_kind text, p_thread_id uuid, p_task_id uuid, p_message_id uuid, p_task_event_id uuid, p_metadata jsonb)",
|
|
322
322
|
"declares_p_metadata": true
|
|
323
323
|
},
|
|
324
|
+
{
|
|
325
|
+
"signature": "agent_liveness_summary()",
|
|
326
|
+
"declares_p_metadata": false
|
|
327
|
+
},
|
|
324
328
|
{
|
|
325
329
|
"signature": "append_task_event(p_task_id uuid, p_actor_agent_id uuid, p_event_type text, p_payload jsonb, p_status text, p_assigned_agent_id uuid, p_completed_at timestamp with time zone)",
|
|
326
330
|
"declares_p_metadata": false
|