@consiliency/agent-board-schema 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -564,6 +564,14 @@
564
564
  {
565
565
  "id": "20260923000100_agent_board_heartbeat_preserve",
566
566
  "sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
567
+ },
568
+ {
569
+ "id": "20260924000100_agent_board_agent_liveness_denominator",
570
+ "sha256": "deb58ead6c4d7bbda686b85191d5eb2f2771ae2a405933ffbfbaabd7ad0117d9"
571
+ },
572
+ {
573
+ "id": "20260925000100_agent_board_ownerless_renew_ttl_cap",
574
+ "sha256": "da312f43cabf07c6dfd10dbe2a119fb46f49f24155e21adce4f38e291a38103c"
567
575
  }
568
576
  ]
569
577
  }
@@ -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;
@@ -0,0 +1,166 @@
1
+ -- #373 — the ownerless TTL cap was ACQUIRE-ONLY, so the bound it advertises did not exist.
2
+ --
3
+ -- THE GAP, re-derived from the catalog rather than taken from the issue:
4
+ --
5
+ -- acquire_claim guard=true ttl_cap=true quota=true
6
+ -- renew_claim guard=false ttl_cap=false quota=false
7
+ -- release_claim guard=false ttl_cap=false quota=false
8
+ --
9
+ -- `_claimgate_ownerless_ttl_cap()` (30 days) bounded an ownerless `file`/`tool`/`custom` lease at
10
+ -- ACQUIRE and nowhere else. An agent could take a 30-day lease and renew it indefinitely, past the
11
+ -- cap, forever. The duration bound was advertised and unenforced.
12
+ --
13
+ -- A COMMENT THAT DESCRIBED BEHAVIOUR THE CODE DID NOT HAVE. `_claimgate_ownerless_ttl_cap()` says
14
+ -- ENFORCE "extends the horizon to renew for all non-task kinds". For the ownerless kinds it
15
+ -- demonstrably did not — the ENFORCE rewrite (20260825000100) re-authorises and clamps only
16
+ -- machine/repository/repo_file and says so in its own text. That is the shape AGENTS.md rule 5
17
+ -- names: an intention written as a fact, which reads as true and is never re-checked.
18
+ --
19
+ -- CLAMP, NOT REJECT — and this differs from acquire deliberately. `acquire_claim` RAISES on an
20
+ -- over-cap lease (22023): nothing is lost, the caller simply asks for less. A renew that raised
21
+ -- would instead KILL A LIVE LEASE the agent is currently relying on, converting a length problem
22
+ -- into an availability incident. Renew already resolves this the same way for the fleet-gated
23
+ -- kinds — clamp to the horizon, and let an over-long legacy lease reconcile at its next renew --
24
+ -- so the ownerless clamp mirrors renew's own idiom rather than acquire's.
25
+ --
26
+ -- SAME FOUR CONDITIONS AS ACQUIRE, so one flag governs both paths and a board that opted out of
27
+ -- our locking is not clamped by it either:
28
+ -- the kind is ownerless · not service_role · _claimgate_ownerless_guards_enabled()
29
+ -- · the claim's board is not claim_policy='advisory'
30
+ --
31
+ -- WHAT THIS DOES NOT DO. The per-agent quota (50 concurrent ownerless claims) is untouched. It was
32
+ -- never defeated by this gap — renewing does not increase an agent's claim count — and all three
33
+ -- board seats on #369 converged on that independently. This closes the TTL half only.
34
+ create or replace function agent_board.renew_claim(p_lease_token uuid, p_lease_expires_at timestamptz)
35
+ returns table(claim_id uuid, task_id uuid, thread_id uuid, agent_id uuid, resource_kind text, resource_key text, status text, lease_token uuid, lease_expires_at timestamptz, released_at timestamptz, created_at timestamptz, updated_at timestamptz)
36
+ language plpgsql
37
+ security definer
38
+ set search_path = agent_board, public
39
+ as $$
40
+ declare
41
+ v_actor_agent_id uuid;
42
+ v_kind text;
43
+ v_key text;
44
+ v_thread_id uuid;
45
+ v_effective_expires timestamptz;
46
+ begin
47
+ if not agent_board._is_service_role() then
48
+ v_actor_agent_id := agent_board._resolved_actor_agent_id(null, 'current_agent_id');
49
+ if not exists (
50
+ select 1
51
+ from agent_board.claims c
52
+ where c.lease_token = p_lease_token
53
+ and c.agent_id = v_actor_agent_id
54
+ ) then
55
+ raise exception 'claim not accessible to current agent' using errcode = '42501';
56
+ end if;
57
+ end if;
58
+
59
+ -- CLAIMGATE/ENFORCE (#133): re-authorize + horizon for the predicate-gated kinds. Read the
60
+ -- live lease's resource identity (SECURITY DEFINER, so RLS does not hide it). A dead/expired
61
+ -- lease yields a null kind and the UPDATE below matches nothing (preserves prior behavior).
62
+ select c.resource_kind, c.resource_key, c.thread_id
63
+ into v_kind, v_key, v_thread_id
64
+ from agent_board.claims c
65
+ where c.lease_token = p_lease_token
66
+ and c.status = 'active'
67
+ and c.released_at is null
68
+ and c.lease_expires_at > now();
69
+
70
+ -- RE-AUTH: bound revocation latency to one lease term. Uses the SAME session wrapper acquire
71
+ -- consults (_can_claim_resource -> _can_claim_resource_for(current_agent_id())), so service_role
72
+ -- keeps its carve-out and acquire+renew share ONE per-kind flag (a mutation splitting the flags
73
+ -- fails a test). Task-renew is EXCLUDED (ownership-only, ratified supersession).
74
+ if v_kind in ('machine', 'repository', 'repo_file')
75
+ and agent_board._claimgate_kind_enforced(v_kind) then
76
+ if not agent_board._can_claim_resource(v_kind, agent_board._canonical_claim_key(v_kind, v_key)) then
77
+ raise exception 'not authorized to renew this % claim (fleet authority revoked)', v_kind
78
+ using errcode = '42501';
79
+ end if;
80
+ end if;
81
+
82
+ -- HORIZON: clamp the renewed lease to now()+horizon for an ENFORCED fleet-gated kind — this is
83
+ -- where an existing overlong lease reconciles (no bulk-clamp migration; the horizon clamps it
84
+ -- at its next renew). service_role exempt. Same per-kind flag as acquire.
85
+ v_effective_expires := p_lease_expires_at;
86
+ if v_kind in ('machine', 'repository', 'repo_file')
87
+ and not agent_board._is_service_role()
88
+ and agent_board._claimgate_kind_enforced(v_kind)
89
+ and p_lease_expires_at > now() + agent_board._claimgate_max_lease_horizon() then
90
+ v_effective_expires := now() + agent_board._claimgate_max_lease_horizon();
91
+ end if;
92
+
93
+ -- #373 OWNERLESS TTL CAP AT RENEW. The half that was missing: without this an agent renews a
94
+ -- file/tool/custom lease past the 30-day cap indefinitely and the bound is decorative.
95
+ --
96
+ -- Clamps rather than raises, unlike acquire. A raise here would kill a lease the agent is
97
+ -- actively holding; shortening it costs one more renew. Same resolution the fleet-kind horizon
98
+ -- clamp above already applies, and the same place an over-long legacy lease reconciles.
99
+ --
100
+ -- The four conditions are acquire's, verbatim, so ONE operator flag governs both paths and a
101
+ -- board that declined our locking (claim_policy='advisory') is not clamped by it either. The
102
+ -- thread comes from the LIVE CLAIM, never from a caller argument -- a caller-supplied thread
103
+ -- would let an agent choose which board's policy is consulted.
104
+ if v_kind in ('file', 'tool', 'custom')
105
+ and not agent_board._is_service_role()
106
+ and agent_board._claimgate_ownerless_guards_enabled()
107
+ and agent_board._claimgate_board_claim_policy(v_thread_id) <> 'advisory'
108
+ and v_effective_expires > now() + agent_board._claimgate_ownerless_ttl_cap() then
109
+ v_effective_expires := now() + agent_board._claimgate_ownerless_ttl_cap();
110
+ end if;
111
+
112
+ -- CLAIMKEY (#133) MUST-FIX 1: re-canonicalize the STORED key as the lease is renewed, but
113
+ -- NEVER propagate a uniqueness collision. A check-then-act guard is NOT race-safe: two aliased
114
+ -- legacy rows renewed concurrently at READ COMMITTED can both observe the canonical key free
115
+ -- and one loses on claims_active_resource_unique (23505). So attempt the canonicalization and,
116
+ -- on unique_violation (deterministic alias OR concurrent race), retry ONCE keeping the CURRENT
117
+ -- key. That makes renew non-raising UNCONDITIONALLY (true normalize-only); the surviving legacy
118
+ -- key is the deferred dual-key alias the ENFORCE phase closes.
119
+ begin
120
+ return query
121
+ update agent_board.claims c
122
+ set lease_expires_at = v_effective_expires,
123
+ resource_key = agent_board._canonical_claim_key(c.resource_kind, c.resource_key),
124
+ updated_at = now()
125
+ where c.lease_token = p_lease_token
126
+ and c.status = 'active'
127
+ and c.released_at is null
128
+ and c.lease_expires_at > now()
129
+ returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
130
+ exception when unique_violation then
131
+ -- the canonical key is already held (by a committed twin or a concurrent renew/acquire):
132
+ -- renew the lease keeping the current key, aliasing deferred to ENFORCE. Only the key change
133
+ -- can raise unique_violation (the lease/timestamp columns are unconstrained), so the retry
134
+ -- cannot itself collide.
135
+ return query
136
+ update agent_board.claims c
137
+ set lease_expires_at = v_effective_expires,
138
+ updated_at = now()
139
+ where c.lease_token = p_lease_token
140
+ and c.status = 'active'
141
+ and c.released_at is null
142
+ and c.lease_expires_at > now()
143
+ returning c.id, c.task_id, c.thread_id, c.agent_id, c.resource_kind, c.resource_key, c.status, c.lease_token, c.lease_expires_at, c.released_at, c.created_at, c.updated_at;
144
+ end;
145
+ end;
146
+ $$;
147
+
148
+ -- The comment #373 called out as describing behaviour the code did not have. It now does.
149
+ comment on function agent_board._claimgate_ownerless_ttl_cap() is
150
+ 'Max lease horizon for an ownerless (file/tool/custom) claim: 30 days. Enforced at BOTH acquire '
151
+ '(which RAISES 22023 on an over-cap request) and renew (which CLAMPS, because raising would kill '
152
+ 'a live lease the agent is relying on). #373 closed the renew half; before that this comment '
153
+ 'claimed a horizon renew did not apply and a lease could be renewed past the cap forever. '
154
+ 'Governed by _claimgate_ownerless_guards_enabled() on both paths, and skipped for a board whose '
155
+ 'claim_policy is advisory.';
156
+
157
+ -- Second stale comment, raised by a review seat: the flags table said this scope_key gates the
158
+ -- ownerless guards "in acquire_claim", which was true when written and is not now. Same rule-5
159
+ -- shape as the helper comment above -- a recorded fact that keeps asserting itself after the code
160
+ -- moved. Corrected here rather than left for the next reader to disbelieve.
161
+ comment on table agent_board.claimgate_enforcement_flags is
162
+ 'CLAIMGATE per-scope enforcement flags. Absent row => disabled (SHADOW default: blocks nothing). '
163
+ 'scope_key ''ownerless_guards'' gates the ownerless (file/tool/custom) guards on BOTH write '
164
+ 'paths: the TTL cap + per-agent quota in acquire_claim, and the TTL clamp in renew_claim (#373 '
165
+ '-- before that the cap was acquire-only and a lease could be renewed past it forever). '
166
+ 'Operator-managed (service_role); enabled only after the prod lease-length census.';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-schema",
3
- "version": "1.43.0",
3
+ "version": "1.45.0",
4
4
  "description": "Canonical, immutable, exact-pinnable agent_board schema bundle: ordered SQL migrations, digest + compatibility manifest, and deterministic materialize/plan/check tooling for adopters.",
5
5
  "repository": "https://github.com/Consiliency/message-board.git",
6
6
  "type": "module",
@@ -2,7 +2,7 @@
2
2
  "schema": "agent_board_schema_manifest.v1",
3
3
  "interface": "IF-0-MBPLANE-BUNDLE-1",
4
4
  "package": "@consiliency/agent-board-schema",
5
- "packageVersion": "1.43.0",
5
+ "packageVersion": "1.45.0",
6
6
  "schemaVersion": 8,
7
7
  "minClientVersion": "1.1.0",
8
8
  "source": {
@@ -46,8 +46,8 @@
46
46
  "usage": "upsert (retention/monitoring defaults; on conflict do nothing)"
47
47
  }
48
48
  ],
49
- "migrationCount": 141,
50
- "aggregateSha256": "d97c281cbc43a2c031dc769731473c6c9d1aa4facb0f72d07095cd1b9301a163",
49
+ "migrationCount": 143,
50
+ "aggregateSha256": "e5f248a69031759fbd220b5504268c4ca3159fc7cf641404badf2f2ae855e4ae",
51
51
  "migrations": [
52
52
  {
53
53
  "order": 0,
@@ -1317,6 +1317,24 @@
1317
1317
  "sourceRef": "message-board",
1318
1318
  "bytes": 7731,
1319
1319
  "sha256": "22713b970564bc59433026f70934758982b4cf7832be9c06197faa2d4ecc0040"
1320
+ },
1321
+ {
1322
+ "order": 141,
1323
+ "id": "20260924000100_agent_board_agent_liveness_denominator",
1324
+ "filename": "20260924000100_agent_board_agent_liveness_denominator.sql",
1325
+ "origin": "message-board-forward",
1326
+ "sourceRef": "message-board",
1327
+ "bytes": 10530,
1328
+ "sha256": "deb58ead6c4d7bbda686b85191d5eb2f2771ae2a405933ffbfbaabd7ad0117d9"
1329
+ },
1330
+ {
1331
+ "order": 142,
1332
+ "id": "20260925000100_agent_board_ownerless_renew_ttl_cap",
1333
+ "filename": "20260925000100_agent_board_ownerless_renew_ttl_cap.sql",
1334
+ "origin": "message-board-forward",
1335
+ "sourceRef": "message-board",
1336
+ "bytes": 9981,
1337
+ "sha256": "da312f43cabf07c6dfd10dbe2a119fb46f49f24155e21adce4f38e291a38103c"
1320
1338
  }
1321
1339
  ],
1322
1340
  "excludedBridge": {
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
38
38
  expect(manifest.schemaVersion).toBe(8);
39
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
40
40
  });
