@consiliency/agent-board-schema 1.42.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.
@@ -560,6 +560,14 @@
560
560
  {
561
561
  "id": "20260922000100_agent_board_board_claim_policy",
562
562
  "sha256": "1363a194f50342feb613a1aa1e01321a2a81783d88dfebe2c8f2002170cdc25c"
563
+ },
564
+ {
565
+ "id": "20260923000100_agent_board_heartbeat_preserve",
566
+ "sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
567
+ },
568
+ {
569
+ "id": "20260924000100_agent_board_agent_liveness_denominator",
570
+ "sha256": "deb58ead6c4d7bbda686b85191d5eb2f2771ae2a405933ffbfbaabd7ad0117d9"
563
571
  }
564
572
  ]
565
573
  }
@@ -0,0 +1,121 @@
1
+ -- #366/F4 — heartbeat must be able to say "I am alive" WITHOUT also saying everything else.
2
+ --
3
+ -- THE DEFECT. The upsert overwrote every field it was not told, and the signature made that
4
+ -- impossible to avoid from a caller:
5
+ --
6
+ -- p_availability text default 'ready'
7
+ --
8
+ -- An OMITTED argument arrives as 'ready', so the function cannot distinguish "not supplied" from
9
+ -- "supplied as ready". A caller that only wants to refresh the lease therefore also resets
10
+ -- availability to 'ready' and clears thread_id, activity_summary and metadata — every time.
11
+ --
12
+ -- That is fatal to an implicit, activity-coupled heartbeat (#329): reporting liveness on every
13
+ -- board tool call would silently wipe an agent's own declared state on every call. Three views
14
+ -- read this table — v_agent_dashboard, v_ops_presence_heartbeats, v_ops_table_growth — so the
15
+ -- damage would be visible as an agent that is permanently 'ready' and never in a thread.
16
+ --
17
+ -- WHY THIS COULD NOT BE FIXED IN THE CLIENT, which is what #329 assumed. That issue said "the fix
18
+ -- is client-side and there is no schema change here at all". It was right about the WRITER — the
19
+ -- reason presence was empty is that nothing called heartbeat — and wrong about the WRITE. No
20
+ -- argument a client can send distinguishes absent from 'ready', because the default is applied
21
+ -- before the function sees it. The distinction has to exist in the signature.
22
+ --
23
+ -- THE CHANGE. Every optional field defaults to NULL, and NULL now means LEAVE UNCHANGED:
24
+ --
25
+ -- p_availability 'ready' -> null
26
+ -- p_metadata '{}' -> null
27
+ -- (p_thread_id and p_activity_summary already defaulted null; their CLEARING behaviour changes)
28
+ --
29
+ -- On INSERT a null still lands the documented starting value ('ready', '{}'). On CONFLICT a null
30
+ -- preserves what is already there.
31
+ --
32
+ -- THE TRADE-OFF, stated rather than buried: there is now NO WAY to clear thread_id or
33
+ -- activity_summary through heartbeat, because null is how you say "don't touch it". Clearing was
34
+ -- never a meaningful operation here — an agent moves to another thread or its row expires — and
35
+ -- the alternative is the defect above. `presence` holds ZERO rows on the canonical project
36
+ -- (measured 2026-08-29, unchanged in four weeks), so no caller relies on the old clearing shape.
37
+ --
38
+ --
39
+ -- #366/F1 IS FIXED HERE TOO, and by a better mechanism than the one first proposed. The client
40
+ -- could not call heartbeat at all: `p_agent_id uuid` had NO DEFAULT, so a call omitting it did not
41
+ -- match the function and was rejected before the body ran. The obvious fix was to add heartbeat to
42
+ -- the client's USER_SCOPED_ACTOR_DEFAULTS map so the client fills the id in.
43
+ --
44
+ -- That would have worked and it is the wrong layer. heartbeat ALREADY resolves its actor from the
45
+ -- session:
46
+ --
47
+ -- v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
48
+ --
49
+ -- and that resolver, for a non-service_role caller, IGNORES a supplied id beyond checking it
50
+ -- matches `current_agent_id()`. Having the client send an id the server then re-derives is the
51
+ -- shape this repo has a rule against: provenance is derived from the session, never from a caller
52
+ -- argument. The id was required by the SIGNATURE, not by the semantics.
53
+ --
54
+ -- So `p_agent_id` gains `default null`. An authenticated caller omits it and the session answers;
55
+ -- a service_role caller must still supply it, because `_resolved_actor_agent_id` raises 22023 for
56
+ -- a null id under service_role — that behaviour is untouched and HBP-12 pins it.
57
+ -- SIGNATURE IS UNCHANGED. Parameter DEFAULTS are not part of a function's identity in PostgreSQL,
58
+ -- so this is a true CREATE OR REPLACE: the ACL and the COMMENT survive, and nothing is dropped.
59
+ -- That matters here specifically — heartbeat is granted to authenticated, and a DROP would hand
60
+ -- EXECUTE back to PUBLIC by default.
61
+ create or replace function agent_board.heartbeat(
62
+ p_agent_id uuid default null,
63
+ p_thread_id uuid default null,
64
+ p_availability text default null,
65
+ p_activity_summary text default null,
66
+ p_expires_in interval default interval '5 minutes',
67
+ p_metadata jsonb default null
68
+ )
69
+ returns table(agent_id uuid, availability text, thread_id uuid, last_heartbeat_at timestamptz, expires_at timestamptz, updated_at timestamptz)
70
+ language plpgsql
71
+ security definer
72
+ set search_path = agent_board, public
73
+ as $$
74
+ declare
75
+ v_actor_agent_id uuid;
76
+ begin
77
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(p_agent_id, 'p_agent_id');
78
+
79
+ -- Unchanged: a supplied thread must exist and be reachable by the caller. A NULL thread now
80
+ -- means "leave my thread as it is", so it is not a claim about any thread and needs no check.
81
+ if p_thread_id is not null then
82
+ if not exists (select 1 from agent_board.threads where id = p_thread_id) then
83
+ raise exception 'thread not found' using errcode = 'P0002';
84
+ end if;
85
+ if not agent_board._current_agent_can_access_thread(p_thread_id) then
86
+ raise exception 'thread not accessible to current agent' using errcode = '42501';
87
+ end if;
88
+ end if;
89
+
90
+ return query
91
+ insert into agent_board.presence(agent_id, thread_id, availability, activity_summary, last_heartbeat_at, expires_at, metadata, updated_at)
92
+ values (
93
+ v_actor_agent_id,
94
+ p_thread_id,
95
+ -- INSERT: a null still lands the documented starting value, so a first heartbeat behaves
96
+ -- exactly as it did before this migration.
97
+ coalesce(p_availability, 'ready'),
98
+ p_activity_summary,
99
+ now(),
100
+ now() + coalesce(p_expires_in, interval '5 minutes'),
101
+ coalesce(p_metadata, '{}'::jsonb),
102
+ now()
103
+ )
104
+ on conflict on constraint presence_pkey do update
105
+ -- UPDATE: null preserves. `presence.<col>` is the EXISTING row; `excluded.<col>` is what this
106
+ -- call proposed. Coalescing onto the existing value is the whole fix -- it is what lets a
107
+ -- lease refresh say nothing about availability.
108
+ set thread_id = coalesce(excluded.thread_id, presence.thread_id),
109
+ availability = coalesce(p_availability, presence.availability),
110
+ activity_summary = coalesce(excluded.activity_summary, presence.activity_summary),
111
+ metadata = coalesce(p_metadata, presence.metadata),
112
+ -- These three are the POINT of a heartbeat and always advance.
113
+ last_heartbeat_at = excluded.last_heartbeat_at,
114
+ expires_at = excluded.expires_at,
115
+ updated_at = excluded.updated_at
116
+ returning presence.agent_id, presence.availability, presence.thread_id, presence.last_heartbeat_at, presence.expires_at, presence.updated_at;
117
+ end;
118
+ $$;
119
+
120
+ comment on function agent_board.heartbeat(uuid, uuid, text, text, interval, jsonb) is
121
+ '#366/F1+F4: asserts liveness for the acting agent. Every optional field defaults to NULL and NULL means LEAVE UNCHANGED on an existing row -- so a caller refreshing only the lease does not reset availability to ''ready'' or clear thread_id/activity_summary/metadata. On a FIRST heartbeat a null still lands the documented starting value (''ready'', ''{}''). Consequence, deliberate: thread_id and activity_summary cannot be CLEARED through this RPC, because null is how a caller declines to change them. Before this migration p_availability defaulted to ''ready'', which made "not supplied" indistinguishable from "supplied as ready" and made an implicit activity-coupled heartbeat destructive. p_agent_id also gains a null default (#366/F1): it was required by the SIGNATURE though the body already resolves the actor from the session via _resolved_actor_agent_id, so an authenticated caller may now omit it entirely. service_role must still supply it -- that resolver raises 22023 for a null id under service_role, unchanged.';
@@ -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.42.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.42.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": 140,
50
- "aggregateSha256": "d82adb3013e8a1118ecae11a3aff4d3f0e7b6f7d53f7116d9565d22b2eca1e93",
49
+ "migrationCount": 142,
50
+ "aggregateSha256": "e608fdc22d910cb43f3d31cf32cb5a3316da2d46404150547c9b2b54a74f585a",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1308,6 +1308,24 @@
1308
1308
  "sourceRef": "message-board",
1309
1309
  "bytes": 22285,
1310
1310
  "sha256": "1363a194f50342feb613a1aa1e01321a2a81783d88dfebe2c8f2002170cdc25c"
1311
+ },
1312
+ {
1313
+ "order": 140,
1314
+ "id": "20260923000100_agent_board_heartbeat_preserve",
1315
+ "filename": "20260923000100_agent_board_heartbeat_preserve.sql",
1316
+ "origin": "message-board-forward",
1317
+ "sourceRef": "message-board",
1318
+ "bytes": 7731,
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"
1311
1329
  }
