@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.
@@ -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.43.0",
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",
@@ -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.43.0",
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": 141,
50
- "aggregateSha256": "d97c281cbc43a2c031dc769731473c6c9d1aa4facb0f72d07095cd1b9301a163",
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": {
@@ -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 = 141)', () => {
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(141);
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