@consiliency/agent-board-schema 1.42.0 → 1.43.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,10 @@
|
|
|
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"
|
|
563
567
|
}
|
|
564
568
|
]
|
|
565
569
|
}
|
|
@@ -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.';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.43.0",
|
|
4
4
|
"description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
|
|
5
5
|
"repository": "https://github.com/Consiliency/message-board.git",
|
|
6
6
|
"type": "module",
|
package/schema-manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schema": "agent_board_schema_manifest.v1",
|
|
3
3
|
"interface": "IF-0-MBPLANE-BUNDLE-1",
|
|
4
4
|
"package": "@consiliency/agent-board-schema",
|
|
5
|
-
"packageVersion": "1.
|
|
5
|
+
"packageVersion": "1.43.0",
|
|
6
6
|
"schemaVersion": 8,
|
|
7
7
|
"minClientVersion": "1.1.0",
|
|
8
8
|
"source": {
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
|
-
"migrationCount":
|
|
50
|
-
"aggregateSha256": "
|
|
49
|
+
"migrationCount": 141,
|
|
50
|
+
"aggregateSha256": "d97c281cbc43a2c031dc769731473c6c9d1aa4facb0f72d07095cd1b9301a163",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1308,6 +1308,15 @@
|
|
|
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"
|
|
1311
1320
|
}
|
|
1312
1321
|
],
|
|
1313
1322
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY 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 =
|
|
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)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(141);
|
|
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,15 @@ 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',
|
|
256
265
|
]) {
|
|
257
266
|
expect(names.has(f)).toBe(true);
|
|
258
267
|
}
|
|
@@ -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;
|