41
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 141)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + one LIVENESSDENOM + one OWNERLESSRENEWTTL 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 + 1 = 143)', () => {
42
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
43
- expect(manifest.migrationCount).toBe(141);
43
+ expect(manifest.migrationCount).toBe(143);
44
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
45
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
46
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -262,6 +262,21 @@ describe('manifest integrity', () => {
262
262
  // existing row; a first heartbeat still lands 'ready'/'{}'. Signature unchanged (parameter
263
263
  // defaults are not part of function identity), so the ACL and COMMENT survive.
264
264
  '20260923000100_agent_board_heartbeat_preserve.sql',
265
+ // #367 LIVENESSDENOM. v_ops_presence_heartbeats selects FROM presence, and presence has no
266
+ // row for an agent that never heartbeated -- so it detects only "was reporting, then
267
+ // stopped" and is structurally blind to "never reported". Measured on the canonical project
268
+ // the day #366 shipped the writer half: 0 presence rows, 8 active agents, and the view
269
+ // returned zero rows, which is byte-identical to a healthy fleet. v_agent_liveness selects
270
+ // FROM agents and LEFT JOINs presence so a never-reporting agent is NAMED, and
271
+ // agent_liveness_summary() always emits reporting_agents beside the stale count so a caller
272
+ // cannot render "0 stale" as "all clear".
273
+ '20260924000100_agent_board_agent_liveness_denominator.sql',
274
+ // #373 OWNERLESSRENEWTTL. _claimgate_ownerless_ttl_cap bounded a file/tool/custom lease at
275
+ // ACQUIRE and nowhere else, so an agent could renew past the 30-day cap indefinitely and the
276
+ // advertised bound did not exist. renew_claim now clamps (rather than raises -- a raise would
277
+ // kill a live lease) under acquire's own four conditions, so one operator flag governs both
278
+ // paths and an advisory board is exempt on both.
279
+ '20260925000100_agent_board_ownerless_renew_ttl_cap.sql',
265
280
  ]) {
266
281
  expect(names.has(f)).toBe(true);
267
282
  }
