@consiliency/agent-board-schema 1.45.0 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +24 -0
- package/migrations/20260926000100_agent_board_ownerless_quota_census.sql +268 -0
- package/migrations/20260927000100_agent_board_fleet_domain_no_downgrade.sql +393 -0
- package/migrations/20260928000100_agent_board_ownerless_quota_renew_convergence.sql +562 -0
- package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
- package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
- package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
- package/package.json +1 -1
- package/schema-manifest.json +57 -3
- package/src/tooling.test.mjs +52 -2
- package/tests/agent_board_board_claim_policy.test.sql +261 -1
- package/tests/agent_board_claim_subject_gate.test.sql +11 -2
- package/tests/agent_board_claimkey.test.sql +17 -2
- package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +270 -1
- package/tests/agent_board_fleet_machine_reclaim.test.sql +28 -2
- package/tests/agent_board_ownerless_quota_census.test.sql +406 -0
- package/tests/agent_board_ownerless_renew_quota.test.sql +580 -0
- package/tests/catalog/p_metadata_parameters.json +28 -0
- package/tests/fixtures/test-prereqs.sql +186 -0
- package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
- package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
- package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
- package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
- package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
- package/tests/isolation/expected/fleet_domain_no_downgrade_demote_race.out +447 -0
- package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
- package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
- package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
- package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
- package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
- package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
- package/tests/isolation/specs/fleet_domain_no_downgrade_demote_race.spec +292 -0
- package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
-- Consiliency/message-board#405 — the board policy lock helper is granted to NOBODY.
|
|
2
|
+
--
|
|
3
|
+
-- WHAT THIS CLOSES, measured rather than argued. 20260930000100 revoked
|
|
4
|
+
-- `_claimgate_lock_board_policy` from public/anon/authenticated and GRANTED it to `service_role`,
|
|
5
|
+
-- on the reasoning (stated in its own comment) that the serialisation "cannot be reached from a
|
|
6
|
+
-- session". `service_role` IS a session role. With that grant in place a single service_role
|
|
7
|
+
-- transaction assembles the exact inversion claimpolicy_claimrow_lock_order.spec constructs by
|
|
8
|
+
-- hand:
|
|
9
|
+
--
|
|
10
|
+
-- BEGIN; select agent_board.renew_claim(<token>, …); -- claims ROW LOCK, no board lock
|
|
11
|
+
-- select agent_board._claimgate_lock_board_policy(<board>); -- board lock, AFTER it
|
|
12
|
+
--
|
|
13
|
+
-- and a concurrent `set_board_claim_policy` on that board (board lock, then #400's cap-reset
|
|
14
|
+
-- UPDATE onto the row session 1 holds) closes the cycle. Reproduced against this bundle as
|
|
15
|
+
-- `ERROR: deadlock detected` before this migration existed.
|
|
16
|
+
--
|
|
17
|
+
-- WHY renew_claim STILL TAKES THE ROW LOCK FOR service_role, which is the part that makes the
|
|
18
|
+
-- composition work: its `update agent_board.claims` sits OUTSIDE the ownerless block, so
|
|
19
|
+
-- `_is_service_role()` short-circuits the BOARD lock while the ROW lock is taken regardless. The
|
|
20
|
+
-- caller therefore holds a claims row lock and no board lock — precisely the state the inversion
|
|
21
|
+
-- needs — and only needs one more granted call to want the board lock.
|
|
22
|
+
--
|
|
23
|
+
-- WHY REVOKING IS SAFE, derived from the catalog rather than assumed. The helper is NOT
|
|
24
|
+
-- `SECURITY DEFINER`; its three callers (`acquire_claim`, `renew_claim`, `set_board_claim_policy`)
|
|
25
|
+
-- ARE, and are owned by the same role that owns the helper. A SECURITY DEFINER function executes
|
|
26
|
+
-- as its owner, and an owner needs no grant on its own function, so the definer path is unaffected
|
|
27
|
+
-- by any grant at all. CPR-22 asserts the empty ACL; CPR-22a asserts the definer path still
|
|
28
|
+
-- reaches the lock AND that the advisory lock is genuinely held afterwards, so the negative cannot
|
|
29
|
+
-- pass by the enforcement having been taken offline.
|
|
30
|
+
--
|
|
31
|
+
-- NOTHING CALLS IT DIRECTLY. Derived from the tree on this branch, not from a live database: the
|
|
32
|
+
-- only references to `_claimgate_lock_board_policy` outside its defining migration are in this
|
|
33
|
+
-- package's pgTAP file, the isolation-spec COMMENTS, and the p_metadata catalog snapshot. No
|
|
34
|
+
-- TypeScript package (client/cli/mcp/runtime) and no Edge Function names it, in any casing.
|
|
35
|
+
--
|
|
36
|
+
-- WHY A NEW MIGRATION RATHER THAN EDITING 20260930000100's GRANT LINE. That migration is this
|
|
37
|
+
-- PR's own and still unmerged, so amending it would be tidier history — but this repository cannot
|
|
38
|
+
-- prove that no database has already applied it (the canary project tracks this branch), and a
|
|
39
|
+
-- bundle is replayed by digest rather than re-derived. A forward revoke is correct whether or not
|
|
40
|
+
-- the grant was ever applied, and `revoke` on an absent grant is a no-op. Replay-safe either way.
|
|
41
|
+
--
|
|
42
|
+
-- NOT `DROP FUNCTION`: that would discard the ACL *and* the COMMENT and re-grant EXECUTE to PUBLIC
|
|
43
|
+
-- by default (AGENTS.md, "Schema-change mechanics that bite"). The function body is unchanged here;
|
|
44
|
+
-- only its ACL and its comment move.
|
|
45
|
+
|
|
46
|
+
-- THE REVOKE IS DERIVED, ACROSS EVERY OVERLOAD, AND CASCADES. Three separate holes closed here,
|
|
47
|
+
-- each of which would leave this migration's own comment asserting something false:
|
|
48
|
+
--
|
|
49
|
+
-- 1. NAMED ROLES ARE A HAND LIST. `revoke ... from public, anon, authenticated, service_role`
|
|
50
|
+
-- leaves a grant to any fifth role standing. That is not hypothetical bookkeeping — this bundle
|
|
51
|
+
-- is REPLAYED ONTO ADOPTER DATABASES we do not control, where an operator may have granted this
|
|
52
|
+
-- helper to a role we have never heard of.
|
|
53
|
+
--
|
|
54
|
+
-- 2. ONE SIGNATURE IS A HAND LIST TOO. Keying the sweep to `(uuid)` lets a `(text)` overload carry
|
|
55
|
+
-- a grant straight through it. The property wanted is about the NAME: nothing in agent_board
|
|
56
|
+
-- called _claimgate_lock_board_policy should be executable by a non-owner, whatever its
|
|
57
|
+
-- argument types. Every overload is swept.
|
|
58
|
+
--
|
|
59
|
+
-- 3. `REVOKE` DEFAULTS TO `RESTRICT`, AND RESTRICT FAILS ON A DEPENDENT GRANT. If a grantee holds
|
|
60
|
+
-- EXECUTE **WITH GRANT OPTION** and has granted it onward, a plain REVOKE ERRORS ("dependent
|
|
61
|
+
-- privileges exist") — and on an adopter database that is a FAILED MIGRATION, not a tidier ACL,
|
|
62
|
+
-- with the empty ACL not achieved either. `CASCADE` is chosen deliberately over enumerating
|
|
63
|
+
-- dependencies: the whole claim of this migration is that NOTHING non-owner holds EXECUTE, so a
|
|
64
|
+
-- downstream grant is exactly as unwanted as the grant it hangs from, and handling dependencies
|
|
65
|
+
-- individually would only reach the same end state by a longer route. Stated rather than
|
|
66
|
+
-- slipped in, because CASCADE silently removes grants a third party made: an operator who
|
|
67
|
+
-- deliberately delegated this helper onward will find that delegation gone after upgrading.
|
|
68
|
+
-- That is the intended outcome — the helper is not meant to be reachable from any session — but
|
|
69
|
+
-- it is a real effect on someone else's database and belongs in the record.
|
|
70
|
+
--
|
|
71
|
+
-- 4. `ON FUNCTION` IS THE WRONG VERB FOR A ROUTINE, AND RAISES. The scan above selects by NAME,
|
|
72
|
+
-- which admits any pg_proc row — a legal same-name PROCEDURE or AGGREGATE included. `REVOKE …
|
|
73
|
+
-- ON FUNCTION` against a procedure fails with `is not a function`, and it would fail at the
|
|
74
|
+
-- MATERIALISATION statement, before the sweep runs at all: a second adopter-upgrade abort from
|
|
75
|
+
-- the same source as the RESTRICT one, a query written for functions applied to routines.
|
|
76
|
+
-- Measured on this bundle: `on function` against a procedure errors, `on routine` succeeds
|
|
77
|
+
-- against a procedure AND an aggregate. So both statements say ROUTINE and there is no prokind
|
|
78
|
+
-- filter — operating on routines consistently is the choice, rather than narrowing the scan to
|
|
79
|
+
-- prokind='f' and leaving a same-name procedure's grant standing. Window functions ('w') cannot
|
|
80
|
+
-- be created without C-level support and are untested here; ROUTINE is the widest verb available
|
|
81
|
+
-- and no narrower verb would cover more.
|
|
82
|
+
--
|
|
83
|
+
-- The OWNER is excluded throughout: EXECUTE is implicit in ownership, cannot be revoked, and is
|
|
84
|
+
-- exactly the path the three SECURITY DEFINER callers use.
|
|
85
|
+
--
|
|
86
|
+
-- A missing function RAISES rather than silently sweeping nothing: this migration exists to
|
|
87
|
+
-- establish an ACL, and doing that to a function that is not there is a failure, not a success.
|
|
88
|
+
-- Likewise an unresolvable grantee OID. Both differ from CPR-22, which RENDERS those cases instead
|
|
89
|
+
-- — a raise inside a pgTAP assertion aborts the file and emits no `not ok`, whereas here failing
|
|
90
|
+
-- closed is right, because skipping silently would leave a live grant standing.
|
|
91
|
+
do $$
|
|
92
|
+
declare
|
|
93
|
+
v_overloads int := 0;
|
|
94
|
+
v_who text;
|
|
95
|
+
f record;
|
|
96
|
+
r record;
|
|
97
|
+
begin
|
|
98
|
+
for f in
|
|
99
|
+
select p.oid, p.oid::regprocedure::text as sig
|
|
100
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
101
|
+
where n.nspname = 'agent_board'
|
|
102
|
+
and p.proname = '_claimgate_lock_board_policy'
|
|
103
|
+
loop
|
|
104
|
+
v_overloads := v_overloads + 1;
|
|
105
|
+
|
|
106
|
+
-- MATERIALISE THE ACL FIRST. While `proacl` is NULL the function carries DEFAULT privileges,
|
|
107
|
+
-- and the default for a function is EXECUTE to PUBLIC — but aclexplode(NULL) yields no rows,
|
|
108
|
+
-- so the sweep below would find nothing to revoke on a database where no grant was ever
|
|
109
|
+
-- written explicitly. This statement both removes that implicit PUBLIC grant and populates
|
|
110
|
+
-- proacl so the sweep can see the rest.
|
|
111
|
+
execute format('revoke all on routine %s from public cascade', f.sig);
|
|
112
|
+
|
|
113
|
+
for r in
|
|
114
|
+
select distinct a.grantee
|
|
115
|
+
from pg_proc p, aclexplode(p.proacl) a
|
|
116
|
+
where p.oid = f.oid
|
|
117
|
+
and a.privilege_type = 'EXECUTE'
|
|
118
|
+
and a.grantee <> p.proowner
|
|
119
|
+
loop
|
|
120
|
+
if r.grantee = 0 then
|
|
121
|
+
v_who := 'public';
|
|
122
|
+
else
|
|
123
|
+
select quote_ident(rolname) into v_who from pg_roles where oid = r.grantee;
|
|
124
|
+
end if;
|
|
125
|
+
if v_who is null then
|
|
126
|
+
raise exception
|
|
127
|
+
'EXECUTE grantee % on % has no pg_roles row — cannot revoke it, and reporting an empty ACL would be false',
|
|
128
|
+
r.grantee, f.sig
|
|
129
|
+
using errcode = '42704';
|
|
130
|
+
end if;
|
|
131
|
+
execute format('revoke all on routine %s from %s cascade', f.sig, v_who);
|
|
132
|
+
end loop;
|
|
133
|
+
end loop;
|
|
134
|
+
|
|
135
|
+
if v_overloads = 0 then
|
|
136
|
+
raise exception
|
|
137
|
+
'no function named agent_board._claimgate_lock_board_policy — refusing to report an empty ACL for a function that does not exist'
|
|
138
|
+
using errcode = '42883';
|
|
139
|
+
end if;
|
|
140
|
+
end $$;
|
|
141
|
+
|
|
142
|
+
-- The comment 20260930000100 attached asserts the service_role grant this migration removes, and
|
|
143
|
+
-- cites CPR-14 for the ACL — CPR-14 is the claims row-lock class assertion; CPR-22 is the ACL one.
|
|
144
|
+
-- Both corrected here, in the same migration that makes the first sentence true.
|
|
145
|
+
comment on function agent_board._claimgate_lock_board_policy(uuid) is
|
|
146
|
+
'#405: the board-scoped serialisation point for claim policy. Takes a pg_advisory_xact_lock on '
|
|
147
|
+
'hashtextextended(''agent_board.board_claim_policy:''||board_id) so set_board_claim_policy''s '
|
|
148
|
+
'flip and every ownerless enforcement decision on that board are totally ordered, and RAISES '
|
|
149
|
+
'0A000 unless transaction_isolation is ''read committed'' — under repeatable read or '
|
|
150
|
+
'serializable the wait does not advance the caller''s snapshot, so the lock would be silently '
|
|
151
|
+
'ineffective; under read uncommitted the lock still works (PostgreSQL runs it as read '
|
|
152
|
+
'committed) and the refusal is a deliberate one-accepted-spelling contract rather than a '
|
|
153
|
+
'correctness requirement. A null board is isolation-checked and takes no lock. '
|
|
154
|
+
'EXECUTE is granted to NO role (#405 follow-up): the helper is not SECURITY DEFINER, its three '
|
|
155
|
+
'callers are and share its owner, so they reach it by ownership. The grant that existed briefly '
|
|
156
|
+
'let a service_role session compose renew_claim''s claims row lock with a direct call to this '
|
|
157
|
+
'helper and deadlock against the policy writer. CPR-22 pins the empty ACL; CPR-22a pins that '
|
|
158
|
+
'the definer path still works.';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consiliency/agent-board-schema",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.50.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.50.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": 149,
|
|
50
|
+
"aggregateSha256": "c15239411cdaff38ce6a52294d6586ae6fe711cdea40938785c55e7e38e13c0b",
|
|
51
51
|
"migrations": [
|
|
52
52
|
{
|
|
53
53
|
"order": 0,
|
|
@@ -1335,6 +1335,60 @@
|
|
|
1335
1335
|
"sourceRef": "message-board",
|
|
1336
1336
|
"bytes": 9981,
|
|
1337
1337
|
"sha256": "da312f43cabf07c6dfd10dbe2a119fb46f49f24155e21adce4f38e291a38103c"
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
"order": 143,
|
|
1341
|
+
"id": "20260926000100_agent_board_ownerless_quota_census",
|
|
1342
|
+
"filename": "20260926000100_agent_board_ownerless_quota_census.sql",
|
|
1343
|
+
"origin": "message-board-forward",
|
|
1344
|
+
"sourceRef": "message-board",
|
|
1345
|
+
"bytes": 17894,
|
|
1346
|
+
"sha256": "bbfabcb2d45cbcebd1e6e121032a1045e8406ea8763a784762a92d210e1e1043"
|
|
1347
|
+
},
|
|
1348
|
+
{
|
|
1349
|
+
"order": 144,
|
|
1350
|
+
"id": "20260927000100_agent_board_fleet_domain_no_downgrade",
|
|
1351
|
+
"filename": "20260927000100_agent_board_fleet_domain_no_downgrade.sql",
|
|
1352
|
+
"origin": "message-board-forward",
|
|
1353
|
+
"sourceRef": "message-board",
|
|
1354
|
+
"bytes": 25689,
|
|
1355
|
+
"sha256": "ec5c4d89aff2d6d77d44cb3d00c1096b39010c6064c23d72eb3c5c354f8a5e74"
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
"order": 145,
|
|
1359
|
+
"id": "20260928000100_agent_board_ownerless_quota_renew_convergence",
|
|
1360
|
+
"filename": "20260928000100_agent_board_ownerless_quota_renew_convergence.sql",
|
|
1361
|
+
"origin": "message-board-forward",
|
|
1362
|
+
"sourceRef": "message-board",
|
|
1363
|
+
"bytes": 36921,
|
|
1364
|
+
"sha256": "9982efff25378703a744187b15081b5cd53ab38fa6ac7d2950092a471780169e"
|
|
1365
|
+
},
|
|
1366
|
+
{
|
|
1367
|
+
"order": 146,
|
|
1368
|
+
"id": "20260929000100_agent_board_advisory_revert_cap_reset",
|
|
1369
|
+
"filename": "20260929000100_agent_board_advisory_revert_cap_reset.sql",
|
|
1370
|
+
"origin": "message-board-forward",
|
|
1371
|
+
"sourceRef": "message-board",
|
|
1372
|
+
"bytes": 24052,
|
|
1373
|
+
"sha256": "f2bd4e9e06f5c348aba379a004a010d8dba29a84f66b1b4569313fad4a0046ba"
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
"order": 147,
|
|
1377
|
+
"id": "20260930000100_agent_board_claim_policy_race",
|
|
1378
|
+
"filename": "20260930000100_agent_board_claim_policy_race.sql",
|
|
1379
|
+
"origin": "message-board-forward",
|
|
1380
|
+
"sourceRef": "message-board",
|
|
1381
|
+
"bytes": 58999,
|
|
1382
|
+
"sha256": "1893b57ccb643a8293c0dc801d2d66d20fe1d71a2b7dc95cd93f64ced39b472d"
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
"order": 148,
|
|
1386
|
+
"id": "20261001000100_agent_board_claimpolicy_lock_grant_revoke",
|
|
1387
|
+
"filename": "20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql",
|
|
1388
|
+
"origin": "message-board-forward",
|
|
1389
|
+
"sourceRef": "message-board",
|
|
1390
|
+
"bytes": 10193,
|
|
1391
|
+
"sha256": "db73a9941d4601dd6db0c767fdc718534fe2856598a305f4747496cadc722afd"
|
|
1338
1392
|
}
|
|
1339
1393
|
],
|
|
1340
1394
|
"excludedBridge": {
|
package/src/tooling.test.mjs
CHANGED
|
@@ -38,9 +38,9 @@ describe('manifest integrity', () => {
|
|
|
38
38
|
expect(manifest.schemaVersion).toBe(8);
|
|
39
39
|
expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
|
|
40
40
|
});
|
|
41
|
-
it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE + one ENROLLRESUMPTION + one SIGNEDAUTHORITY + one SIGNERLIFECYCLE + one LEDGERAUTHORITY + one CEILINGLOCKCOMMENT + one LEDGERBOARDCREATE + one RECEIPTSUBJECTGATE + one TASKTHREADLOCKORDER + one CLAIMSUBJECTGATE + one FEDSESSIONIDENTITYGATE + one FEDIDENTITYSLUG + one INVITENODOWNGRADE + one SIGNEDWRITEFLAG + one SIGNEDWRITEMODEL + one RTCREDCLAIM + one ORIGINPOSTURE + one BOARDCLAIMPOLICY + one HEARTBEATPRESERVE + 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 =
|
|
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 + one OWNERLESSQUOTACENSUS + one FLEETDOMAINNODOWNGRADE + one OWNERLESSQUOTACONVERGE + one ADVISORYREVERTRESET + one CLAIMPOLICYRACE + one CLAIMPOLICYLOCKGRANT 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 + 1 + 1 + 1 + 1 + 1 + 1 = 149)', () => {
|
|
42
42
|
expect(manifest.migrationCount).toBe(manifest.migrations.length);
|
|
43
|
-
expect(manifest.migrationCount).toBe(
|
|
43
|
+
expect(manifest.migrationCount).toBe(149);
|
|
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,
|
|
@@ -277,6 +277,56 @@ describe('manifest integrity', () => {
|
|
|
277
277
|
// kill a live lease) under acquire's own four conditions, so one operator flag governs both
|
|
278
278
|
// paths and an advisory board is exempt on both.
|
|
279
279
|
'20260925000100_agent_board_ownerless_renew_ttl_cap.sql',
|
|
280
|
+
// #388 OWNERLESSQUOTACENSUS. The quota bounds new acquisitions and never CONVERGES an agent
|
|
281
|
+
// already over it: renew does not check it (deliberately -- refusing would kill a live
|
|
282
|
+
// lease), so an agent over the ceiling renews forever and stays over. Three real routes in:
|
|
283
|
+
// claims taken while ownerless_guards was off, claims taken under an advisory board later
|
|
284
|
+
// set back to inherit, and a quota lowered afterwards. v_ownerless_claim_quota +
|
|
285
|
+
// ownerless_quota_census() are READ-ONLY: they count with acquire_claim's own predicate and
|
|
286
|
+
// always emit the population beside the finding, so "0 over quota" can never be read as
|
|
287
|
+
// "all clear" the way #367's zero rows were. Enforcement is deferred to a product decision.
|
|
288
|
+
'20260926000100_agent_board_ownerless_quota_census.sql',
|
|
289
|
+
// #395 FLEETDOMAINNODOWNGRADE. register_fleet_machine's on-conflict branch MOVES the machine
|
|
290
|
+
// (trust_domain_id = excluded), the destination gate admits a never-approved 'pending' domain,
|
|
291
|
+
// lifecycle_state is deliberately not updated (#141) and fleet_machine_agents keys on
|
|
292
|
+
// machine_id -- so an owner re-registering their own fingerprint relocated a still-'active',
|
|
293
|
+
// still-BOUND machine into a domain nobody approved, and evaluate_fleet_sender_policy never
|
|
294
|
+
// reads the domain. The move is now refused when the destination's approval rank
|
|
295
|
+
// (active=2 > pending=1 > quarantined=suspended=revoked=0) is LOWER than the machine's
|
|
296
|
+
// current domain's; equal ranks, upgrades and first registrations are unaffected.
|
|
297
|
+
'20260927000100_agent_board_fleet_domain_no_downgrade.sql',
|
|
298
|
+
// #388 OWNERLESSQUOTACONVERGE. The enforcement half the census deferred. renew_claim now
|
|
299
|
+
// asks the quota too, and while the holder is OVER it a renew may not POSTPONE a deadline:
|
|
300
|
+
// it still succeeds and the held lease is preserved exactly, so each claim lapses on its own
|
|
301
|
+
// schedule and the count falls to the ceiling. NOT a raise (that kills live work, the design
|
|
302
|
+
// #387 rejected) and NOT a short grace horizon (an agent at 60 just renews all 60 hourly and
|
|
303
|
+
// never converges). `> quota`, not acquire's `>= quota`, so an agent legitimately AT the
|
|
304
|
+
// ceiling is untouched. acquire's inline count is extracted into
|
|
305
|
+
// _claimgate_active_ownerless_claims() so the two enforcement paths cannot drift.
|
|
306
|
+
'20260928000100_agent_board_ownerless_quota_renew_convergence.sql',
|
|
307
|
+
// #400 ADVISORYREVERTRESET. claim_policy='advisory' skips both the ownerless TTL cap and
|
|
308
|
+
// #388's convergence clamp, and set_board_claim_policy is gated on the owner membership
|
|
309
|
+
// create_board seeds for the creator -- so an agent that creates a board can flip to
|
|
310
|
+
// advisory, renew its ownerless claims to an arbitrary horizon, flip back to inherit, and
|
|
311
|
+
// repeat, staying over quota forever while the census (which resolves policy at query time)
|
|
312
|
+
// reports no advisory share at all. The revert now RE-APPLIES the ownerless TTL cap to that
|
|
313
|
+
// board's active ownerless claims -- least(lease_expires_at, now()+cap), clamp only, never an
|
|
314
|
+
// extension -- so a horizon banked under the opt-out cannot be kept by handing the opt-out
|
|
315
|
+
// back. advisory->inherit only, governed by ownerless_guards. Bounded, not closed: a cycle
|
|
316
|
+
// still banks up to the cap.
|
|
317
|
+
'20260929000100_agent_board_advisory_revert_cap_reset.sql',
|
|
318
|
+
// #405 CLAIMPOLICYRACE. _claimgate_board_claim_policy took no lock on boards, so any
|
|
319
|
+
// operation whose snapshot predated a committed policy change acted on policy that was
|
|
320
|
+
// already gone -- renew_claim (read stale), acquire_claim (stale AND no row for #406's
|
|
321
|
+
// reset to clamp), and set_board_claim_policy itself (readers serialised on nothing).
|
|
322
|
+
// All three now take a board-scoped advisory xact lock BEFORE the policy decision --
|
|
323
|
+
// including acquire's 'advisory' path, which skips the block the agent-scoped lock lives
|
|
324
|
+
// in -- and re-read the policy as a separate statement; renew re-reads the whole CLAIM,
|
|
325
|
+
// because #400's reset clamps the very row whose pre-wait expiry is the convergence
|
|
326
|
+
// baseline. The lock helper REFUSES any isolation level but read committed, where the
|
|
327
|
+
// wait would not advance the snapshot and the lock would be silently inert.
|
|
328
|
+
'20260930000100_agent_board_claim_policy_race.sql',
|
|
329
|
+
'20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql',
|
|
280
330
|
]) {
|
|
281
331
|
expect(names.has(f)).toBe(true);
|
|
282
332
|
}
|
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
-- the kind of reasoning that stops being true when someone moves a line, and this file would
|
|
18
18
|
-- otherwise be a set of tests that only ever prove the permissive direction.
|
|
19
19
|
--
|
|
20
|
+
-- BCP-11..21 ARE #400: THE ADVISORY-REVERT CAP RESET. The opt-out is a TOGGLE, and that made it an
|
|
21
|
+
-- escape from every ownerless bound: flip to advisory, take an arbitrary horizon, flip back, repeat
|
|
22
|
+
-- — self-service, because create_board seeds the creator's owner membership and this RPC is gated
|
|
23
|
+
-- on exactly that. Reverting to inherit now re-applies the ownerless TTL cap to the board's active
|
|
24
|
+
-- ownerless claims, clamp only. The section pins all four transitions, both bounds (kind and board),
|
|
25
|
+
-- the operator flag, and the catalog class check that this RPC is the only writer of the column.
|
|
26
|
+
--
|
|
20
27
|
-- BCP-5 IS THE SECOND ONE THAT MATTERS. A claim reaches its board through its thread. So the
|
|
21
28
|
-- obvious hole is a claim that simply omits the thread — if that inherited "no board, therefore no
|
|
22
29
|
-- enforcement", any agent could opt itself out by passing null. It resolves to 'inherit' instead,
|
|
@@ -26,7 +33,7 @@ set search_path = extensions, public, agent_board;
|
|
|
26
33
|
|
|
27
34
|
begin;
|
|
28
35
|
|
|
29
|
-
select plan(
|
|
36
|
+
select plan(30);
|
|
30
37
|
|
|
31
38
|
-- ---------------------------------------------------------------------------
|
|
32
39
|
-- Fixture: one board with an owner and a non-owner member, a board-linked thread, and a
|
|
@@ -193,6 +200,259 @@ select throws_ok(
|
|
|
193
200
|
null,
|
|
194
201
|
'BCP-9: an unknown policy is refused as a bad argument, naming the vocabulary — not as a raw CHECK violation');
|
|
195
202
|
|
|
203
|
+
-- ===========================================================================
|
|
204
|
+
-- BCP-11..21 (#400): THE ADVISORY-REVERT CAP RESET.
|
|
205
|
+
--
|
|
206
|
+
-- THE DEFECT. The advisory carve-out is resolved at ENFORCEMENT time against the board's CURRENT
|
|
207
|
+
-- policy, so an owner could flip to 'advisory', let an ownerless claim take an arbitrary horizon
|
|
208
|
+
-- (the carve-out skips both the #373 TTL cap and #388's convergence clamp), flip back to 'inherit',
|
|
209
|
+
-- and repeat before expiry. `create_board` seeds the creator's owner membership and this RPC is
|
|
210
|
+
-- gated on exactly that, so an agent that created a board holds the switch that exempts its own
|
|
211
|
+
-- claims: no accomplice. And #393's census resolves policy at QUERY time (OQ-29), so after the
|
|
212
|
+
-- revert the escaped claim contributes zero to `claims_on_advisory_boards` while its overage still
|
|
213
|
+
-- counts.
|
|
214
|
+
--
|
|
215
|
+
-- THE FIX. Reverting to 'inherit' means accepting inherit's rules: the ownerless TTL cap is
|
|
216
|
+
-- re-applied to this board's active ownerless claims, clamp only. The opt-out may be given back —
|
|
217
|
+
-- but not after banking a lease longer than inherit allows.
|
|
218
|
+
--
|
|
219
|
+
-- BOUNDED, NOT CLOSED, and this file does not pretend otherwise: each advisory window still banks
|
|
220
|
+
-- up to the cap, so an owner flipping every 29 days still defeats convergence. What is gone is the
|
|
221
|
+
-- UNBOUNDED horizon. ORQ-11 pins that bound end-to-end through the real RPCs.
|
|
222
|
+
--
|
|
223
|
+
-- NUMBERED 11+ AND PLACED HERE, BEFORE BCP-10, because BCP-10 fleet-revokes the owner and every
|
|
224
|
+
-- assertion below needs that owner to be able to call this RPC. Renumbering BCP-10 would break its
|
|
225
|
+
-- citations for no gain.
|
|
226
|
+
-- ===========================================================================
|
|
227
|
+
do $$
|
|
228
|
+
declare
|
|
229
|
+
v_b uuid := gen_random_uuid();
|
|
230
|
+
v_t uuid := gen_random_uuid();
|
|
231
|
+
begin
|
|
232
|
+
-- Session: the owner. BCP-7/8/9 set their GUCs INSIDE throws_ok, whose exception block rolls
|
|
233
|
+
-- them back with the raise, so the state here is the fixture's — but inferring that is exactly
|
|
234
|
+
-- the reading rule 4 exists for, so it is set explicitly instead.
|
|
235
|
+
perform set_config('request.jwt.claim.role', 'authenticated', true);
|
|
236
|
+
perform set_config('request.jwt.claim.sub', (select owner_user from _bcp)::text, true);
|
|
237
|
+
|
|
238
|
+
-- A SECOND BOARD, and it stays 'advisory' for the whole section. It is the scope witness for
|
|
239
|
+
-- BCP-14: reverting board A must not reach a board that never reverted. Advisory is also the
|
|
240
|
+
-- realistic shape — a board still coordinating through its own mutex, legitimately holding a
|
|
241
|
+
-- lease longer than our cap.
|
|
242
|
+
insert into agent_board.boards(id, slug, name, created_by_agent_id, claim_policy)
|
|
243
|
+
values (v_b, 'bcp400-' || substr(v_b::text, 1, 8), 'BCP 400 Other Board', (select owner_agent from _bcp), 'advisory');
|
|
244
|
+
insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
|
|
245
|
+
values (v_t, 'coordination', (select owner_agent from _bcp), 'BCP 400 other-board thread', v_b);
|
|
246
|
+
|
|
247
|
+
create temp table _bcp400 (other_board uuid, other_thread uuid) on commit drop;
|
|
248
|
+
insert into _bcp400 values (v_b, v_t);
|
|
249
|
+
end $$;
|
|
250
|
+
|
|
251
|
+
select lives_ok(
|
|
252
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
|
|
253
|
+
'BCP-11a: the owner opts the board out again — this is the flip an over-quota holder makes to buy an unbounded horizon');
|
|
254
|
+
|
|
255
|
+
-- The claims the advisory window buys. Inserted directly rather than acquired, because three of
|
|
256
|
+
-- them are shapes acquire cannot produce in one call (a 'machine' kind needs a fleet fixture, one
|
|
257
|
+
-- belongs to another board, one has no thread) — but they are all shapes the DATABASE can hold:
|
|
258
|
+
-- an over-cap ownerless lease exists exactly where an advisory board, a grandfathered pre-flag
|
|
259
|
+
-- claim, or a service_role extension put it. BCP-11 scores a fourth claim that WAS acquired
|
|
260
|
+
-- through the real RPC under advisory, so the property is not proven only on synthetic rows.
|
|
261
|
+
insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, lease_expires_at)
|
|
262
|
+
values
|
|
263
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-file', now() + interval '400 days'),
|
|
264
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'tool', 'bcp400-a-tool', now() + interval '400 days'),
|
|
265
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'custom', 'bcp400-a-custom', now() + interval '400 days'),
|
|
266
|
+
-- an OWNED kind on the same board: governed by the fleet-authority gate, never by this cap
|
|
267
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'machine', 'bcp400-a-machine', now() + interval '400 days'),
|
|
268
|
+
-- already WITHIN the cap: the twin. A reset that assigned now()+cap would LENGTHEN this one
|
|
269
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-under', now() + interval '5 days'),
|
|
270
|
+
-- another board, still advisory
|
|
271
|
+
((select owner_agent from _bcp), (select other_thread from _bcp400), 'file', 'bcp400-b-file', now() + interval '400 days'),
|
|
272
|
+
-- no thread at all, so no board to revert
|
|
273
|
+
((select owner_agent from _bcp), null, 'file', 'bcp400-free-file', now() + interval '400 days');
|
|
274
|
+
|
|
275
|
+
-- Two DEAD rows on the reverting board, for BCP-21. They are inserted separately because they need
|
|
276
|
+
-- columns the list above does not set.
|
|
277
|
+
insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, status, released_at, lease_expires_at, updated_at)
|
|
278
|
+
values
|
|
279
|
+
-- released: least() would still shorten it, so this conjunct IS observable through the lease
|
|
280
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-released', 'released', now(), now() + interval '400 days', now() - interval '3 days'),
|
|
281
|
+
-- active but LAPSED: least(now()-1h, now()+cap) is the lease it already has, so this conjunct is
|
|
282
|
+
-- observable ONLY through updated_at — which is why the row carries a distinctive one
|
|
283
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-a-lapsed', 'active', null, now() - interval '1 hour', now() - interval '3 days');
|
|
284
|
+
|
|
285
|
+
select lives_ok(
|
|
286
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
|
|
287
|
+
'BCP-11b: and hands the opt-out back — the step that used to cost the escaping agent nothing');
|
|
288
|
+
|
|
289
|
+
-- ---------------------------------------------------------------------------
|
|
290
|
+
-- BCP-11: THE POINT. Every active ownerless claim on the reverting board is clamped to the TTL cap.
|
|
291
|
+
-- All three ownerless kinds are scored: every other assertion here drives `file`, and dropping
|
|
292
|
+
-- 'tool' or 'custom' from the kind list is the mutation that passed ORT's entire battery (ORT-9).
|
|
293
|
+
-- `rpc-acquired` is 'bcp-over-cap-advisory' from BCP-3b — taken through acquire_claim under
|
|
294
|
+
-- advisory and clamped by this file's own earlier revert, so the property is pinned on a row a
|
|
295
|
+
-- deployment can actually produce and not only on inserted fixtures.
|
|
296
|
+
-- ---------------------------------------------------------------------------
|
|
297
|
+
select is(
|
|
298
|
+
(select string_agg(l.label || '=' ||
|
|
299
|
+
(c.lease_expires_at = now() + agent_board._claimgate_ownerless_ttl_cap())::text, ', ' order by l.label)
|
|
300
|
+
from agent_board.claims c
|
|
301
|
+
join (values ('bcp400-a-file', 'file'),
|
|
302
|
+
('bcp400-a-tool', 'tool'),
|
|
303
|
+
('bcp400-a-custom', 'custom'),
|
|
304
|
+
('bcp-over-cap-advisory', 'rpc-acquired')) as l(key, label) on l.key = c.resource_key),
|
|
305
|
+
'custom=true, file=true, rpc-acquired=true, tool=true',
|
|
306
|
+
'BCP-11 THE POINT: reverting advisory -> inherit re-applies the ownerless TTL cap to this board''s active claims, all three kinds — so a horizon banked under the opt-out cannot be kept by handing the opt-out back');
|
|
307
|
+
|
|
308
|
+
-- ---------------------------------------------------------------------------
|
|
309
|
+
-- BCP-12: THE TWIN, and the reason the clamp is least(). A claim already within the cap must come
|
|
310
|
+
-- back untouched: an assignment to now()+cap would satisfy BCP-11 and silently LENGTHEN this lease
|
|
311
|
+
-- from five days to thirty. Asserts the lease only — the row's updated_at IS bumped, which is the
|
|
312
|
+
-- documented cost of least() being the single mutation-visible clamp (see the migration header).
|
|
313
|
+
-- ---------------------------------------------------------------------------
|
|
314
|
+
select is(
|
|
315
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-a-under'),
|
|
316
|
+
now() + interval '5 days',
|
|
317
|
+
'BCP-12 THE TWIN: a lease already within the cap is preserved to the microsecond — the reset is least(held, now()+cap), not a rewrite to now()+cap, which would hand back time the claim never had');
|
|
318
|
+
|
|
319
|
+
-- ---------------------------------------------------------------------------
|
|
320
|
+
-- BCP-13: the kind bound. 'machine' is an OWNED kind, governed by the fleet-authority gate and by
|
|
321
|
+
-- no ownerless cap at all, so the reset must not touch it — a reset that clamped every claim on the
|
|
322
|
+
-- board would satisfy BCP-11 and quietly shorten fleet leases.
|
|
323
|
+
-- ---------------------------------------------------------------------------
|
|
324
|
+
select is(
|
|
325
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-a-machine'),
|
|
326
|
+
now() + interval '400 days',
|
|
327
|
+
'BCP-13: a machine claim on the reverting board is untouched — the reset is scoped to the ownerless kinds, which are the only ones the TTL cap ever governed');
|
|
328
|
+
|
|
329
|
+
-- ---------------------------------------------------------------------------
|
|
330
|
+
-- BCP-14: the board bound. This board's revert is a statement about THIS board. A claim on another
|
|
331
|
+
-- board — still advisory, legitimately holding a long lease — and a claim with no thread at all
|
|
332
|
+
-- must both survive it. Dropping the board join would clamp every ownerless claim in the fleet on
|
|
333
|
+
-- any revert anywhere, and every assertion above would still pass.
|
|
334
|
+
-- ---------------------------------------------------------------------------
|
|
335
|
+
select is(
|
|
336
|
+
(select string_agg(l.label || '=' || (c.lease_expires_at = now() + interval '400 days')::text, ', ' order by l.label)
|
|
337
|
+
from agent_board.claims c
|
|
338
|
+
join (values ('bcp400-b-file', 'other-board'),
|
|
339
|
+
('bcp400-free-file', 'no-thread')) as l(key, label) on l.key = c.resource_key),
|
|
340
|
+
'no-thread=true, other-board=true',
|
|
341
|
+
'BCP-14 SCOPE: a claim on a DIFFERENT board and a claim with no thread are both untouched by this board''s revert — the reset resolves through the claim''s thread to this board, the same path the policy resolver uses');
|
|
342
|
+
|
|
343
|
+
-- ---------------------------------------------------------------------------
|
|
344
|
+
-- BCP-21: THE DEAD ROWS. The reset is scoped to ACTIVE, unreleased, unexpired claims — the same
|
|
345
|
+
-- three conjuncts the shared count predicate uses — so a revert does not rewrite history.
|
|
346
|
+
--
|
|
347
|
+
-- The two halves are scored differently on purpose. A RELEASED row still holds a future timestamp,
|
|
348
|
+
-- so dropping `status = 'active' and released_at is null` shortens it and the LEASE moves. A LAPSED
|
|
349
|
+
-- row's lease is already in the past, and least(past, now()+cap) is that same past value — so
|
|
350
|
+
-- dropping `lease_expires_at > now()` is invisible in the lease and visible ONLY as a touched row.
|
|
351
|
+
-- Both rows therefore carry a distinctive updated_at, and this assertion reads it.
|
|
352
|
+
-- ---------------------------------------------------------------------------
|
|
353
|
+
select is(
|
|
354
|
+
(select string_agg(l.label || '=' ||
|
|
355
|
+
(c.lease_expires_at = l.expected and c.updated_at = now() - interval '3 days')::text, ', ' order by l.label)
|
|
356
|
+
from agent_board.claims c
|
|
357
|
+
join (values ('bcp400-a-released', 'released', now() + interval '400 days'),
|
|
358
|
+
('bcp400-a-lapsed', 'lapsed', now() - interval '1 hour')) as l(key, label, expected) on l.key = c.resource_key),
|
|
359
|
+
'lapsed=true, released=true',
|
|
360
|
+
'BCP-21: a RELEASED claim and a LAPSED one on the reverting board are not touched at all — same lease, same updated_at. The reset governs live leases, not the ledger of dead ones');
|
|
361
|
+
|
|
362
|
+
-- ---------------------------------------------------------------------------
|
|
363
|
+
-- BCP-15..17: THE TRANSITION CONDITION, all three non-triggering transitions. The rows below are
|
|
364
|
+
-- inserted once, while the board is 'inherit', and each is read after its own transition.
|
|
365
|
+
--
|
|
366
|
+
-- BCP-17 (advisory -> advisory) is the one the other two cannot see: dropping the `= 'inherit'`
|
|
367
|
+
-- conjunct leaves "was previously advisory", which still clamps nothing on either inherit-first
|
|
368
|
+
-- case. Without it that mutation survives BCP-15 and BCP-16 untouched.
|
|
369
|
+
-- ---------------------------------------------------------------------------
|
|
370
|
+
insert into agent_board.claims(agent_id, thread_id, resource_kind, resource_key, lease_expires_at)
|
|
371
|
+
values
|
|
372
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-ii', now() + interval '400 days'),
|
|
373
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-ia', now() + interval '400 days'),
|
|
374
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-noop-aa', now() + interval '400 days'),
|
|
375
|
+
((select owner_agent from _bcp), (select thread from _bcp), 'file', 'bcp400-flag-off', now() + interval '400 days');
|
|
376
|
+
|
|
377
|
+
select lives_ok(
|
|
378
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
|
|
379
|
+
'BCP-15a: the owner re-sets inherit on a board that is already inherit');
|
|
380
|
+
|
|
381
|
+
select is(
|
|
382
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-ii'),
|
|
383
|
+
now() + interval '400 days',
|
|
384
|
+
'BCP-15: inherit -> inherit clamps NOTHING — a no-op re-set is not a policy change, and treating it as one would let any owner shorten their own board''s leases by calling the RPC twice');
|
|
385
|
+
|
|
386
|
+
select lives_ok(
|
|
387
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
|
|
388
|
+
'BCP-16a: the owner opts out');
|
|
389
|
+
|
|
390
|
+
select is(
|
|
391
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-ia'),
|
|
392
|
+
now() + interval '400 days',
|
|
393
|
+
'BCP-16: inherit -> advisory clamps nothing — the reset belongs to the REVERT, and clamping while opting OUT would be the opposite of what the opt-out promises');
|
|
394
|
+
|
|
395
|
+
select lives_ok(
|
|
396
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'advisory') $$,
|
|
397
|
+
'BCP-17a: and re-sets advisory on a board that is already advisory');
|
|
398
|
+
|
|
399
|
+
select is(
|
|
400
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-noop-aa'),
|
|
401
|
+
now() + interval '400 days',
|
|
402
|
+
'BCP-17: advisory -> advisory clamps nothing — the discriminating case, because a guard that asked only "was it advisory?" passes BCP-15 and BCP-16 and would clamp a board that never came back');
|
|
403
|
+
|
|
404
|
+
-- ---------------------------------------------------------------------------
|
|
405
|
+
-- BCP-18: ONE OPERATOR FLAG. With ownerless_guards off, nothing enforces the TTL cap at acquire or
|
|
406
|
+
-- at renew, so a revert must not enforce it either — the flag means the same thing on all three
|
|
407
|
+
-- write paths or it means nothing.
|
|
408
|
+
-- ---------------------------------------------------------------------------
|
|
409
|
+
do $$
|
|
410
|
+
begin
|
|
411
|
+
update agent_board.claimgate_enforcement_flags set enabled = false where scope_key = 'ownerless_guards';
|
|
412
|
+
end $$;
|
|
413
|
+
|
|
414
|
+
select lives_ok(
|
|
415
|
+
$$ select agent_board.set_board_claim_policy((select board from _bcp), 'inherit') $$,
|
|
416
|
+
'BCP-18a: with the guards off the owner reverts to inherit');
|
|
417
|
+
|
|
418
|
+
select is(
|
|
419
|
+
(select lease_expires_at from agent_board.claims where resource_key = 'bcp400-flag-off'),
|
|
420
|
+
now() + interval '400 days',
|
|
421
|
+
'BCP-18: with ownerless_guards OFF an advisory -> inherit revert clamps nothing — one flag governs the cap at acquire, at renew and at the revert alike');
|
|
422
|
+
|
|
423
|
+
do $$
|
|
424
|
+
begin
|
|
425
|
+
update agent_board.claimgate_enforcement_flags set enabled = true where scope_key = 'ownerless_guards';
|
|
426
|
+
end $$;
|
|
427
|
+
|
|
428
|
+
-- ---------------------------------------------------------------------------
|
|
429
|
+
-- BCP-19/20: CLASS (rule 1). The hook can only close the escape if this RPC is the ONLY way
|
|
430
|
+
-- boards.claim_policy changes. Derived from the catalog, not from a list: a second writer names
|
|
431
|
+
-- itself here rather than silently inheriting a policy change that resets nothing.
|
|
432
|
+
--
|
|
433
|
+
-- Two independent bounds, because neither is sufficient alone. BCP-19 is a source scan and a source
|
|
434
|
+
-- scan has a floor — it sees `set claim_policy` and not, say, a dynamic EXECUTE. BCP-20 is the
|
|
435
|
+
-- privilege bound underneath it: NO role holds UPDATE on agent_board.boards, so the column cannot
|
|
436
|
+
-- be written from a session at all, only from a SECURITY DEFINER function (which BCP-19 enumerates)
|
|
437
|
+
-- or by the table owner. `boards` also carries no user triggers, so there is no third path.
|
|
438
|
+
-- ---------------------------------------------------------------------------
|
|
439
|
+
select is(
|
|
440
|
+
(select coalesce(string_agg(p.proname, ', ' order by p.proname), '')
|
|
441
|
+
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
|
|
442
|
+
where n.nspname = 'agent_board'
|
|
443
|
+
and agent_board_test.stripped_body(p.oid, true) ~ 'set\s+claim_policy'),
|
|
444
|
+
'set_board_claim_policy',
|
|
445
|
+
'BCP-19 CLASS: set_board_claim_policy is the ONLY function that writes boards.claim_policy — a second writer would need the same advisory-revert reset and fails here instead of being remembered');
|
|
446
|
+
|
|
447
|
+
select is(
|
|
448
|
+
format('anon=%s, authenticated=%s, service_role=%s, user_triggers=%s',
|
|
449
|
+
has_table_privilege('anon', 'agent_board.boards', 'update')::text,
|
|
450
|
+
has_table_privilege('authenticated', 'agent_board.boards', 'update')::text,
|
|
451
|
+
has_table_privilege('service_role', 'agent_board.boards', 'update')::text,
|
|
452
|
+
(select count(*) from pg_trigger where tgrelid = 'agent_board.boards'::regclass and not tgisinternal)::text),
|
|
453
|
+
'anon=false, authenticated=false, service_role=false, user_triggers=0',
|
|
454
|
+
'BCP-20 CLASS: no role holds UPDATE on agent_board.boards and the table carries no user triggers — so the column is unreachable except through the definer function BCP-19 enumerates, which is the bound a source scan cannot give');
|
|
455
|
+
|
|
196
456
|
-- ---------------------------------------------------------------------------
|
|
197
457
|
-- BCP-10: A FLEET-REVOKED OWNER IS STILL AN OWNER BY MEMBERSHIP. This function shipped its first
|
|
198
458
|
-- version WITHOUT this check while its own commit message claimed to mirror set_board_role_gate
|
|
@@ -850,14 +850,23 @@ select is(
|
|
|
850
850
|
-- sides: the catalog function scan, and the effective-privilege assertions after it (no role
|
|
851
851
|
-- holds a write privilege by ANY route, so a direct write is impossible and the SECURITY
|
|
852
852
|
-- DEFINER bodies are the whole surface).
|
|
853
|
+
--
|
|
854
|
+
-- set_board_claim_policy (#400) is the FIFTH member, and it named itself here rather than being
|
|
855
|
+
-- remembered -- which is what this assertion is for. It creates no claims row and re-keys nothing:
|
|
856
|
+
-- its only claims write is the advisory -> inherit cap reset, `set lease_expires_at =
|
|
857
|
+
-- least(lease_expires_at, now() + cap), updated_at = now()`, on the reverting board's active
|
|
858
|
+
-- ownerless claims. That EXEMPTION IS ASSERTED POSITIVELY, not by absence: BCP-11/13/14 look those
|
|
859
|
+
-- claims up BY resource_key and score the resulting timestamps, so a reset that re-keyed or
|
|
860
|
+
-- inserted a row fails them by name, and assertion 51 below (every function that CREATES a claims
|
|
861
|
+
-- row refuses without thread access) still holds because this one creates none.
|
|
853
862
|
select is(
|
|
854
863
|
(select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
|
|
855
864
|
from pg_proc p
|
|
856
865
|
where p.pronamespace = 'agent_board'::regnamespace
|
|
857
866
|
and lower(agent_board_test.stripped_body(p.oid, true))
|
|
858
867
|
~ '(insert\s+into|merge\s+into|update|delete\s+from|truncate)(\s+table)?(\s+only)?\s+(("?agent_board"?\s*\.\s*)?claims\M|("?agent_board"?\s*\.\s*)?"claims")'),
|
|
859
|
-
'acquire_claim,reclaim_fleet_machine,release_claim,renew_claim',
|
|
860
|
-
'catalog: these
|
|
868
|
+
'acquire_claim,reclaim_fleet_machine,release_claim,renew_claim,set_board_claim_policy',
|
|
869
|
+
'catalog: these five are the ONLY agent_board functions that write claims (insert/merge/update/delete/TRUNCATE) -- a new writer names itself here');
|
|
861
870
|
|
|
862
871
|
select is(
|
|
863
872
|
(select coalesce(string_agg(p.proname, ',' order by p.proname), '(none)')
|