1312
1330
  ],
1313
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 forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 140)', () => {
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(140);
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,
@@ -253,6 +253,24 @@ describe('manifest integrity', () => {
253
253
  // fleet-authority gate on machine/repository/repo_file -- a board may decline our lock,
254
254
  // never grant itself another owner's resource (BCP-6 asserts that from the catalog).
255
255
  '20260922000100_agent_board_board_claim_policy.sql',
256
+ // #366/F4 HEARTBEATPRESERVE. heartbeat's upsert overwrote every field it was not told, and
257
+ // p_availability defaulted to 'ready' -- so the function could not distinguish "not supplied"
258
+ // from "supplied as ready", and a caller refreshing only its lease also reset availability
259
+ // and cleared thread_id/activity_summary/metadata. Fatal to an implicit activity-coupled
260
+ // heartbeat (#329), and unfixable from the client because the default is applied before the
261
+ // function sees it. Optional fields now default NULL and NULL means LEAVE UNCHANGED on an
262
+ // existing row; a first heartbeat still lands 'ready'/'{}'. Signature unchanged (parameter
263
+ // defaults are not part of function identity), so the ACL and COMMENT survive.
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',
256
274
  ]) {
257
275
  expect(names.has(f)).toBe(true);
258
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;
@@ -0,0 +1,240 @@
1
+ -- #366/F4 pgTAP: a heartbeat can say "I am alive" WITHOUT also saying everything else.
2
+ --
3
+ -- THE DEFECT THIS PINS. heartbeat's upsert overwrote every field it was not told, and the
4
+ -- signature made that unavoidable from a caller: `p_availability text default 'ready'` means an
5
+ -- OMITTED argument arrives as 'ready', so the function could not distinguish "not supplied" from
6
+ -- "supplied as ready". A caller refreshing only its lease also reset availability and cleared
7
+ -- thread_id, activity_summary and metadata — every single call.
8
+ --
9
+ -- That is fatal to the implicit, activity-coupled heartbeat #329 wants: reporting liveness on
10
+ -- every board tool call would wipe the agent's own declared state on every call.
11
+ --
12
+ -- HBP-2 IS THE ONE THIS FILE EXISTS FOR. Everything else checks that the change did not break the
13
+ -- documented behaviour; HBP-2 checks the behaviour the change was made for. HBP-6 is its
14
+ -- necessary twin: "preserve on null" must not decay into "ignore the argument", and a file that
15
+ -- only asserted preservation would pass against a function that ignored availability entirely.
16
+ --
17
+ -- WHAT THIS FILE STRUCTURALLY CANNOT SEE, named here because a review seat walked through it.
18
+ -- Every assertion below calls the RPC with `p_metadata` OMITTED. The production client did not:
19
+ -- `heartbeat` was classified `signed-metadata`, and the client's attachSigningMetadata ALWAYS
20
+ -- writes a non-null `p_metadata`, so for every signing-enabled agent the server saw a supplied
21
+ -- argument and HBP-5's coalesce replaced the declared metadata with the signing envelope. HBP-2/3/4
22
+ -- were true and HBP-5 was false in production, and no assertion here could have told the
23
+ -- difference, because pgTAP cannot see what the client puts on the wire. The fix was to
24
+ -- reclassify the RPC as `unsigned-deliberate`; the pin lives in client.test.ts, "leaves heartbeat
25
+ -- unsigned, so a lease refresh cannot clobber declared presence (#366)". A server-side test file
26
+ -- naming its own blind spot is cheaper than a reader assuming it has none.
27
+ --
28
+ -- HBP-9 ASSERTS THE TRADE-OFF RATHER THAN HIDING IT. Because null now means "leave unchanged",
29
+ -- there is no way to CLEAR thread_id through this RPC. That is a real capability loss, it was
30
+ -- chosen deliberately over the defect above, and it is asserted here so it is a documented
31
+ -- property rather than something a future reader discovers and treats as a bug.
32
+ create extension if not exists pgtap with schema extensions;
33
+ set search_path = extensions, public, agent_board;
34
+
35
+ begin;
36
+
37
+ select plan(14);
38
+
39
+ -- ---------------------------------------------------------------------------
40
+ -- Fixture: one agent, and two threads it can reach.
41
+ -- ---------------------------------------------------------------------------
42
+ create temp table _hb (agent uuid, auth_user uuid, thread_a uuid, thread_b uuid) on commit drop;
43
+
44
+ do $$
45
+ declare
46
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
47
+ v_agent uuid := gen_random_uuid();
48
+ v_user uuid;
49
+ v_a uuid := gen_random_uuid();
50
+ v_b uuid := gen_random_uuid();
51
+ begin
52
+ v_user := public.ensure_github_user('hb-' || v_sfx, 'hb-' || v_sfx || '@example.test', 'hb-' || v_sfx, 'HB Agent');
53
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
54
+ values (v_agent, 'agent', 'hb-' || v_sfx, 'HB Agent', 'active', v_user, v_user);
55
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
56
+ values (v_a, 'coordination', v_agent, 'HB thread A'),
57
+ (v_b, 'coordination', v_agent, 'HB thread B');
58
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
59
+ perform set_config('request.jwt.claim.sub', v_user::text, true);
60
+ insert into _hb values (v_agent, v_user, v_a, v_b);
61
+ end $$;
62
+
63
+ -- ---------------------------------------------------------------------------
64
+ -- HBP-1: the INSERT path is unchanged. A first heartbeat with nothing supplied still lands the
65
+ -- documented starting values, so this migration is not a behaviour change for a new agent.
66
+ -- ---------------------------------------------------------------------------
67
+ do $$
68
+ begin
69
+ perform agent_board.heartbeat((select agent from _hb));
70
+ end $$;
71
+
72
+ select is(
73
+ (select availability || '|' || coalesce(thread_id::text, 'NULL') || '|' || coalesce(activity_summary, 'NULL') || '|' || metadata::text
74
+ from agent_board.presence where agent_id = (select agent from _hb)),
75
+ 'ready|NULL|NULL|{}',
76
+ 'HBP-1: a FIRST heartbeat with nothing supplied still lands ready / no thread / no summary / {}');
77
+
78
+ -- Now the agent declares real state, the way a caller that means it would.
79
+ do $$
80
+ begin
81
+ perform agent_board.heartbeat(
82
+ (select agent from _hb),
83
+ (select thread_a from _hb),
84
+ 'busy',
85
+ 'reviewing the panel findings',
86
+ interval '10 minutes',
87
+ '{"lane":"review"}'::jsonb);
88
+ end $$;
89
+
90
+ select is(
91
+ (select availability || '|' || thread_id::text || '|' || activity_summary || '|' || (metadata->>'lane')
92
+ from agent_board.presence where agent_id = (select agent from _hb)),
93
+ 'busy|' || (select thread_a from _hb)::text || '|reviewing the panel findings|review',
94
+ 'HBP-2a: an explicit heartbeat sets every field it supplies');
95
+
96
+ -- ---------------------------------------------------------------------------
97
+ -- HBP-2..5: THE FIX. A lease-only refresh — exactly what an implicit activity-coupled heartbeat
98
+ -- sends — must leave all four declared fields alone.
99
+ -- ---------------------------------------------------------------------------
100
+ do $$
101
+ begin
102
+ perform agent_board.heartbeat((select agent from _hb), p_expires_in => interval '30 minutes');
103
+ end $$;
104
+
105
+ select is(
106
+ (select availability from agent_board.presence where agent_id = (select agent from _hb)),
107
+ 'busy',
108
+ 'HBP-2: a lease-only refresh PRESERVES availability — before this fix it silently reset to ready on every call');
109
+
110
+ select is(
111
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
112
+ (select thread_a from _hb),
113
+ 'HBP-3: and preserves thread_id');
114
+
115
+ select is(
116
+ (select activity_summary from agent_board.presence where agent_id = (select agent from _hb)),
117
+ 'reviewing the panel findings',
118
+ 'HBP-4: and preserves activity_summary');
119
+
120
+ select is(
121
+ (select metadata->>'lane' from agent_board.presence where agent_id = (select agent from _hb)),
122
+ 'review',
123
+ 'HBP-5: and preserves metadata');
124
+
125
+ -- ---------------------------------------------------------------------------
126
+ -- HBP-6: the necessary twin. "Preserve on null" must not decay into "ignore the argument" — a
127
+ -- function that ignored availability entirely would pass HBP-2 through HBP-5 and be useless.
128
+ -- ---------------------------------------------------------------------------
129
+ do $$
130
+ begin
131
+ perform agent_board.heartbeat((select agent from _hb), p_availability => 'away');
132
+ end $$;
133
+
134
+ select is(
135
+ (select availability from agent_board.presence where agent_id = (select agent from _hb)),
136
+ 'away',
137
+ 'HBP-6: an EXPLICIT availability still sets it — preserve-on-null is not ignore-the-argument');
138
+
139
+ -- ---------------------------------------------------------------------------
140
+ -- HBP-7: the fields a heartbeat exists to MOVE must always move, however little else is supplied.
141
+ -- A preserve-everything bug would freeze the lease and make every agent look permanently stale --
142
+ -- the exact opposite failure to the one HBP-2 fixes, and just as invisible.
143
+ --
144
+ -- WHY THE OBVIOUS SPELLING DOES NOT WORK: now() is fixed for an entire TRANSACTION in PostgreSQL,
145
+ -- and this file runs in one. Two heartbeats here therefore produce the SAME last_heartbeat_at, and
146
+ -- pg_sleep does not change that. The first version of this assertion compared two in-transaction
147
+ -- heartbeats and failed for that reason -- a broken probe, not a broken function.
148
+ --
149
+ -- So the row is pushed into the past first. If last_heartbeat_at were preserved rather than
150
+ -- written, it would STAY an hour old, which is a difference this transaction can actually see.
151
+ -- ---------------------------------------------------------------------------
152
+ update agent_board.presence
153
+ set last_heartbeat_at = now() - interval '1 hour',
154
+ expires_at = now() - interval '30 minutes'
155
+ where agent_id = (select agent from _hb);
156
+
157
+ do $$
158
+ begin
159
+ perform agent_board.heartbeat((select agent from _hb), p_expires_in => interval '90 minutes');
160
+ end $$;
161
+
162
+ select ok(
163
+ (select last_heartbeat_at = now() and expires_at > now() + interval '89 minutes'
164
+ from agent_board.presence where agent_id = (select agent from _hb)),
165
+ 'HBP-7: last_heartbeat_at and expires_at ALWAYS advance -- pushed an hour into the past, a heartbeat brings them back');
166
+
167
+ -- ---------------------------------------------------------------------------
168
+ -- HBP-8: an explicitly supplied thread still moves the agent, so preservation does not pin it.
169
+ -- ---------------------------------------------------------------------------
170
+ do $$
171
+ begin
172
+ perform agent_board.heartbeat((select agent from _hb), (select thread_b from _hb));
173
+ end $$;
174
+
175
+ select is(
176
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
177
+ (select thread_b from _hb),
178
+ 'HBP-8: an explicit thread still moves the agent between threads');
179
+
180
+ -- ---------------------------------------------------------------------------
181
+ -- HBP-9: THE TRADE-OFF, asserted rather than hidden. Null means "leave unchanged", so thread_id
182
+ -- cannot be CLEARED through this RPC. Chosen deliberately over the destructive alternative, and
183
+ -- pinned here so a future reader finds a documented property instead of what looks like a bug.
184
+ -- ---------------------------------------------------------------------------
185
+ do $$
186
+ begin
187
+ perform agent_board.heartbeat((select agent from _hb), null);
188
+ end $$;
189
+
190
+ select is(
191
+ (select thread_id from agent_board.presence where agent_id = (select agent from _hb)),
192
+ (select thread_b from _hb),
193
+ 'HBP-9 TRADE-OFF: a null thread does NOT clear it — null is how a caller declines to change a field, so clearing is not expressible here');
194
+
195
+ -- ---------------------------------------------------------------------------
196
+ -- HBP-10: CREATE OR REPLACE kept the ACL. Parameter defaults are not part of a function's
197
+ -- identity, so this was a true replace — but DROP FUNCTION would have handed EXECUTE back to
198
+ -- PUBLIC, and heartbeat is granted to authenticated.
199
+ -- ---------------------------------------------------------------------------
200
+ select is(
201
+ (select coalesce(string_agg(r, ', ' order by r), '')
202
+ from unnest(array['anon','authenticated','service_role']) r
203
+ where has_function_privilege(r, 'agent_board.heartbeat(uuid,uuid,text,text,interval,jsonb)', 'execute')),
204
+ 'authenticated, service_role',
205
+ 'HBP-10: the ACL survived the replace — authenticated and service_role, and NOT anon or public');
206
+
207
+ -- ---------------------------------------------------------------------------
208
+ -- HBP-11/12: #366/F1 — the call that could not be made.
209
+ --
210
+ -- p_agent_id had no default, so `heartbeat()` did not match the function and was rejected before
211
+ -- the body ran. That is why the implicit heartbeat in #366 was a silent no-op: the RPC rejected,
212
+ -- and the fire-and-forget reporter swallowed the error by design.
213
+ --
214
+ -- The fix is the SIGNATURE, not the client. The body already resolves the actor from the session,
215
+ -- and _resolved_actor_agent_id ignores a supplied id for a non-service_role caller beyond checking
216
+ -- it matches. Having the client send an id the server re-derives is the shape this repo has a rule
217
+ -- against.
218
+ -- ---------------------------------------------------------------------------
219
+ select lives_ok(
220
+ $$ select agent_board.heartbeat() $$,
221
+ 'HBP-11: an authenticated caller can heartbeat with NO arguments at all — the session answers for the actor');
222
+
223
+ select is(
224
+ (select count(*)::int from agent_board.presence where agent_id = (select agent from _hb)),
225
+ 1,
226
+ 'HBP-11b: and it wrote the ACTING agent''s row, not some other one');
227
+
228
+ -- service_role has no session identity, so the resolver must still demand an explicit id. If this
229
+ -- ever passed, an operator call with no actor would silently write somebody's presence.
230
+ select throws_ok(
231
+ $$ select set_config('request.jwt.claims', '', true);
232
+ select set_config('request.jwt.claim.sub', '', true);
233
+ select set_config('request.jwt.claim.role', '', true);
234
+ select agent_board.heartbeat() $$,
235
+ '22023',
236
+ null,
237
+ 'HBP-12: service_role must STILL supply p_agent_id — the null default relaxes the signature, not the resolver');
238
+
239
+ select * from finish();
240
+ 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