@@ -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,226 @@
1
+ -- #373 — the ownerless TTL cap must bind at RENEW, not only at acquire.
2
+ --
3
+ -- ORT-3 IS THE ASSERTION THIS FILE EXISTS FOR, and it is written as the ATTACK: acquire a lease
4
+ -- inside the cap, then renew it far past the cap. Before this migration that succeeded and could
5
+ -- be repeated forever, so the 30-day bound the cap advertised did not exist. The existing suite
6
+ -- could not have caught its removal because it exercises acquire only — which is exactly how the
7
+ -- gap survived.
8
+ --
9
+ -- ORT-4 IS THE TWIN, and without it ORT-3 is satisfiable by a function that simply refuses to
10
+ -- renew anything. Clamping must still RENEW: a within-cap request has to pass through untouched.
11
+ -- A guard that works by breaking the feature is the failure mode this repo has hit before.
12
+ --
13
+ -- WHY CLAMP AND NOT RAISE, asserted rather than assumed (ORT-3 checks the resulting timestamp, not
14
+ -- an error): acquire RAISES on an over-cap request because nothing is lost by asking again, but a
15
+ -- renew that raised would kill a lease the agent is actively holding. Renew already resolves the
16
+ -- fleet-kind horizon the same way. If someone later "hardens" this into a raise, ORT-3 fails.
17
+ --
18
+ -- SCOPING. The guard flag is fleet-wide state shared with every other test, so this file SETS it
19
+ -- explicitly rather than inheriting whatever ran before — an inherited `false` would make every
20
+ -- assertion here pass vacuously against a completely unguarded function.
21
+ create extension if not exists pgtap with schema extensions;
22
+ set search_path = extensions, public, agent_board;
23
+
24
+ begin;
25
+
26
+ select plan(11);
27
+
28
+ -- ---------------------------------------------------------------------------
29
+ -- Fixture: one agent, one board-linked thread, and the guard flag ON.
30
+ -- ---------------------------------------------------------------------------
31
+ create temp table _ort (agent uuid, auth_user uuid, thread uuid, board uuid) on commit drop;
32
+
33
+ do $$
34
+ declare
35
+ v_sfx text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
36
+ v_agent uuid := gen_random_uuid();
37
+ v_user uuid;
38
+ v_thread uuid := gen_random_uuid();
39
+ v_board uuid;
40
+ begin
41
+ v_user := public.ensure_github_user('ort-' || v_sfx, 'ort-' || v_sfx || '@example.test', 'ort-' || v_sfx, 'ORT Agent');
42
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
43
+ values (v_agent, 'agent', 'ort-' || v_sfx, 'ORT Agent', 'active', v_user, v_user);
44
+
45
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
46
+ perform set_config('request.jwt.claim.sub', v_user::text, true);
47
+ select board_id into v_board
48
+ from agent_board.create_board(v_agent, 'ort-' || v_sfx, 'ORT Board', 'ownerless renew', '{}'::jsonb, 'private');
49
+
50
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
51
+ values (v_thread, 'coordination', v_agent, 'ORT thread', v_board);
52
+
53
+ insert into _ort values (v_agent, v_user, v_thread, v_board);
54
+ end $$;
55
+
56
+ -- The guard must be ON or every assertion below passes against an unguarded function.
57
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled)
58
+ values ('ownerless_guards', true)
59
+ on conflict (scope_key) do update set enabled = true;
60
+
61
+ -- ---------------------------------------------------------------------------
62
+ -- ORT-1/2: the acquire half still behaves — the baseline this migration must not disturb.
63
+ -- ---------------------------------------------------------------------------
64
+ select throws_ok(
65
+ format($$ select * from agent_board.acquire_claim(%L, 'file', 'ort-overcap-acquire', now() + interval '400 days', null, %L) $$,
66
+ (select agent from _ort)::text, (select thread from _ort)::text),
67
+ '22023',
68
+ null,
69
+ 'ORT-1: acquire still REJECTS an over-cap ownerless lease (22023) — the half that already worked');
70
+
71
+ select lives_ok(
72
+ format($$ select * from agent_board.acquire_claim(%L, 'file', 'ort-within-cap', now() + interval '1 day', null, %L) $$,
73
+ (select agent from _ort)::text, (select thread from _ort)::text),
74
+ 'ORT-2: a within-cap acquire still succeeds');
75
+
76
+ -- ---------------------------------------------------------------------------
77
+ -- ORT-3: THE ATTACK. Renew that same lease far past the cap. Before this migration the renewed
78
+ -- lease landed at +400 days; now it is clamped to the 30-day horizon.
79
+ -- ---------------------------------------------------------------------------
80
+ create temp table _tok (lease_token uuid) on commit drop;
81
+ insert into _tok
82
+ select lease_token from agent_board.claims
83
+ where agent_id = (select agent from _ort) and resource_key = 'ort-within-cap' and status = 'active';
84
+
85
+ do $$
86
+ begin
87
+ perform agent_board.renew_claim((select lease_token from _tok), now() + interval '400 days');
88
+ end $$;
89
+
90
+ select ok(
91
+ (select lease_expires_at <= now() + agent_board._claimgate_ownerless_ttl_cap() + interval '1 minute'
92
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
93
+ 'ORT-3 THE POINT: a renew asking for +400 days is CLAMPED to the 30-day cap — before this it landed at 400 days and could be repeated forever');
94
+
95
+ select ok(
96
+ (select lease_expires_at > now() + interval '29 days'
97
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
98
+ 'ORT-3b: and it is clamped TO the cap, not to something arbitrarily short');
99
+
100
+ -- ---------------------------------------------------------------------------
101
+ -- ORT-4: THE TWIN. Clamping must not become refusing — a within-cap renew passes through exactly.
102
+ -- ---------------------------------------------------------------------------
103
+ do $$
104
+ begin
105
+ perform agent_board.renew_claim((select lease_token from _tok), now() + interval '2 days');
106
+ end $$;
107
+
108
+ select ok(
109
+ (select lease_expires_at between now() + interval '47 hours' and now() + interval '49 hours'
110
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
111
+ 'ORT-4: a WITHIN-cap renew is untouched (+2 days lands at +2 days) — a guard that refused every renew would pass ORT-3 and be useless');
112
+
113
+ -- ---------------------------------------------------------------------------
114
+ -- ORT-5: the operator carve-out. service_role is exempt on renew exactly as on acquire, so an
115
+ -- operator can still extend a lease deliberately.
116
+ -- ---------------------------------------------------------------------------
117
+ do $$
118
+ begin
119
+ perform set_config('request.jwt.claims', '', true);
120
+ perform set_config('request.jwt.claim.sub', '', true);
121
+ perform set_config('request.jwt.claim.role', '', true);
122
+ perform agent_board.renew_claim((select lease_token from _tok), now() + interval '400 days');
123
+ end $$;
124
+
125
+ select ok(
126
+ (select lease_expires_at > now() + interval '300 days'
127
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
128
+ 'ORT-5: service_role is EXEMPT at renew, same carve-out as acquire — an operator extension is not clamped');
129
+
130
+ -- ---------------------------------------------------------------------------
131
+ -- ORT-6: the flag governs BOTH paths. With ownerless_guards off, renew stops clamping — so the
132
+ -- operator switch means the same thing on each path rather than half-latching.
133
+ -- ---------------------------------------------------------------------------
134
+ do $$
135
+ begin
136
+ update agent_board.claimgate_enforcement_flags set enabled = false where scope_key = 'ownerless_guards';
137
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
138
+ perform set_config('request.jwt.claim.sub', (select auth_user from _ort)::text, true);
139
+ perform agent_board.renew_claim((select lease_token from _tok), now() + interval '400 days');
140
+ end $$;
141
+
142
+ select ok(
143
+ (select lease_expires_at > now() + interval '300 days'
144
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
145
+ 'ORT-6: with ownerless_guards OFF the renew is NOT clamped — one flag governs acquire and renew alike');
146
+
147
+ -- ---------------------------------------------------------------------------
148
+ -- ORT-7: the board opt-out reaches renew too. A board coordinating its own locking
149
+ -- (claim_policy='advisory') is not clamped on either path.
150
+ -- ---------------------------------------------------------------------------
151
+ do $$
152
+ begin
153
+ update agent_board.claimgate_enforcement_flags set enabled = true where scope_key = 'ownerless_guards';
154
+ perform agent_board.set_board_claim_policy((select board from _ort), 'advisory');
155
+ perform agent_board.renew_claim((select lease_token from _tok), now() + interval '400 days');
156
+ end $$;
157
+
158
+ select ok(
159
+ (select lease_expires_at > now() + interval '300 days'
160
+ from agent_board.claims where lease_token = (select lease_token from _tok)),
161
+ 'ORT-7: a board with claim_policy=advisory is not clamped at renew either — the opt-out is not acquire-only, which would have been the same bug one layer down');
162
+
163
+ -- ---------------------------------------------------------------------------
164
+ -- ORT-8: CLASS. Catalog-derived, so the acquire/renew asymmetry that caused #373 fails a test if
165
+ -- it ever returns. Scored on the pair rather than on renew alone: asserting only "renew has the
166
+ -- cap" would pass if acquire silently LOST it.
167
+ -- ---------------------------------------------------------------------------
168
+ select is(
169
+ (select string_agg(p.proname || '=' || (agent_board_test.stripped_body(p.oid, true) ~ '_claimgate_ownerless_ttl_cap')::text, ', ' order by p.proname)
170
+ from pg_proc p join pg_namespace n on n.oid = p.pronamespace
171
+ where n.nspname = 'agent_board' and p.proname in ('acquire_claim', 'renew_claim')),
172
+ 'acquire_claim=true, renew_claim=true',
173
+ 'ORT-8 CLASS: BOTH acquire and renew reference the ownerless TTL cap — #373 was exactly this pair reading true/false');
174
+
175
+ -- ---------------------------------------------------------------------------
176
+ -- ORT-9: THE OTHER TWO KINDS. Every assertion above drives `file`, so dropping 'tool' or 'custom'
177
+ -- from renew's kind list passed the entire battery — named independently by two review seats as
178
+ -- the seventh mutation, and they were right. ORT-8's catalog scan cannot see it either: the helper
179
+ -- reference stays present while two thirds of the class stop being covered.
180
+ --
181
+ -- Both kinds are exercised BEHAVIOURALLY rather than by widening the scan, because the defect is
182
+ -- "this kind is not clamped", which only a clamp observation can catch.
183
+ -- ---------------------------------------------------------------------------
184
+ do $$
185
+ declare k text; tok uuid;
186
+ begin
187
+ perform set_config('request.jwt.claim.role', 'authenticated', true);
188
+ perform set_config('request.jwt.claim.sub', (select auth_user from _ort)::text, true);
189
+ perform agent_board.set_board_claim_policy((select board from _ort), 'inherit');
190
+ foreach k in array array['tool', 'custom'] loop
191
+ select lease_token into tok
192
+ from agent_board.acquire_claim((select agent from _ort), k, 'ort-' || k, now() + interval '1 day', null, (select thread from _ort));
193
+ perform agent_board.renew_claim(tok, now() + interval '400 days');
194
+ end loop;
195
+ end $$;
196
+
197
+ select is(
198
+ (select string_agg(resource_kind || '=' ||
199
+ (lease_expires_at <= now() + agent_board._claimgate_ownerless_ttl_cap() + interval '1 minute')::text, ', ' order by resource_kind)
200
+ from agent_board.claims
201
+ where agent_id = (select agent from _ort) and resource_kind in ('tool', 'custom') and status = 'active'),
202
+ 'custom=true, tool=true',
203
+ 'ORT-9: tool and custom are clamped at renew too — every other assertion drives `file`, so dropping either from the kind list passed the whole battery');
204
+
205
+ -- ---------------------------------------------------------------------------
206
+ -- ORT-10: A CLAIM WITH NO THREAD IS STILL CLAMPED. The renew-side twin of BCP-5: the board opt-out
207
+ -- is resolved from the claim's thread, and a claim with no thread must fall back to `inherit`
208
+ -- rather than escaping the guard by omission. Named by a third seat as its own seventh mutation —
209
+ -- adding `and v_thread_id is not null` to the renew guard would let an agent acquire a within-cap
210
+ -- threadless lease and then renew it to +400 days.
211
+ -- ---------------------------------------------------------------------------
212
+ do $$
213
+ declare tok uuid;
214
+ begin
215
+ select lease_token into tok
216
+ from agent_board.acquire_claim((select agent from _ort), 'file', 'ort-nothread', now() + interval '1 day', null, null);
217
+ perform agent_board.renew_claim(tok, now() + interval '400 days');
218
+ end $$;
219
+
220
+ select ok(
221
+ (select lease_expires_at <= now() + agent_board._claimgate_ownerless_ttl_cap() + interval '1 minute'
222
+ from agent_board.claims where agent_id = (select agent from _ort) and resource_key = 'ort-nothread' and status = 'active'),
223
+ 'ORT-10: a claim with NO thread is still clamped at renew — the board opt-out cannot be obtained by omitting the thread');
224
+
225
+ select * from finish();
226
+ 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