@consiliency/agent-board-schema 1.48.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.
Files changed (28) hide show
  1. package/fixtures/already-applied.json +12 -0
  2. package/migrations/20260929000100_agent_board_advisory_revert_cap_reset.sql +371 -0
  3. package/migrations/20260930000100_agent_board_claim_policy_race.sql +922 -0
  4. package/migrations/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql +158 -0
  5. package/package.json +1 -1
  6. package/schema-manifest.json +30 -3
  7. package/src/tooling.test.mjs +25 -2
  8. package/tests/agent_board_board_claim_policy.test.sql +261 -1
  9. package/tests/agent_board_claim_subject_gate.test.sql +11 -2
  10. package/tests/agent_board_claimkey.test.sql +17 -2
  11. package/tests/agent_board_claimpolicy_race.test.sql +1374 -0
  12. package/tests/agent_board_ownerless_quota_census.test.sql +6 -0
  13. package/tests/agent_board_ownerless_renew_quota.test.sql +37 -8
  14. package/tests/catalog/p_metadata_parameters.json +16 -0
  15. package/tests/fixtures/test-prereqs.sql +186 -0
  16. package/tests/isolation/expected/claimpolicy_acquire_revert_race.out +66 -0
  17. package/tests/isolation/expected/claimpolicy_board_scope.out +73 -0
  18. package/tests/isolation/expected/claimpolicy_claimrow_lock_order.out +80 -0
  19. package/tests/isolation/expected/claimpolicy_isolation_refusal.out +114 -0
  20. package/tests/isolation/expected/claimpolicy_renew_revert_race.out +66 -0
  21. package/tests/isolation/expected/claimpolicy_writer_keyshare.out +49 -0
  22. package/tests/isolation/specs/claimpolicy_acquire_revert_race.spec +118 -0
  23. package/tests/isolation/specs/claimpolicy_board_scope.spec +115 -0
  24. package/tests/isolation/specs/claimpolicy_claimrow_lock_order.spec +200 -0
  25. package/tests/isolation/specs/claimpolicy_isolation_refusal.spec +162 -0
  26. package/tests/isolation/specs/claimpolicy_renew_revert_race.spec +150 -0
  27. package/tests/isolation/specs/claimpolicy_writer_keyshare.spec +107 -0
  28. package/tests/seeds/20261001000100_agent_board_claimpolicy_lock_grant_revoke.sql.pre.sql +99 -0
@@ -0,0 +1,162 @@
1
+ # Consiliency/message-board#405 — the board policy lock REFUSES a transaction
2
+ # that is not READ COMMITTED, rather than being silently inert in one.
3
+ #
4
+ # THE DEPENDENCY THIS PINS. The whole mechanism is "take the lock, then re-read
5
+ # the policy in a NEW statement". That works because under READ COMMITTED each
6
+ # statement takes a fresh snapshot, so the re-read sees what the writer
7
+ # committed while we were waiting. Under REPEATABLE READ and SERIALIZABLE the
8
+ # snapshot is fixed at first use: the wait does not advance it, the re-read
9
+ # returns the SAME stale policy, and the lock is not a guard at all — it is a
10
+ # delay. That failure mode is invisible; nothing errors, nothing waits
11
+ # incorrectly, the caller simply gets the answer the race was supposed to
12
+ # prevent.
13
+ #
14
+ # SO THE DECISION IS REFUSE, NOT DOCUMENT. `_claimgate_lock_board_policy` reads
15
+ # `current_setting('transaction_isolation')` and raises 0A000 naming the level.
16
+ # Nothing in this repository sets another level (a repo-wide search finds only
17
+ # two prose mentions and no code), so this refuses no caller that exists — and
18
+ # if one appears it fails loudly at its first guarded claim instead of quietly
19
+ # banking a horizon.
20
+ #
21
+ # THIS CANNOT BE A pgTAP ASSERTION. `SET TRANSACTION ISOLATION LEVEL` must be a
22
+ # transaction's first statement, and the pgTAP suite is already inside one. An
23
+ # isolation spec is the only place the level can be chosen.
24
+ #
25
+ # THREE PERMUTATIONS, because a refusal that fires everywhere is a different bug
26
+ # from one that fires nowhere:
27
+ #
28
+ # 1. REPEATABLE READ, ownerless claim -> REFUSED, by name.
29
+ # 2. READ COMMITTED, same call -> SUCCEEDS (the allow side).
30
+ # 3. REPEATABLE READ, 'machine' kind -> NOT refused. The guard is scoped to
31
+ # the ownerless region it protects; a fleet-kind acquire never resolves
32
+ # board policy and must not be collateral damage.
33
+ #
34
+ # MUTATION SIGNAL (verified in the PR): invert the comparison in
35
+ # `_claimgate_lock_board_policy` (`= 'read committed'`) -> permutation 1 succeeds
36
+ # and permutation 2 raises; both halves of the golden move.
37
+
38
+ setup
39
+ {
40
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
41
+ on conflict (scope_key) do update set enabled = true;
42
+ insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000d1');
43
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
44
+ values ('40500000-0000-4000-8000-000000000004', 'agent', 'agent',
45
+ 'cpr-iso-owner', 'CPR Isolation Owner', 'active',
46
+ '40500000-0000-4000-8000-0000000000d1', '40500000-0000-4000-8000-0000000000d1');
47
+ select count(*) from agent_board.create_board(
48
+ '40500000-0000-4000-8000-000000000004', 'cpr-iso-board', 'CPR Isolation Board',
49
+ 'message-board#405 isolation-level refusal probe', jsonb_build_object(), 'private');
50
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
51
+ values ('40500000-0000-4000-8000-000000000050', 'coordination',
52
+ '40500000-0000-4000-8000-000000000004', 'CPR isolation thread',
53
+ (select id from agent_board.boards where slug = 'cpr-iso-board'));
54
+ }
55
+
56
+ teardown
57
+ {
58
+ delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000004';
59
+ delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000050';
60
+ delete from agent_board.board_membership_events where board_id in (
61
+ select id from agent_board.boards where slug = 'cpr-iso-board');
62
+ delete from agent_board.board_memberships where board_id in (
63
+ select id from agent_board.boards where slug = 'cpr-iso-board');
64
+ delete from agent_board.boards where slug = 'cpr-iso-board';
65
+ delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000004';
66
+ delete from auth.users where id = '40500000-0000-4000-8000-0000000000d1';
67
+ delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
68
+ }
69
+
70
+ # rr: a REPEATABLE READ caller. The lease it asks for is WITHIN the cap, so nothing else in the
71
+ # ownerless block would reject it -- the only reason this can fail is the isolation guard.
72
+ session "rr"
73
+ step "rr_begin" { BEGIN ISOLATION LEVEL REPEATABLE READ; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
74
+ step "rr_acq" {
75
+ select resource_key
76
+ from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
77
+ null, '40500000-0000-4000-8000-000000000050');
78
+ }
79
+ # The fleet-kind path never resolves board policy, so it never reaches the guard. A claim with no
80
+ # thread and no board: kind 'machine' is not ownerless, so the guarded region is skipped entirely.
81
+ step "rr_machine" {
82
+ select resource_key
83
+ from agent_board.acquire_claim(null, 'machine', 'cpr-iso-machine', now() + interval '1 hour',
84
+ null, null);
85
+ }
86
+ step "rr_rollback" { ROLLBACK; }
87
+
88
+ # rc: the same call under the level the guard demands.
89
+ session "rc"
90
+ step "rc_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
91
+ step "rc_acq" {
92
+ select resource_key
93
+ from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
94
+ null, '40500000-0000-4000-8000-000000000050');
95
+ }
96
+ step "rc_commit" { COMMIT; }
97
+
98
+ # sz: SERIALIZABLE. THE LEVEL THE OLD SPEC LEFT UNPINNED, and the one that matters most. Like
99
+ # REPEATABLE READ, a serializable transaction fixes its snapshot at first use and waiting on the
100
+ # writer's lock does NOT advance it -- so the re-read after the lock returns the SAME stale policy
101
+ # and the advisory lock is silently ineffective rather than failing closed. Admitting this level
102
+ # REOPENS THE RACE this whole issue exists to close.
103
+ #
104
+ # WHY IT NEEDED ITS OWN PERMUTATION. The guard is `<> 'read committed'`, so one permutation under
105
+ # REPEATABLE READ appears to cover its siblings -- but only the levels a permutation NAMES are
106
+ # pinned. Measured: narrowing the guard to `= 'repeatable read'` kept permutation 1 passing and the
107
+ # entire pgTAP suite green while serializable sailed through. That mutation now fails HERE by name,
108
+ # and structurally at CPR-25a.
109
+ session "sz"
110
+ step "sz_begin" { BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
111
+ step "sz_acq" {
112
+ select resource_key
113
+ from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
114
+ null, '40500000-0000-4000-8000-000000000050');
115
+ }
116
+ step "sz_rollback" { ROLLBACK; }
117
+
118
+ # ru: READ UNCOMMITTED. A DIFFERENT KIND OF CLAIM, and the difference is deliberate rather than an
119
+ # oversight -- do not collapse this permutation into the one above. PostgreSQL EXECUTES read
120
+ # uncommitted AS read committed (fresh snapshot per statement), so the lock would in fact work at
121
+ # this level: admitting it would NOT reopen the race. What the refusal defends is the guard's
122
+ # one-accepted-spelling CONTRACT, which the migration argues for on the grounds that a guard
123
+ # carrying an equivalence argument has to be re-argued by every reader.
124
+ #
125
+ # So this permutation pins a CONTRACT regression, not a correctness one. It is here because
126
+ # `current_setting('transaction_isolation')` really does report `read uncommitted` distinctly --
127
+ # the GUC preserves the requested spelling even though the engine does not distinguish the
128
+ # behaviour -- which is the only reason a `<> 'read committed'` guard can see it at all.
129
+ session "ru"
130
+ step "ru_begin" { BEGIN ISOLATION LEVEL READ UNCOMMITTED; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000d1'; }
131
+ step "ru_acq" {
132
+ select resource_key
133
+ from agent_board.acquire_claim(null, 'file', 'cpr-iso-key', now() + interval '1 hour',
134
+ null, '40500000-0000-4000-8000-000000000050');
135
+ }
136
+ step "ru_rollback" { ROLLBACK; }
137
+
138
+ session "chk"
139
+ step "chk" {
140
+ select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims
141
+ from agent_board.claims
142
+ where agent_id = '40500000-0000-4000-8000-000000000004'
143
+ and status = 'active' and released_at is null;
144
+ }
145
+
146
+ # 1. REPEATABLE READ is refused by name, and nothing is written.
147
+ permutation "rr_begin" "rr_acq" "rr_rollback" "chk"
148
+
149
+ # 2. THE ALLOW SIDE: the identical call under READ COMMITTED succeeds.
150
+ permutation "rc_begin" "rc_acq" "rc_commit" "chk"
151
+
152
+ # 3. SCOPE: a fleet-kind acquire under REPEATABLE READ never resolves board policy, so it is not
153
+ # refused. A guard that raised on entry to acquire_claim would fail here.
154
+ permutation "rr_begin" "rr_machine" "rr_rollback" "chk"
155
+
156
+ # 4. SERIALIZABLE is refused by name, and nothing is written. The race-reopening level.
157
+ permutation "sz_begin" "sz_acq" "sz_rollback" "chk"
158
+
159
+ # 5. READ UNCOMMITTED is refused by name too -- the contract half. PostgreSQL runs this level AS
160
+ # read committed, so this permutation pins the guard's single-accepted-spelling rule rather than a
161
+ # correctness property; see session "ru" for why the two are not the same claim.
162
+ permutation "ru_begin" "ru_acq" "ru_rollback" "chk"
@@ -0,0 +1,150 @@
1
+ # Consiliency/message-board#405 — a renew whose snapshot predates a committed
2
+ # advisory -> inherit revert must neither resolve the stale policy NOR hand back
3
+ # the horizon the revert just clamped.
4
+ #
5
+ # THIS IS #406's OWN RECORDED LIMITATION 3, closed. That header says: "a
6
+ # renew_claim whose snapshot predates the revert's commit resolves 'advisory'
7
+ # and, under READ COMMITTED, re-evaluates and applies its full horizon AFTER
8
+ # this function's row lock is released at commit ... Recorded as open rather
9
+ # than quietly excluded from the claim."
10
+ #
11
+ # TWO INDEPENDENT PROPERTIES, AND THE SECOND IS THE ONE THAT IS EASY TO GET
12
+ # WRONG.
13
+ #
14
+ # (1) THE POLICY RE-READ. Without the board lock, r resolves 'advisory', skips
15
+ # the #373 TTL cap and the #388 convergence clamp, and writes the full
16
+ # 400-day horizon after the revert has committed. `over_cap_lease` reads
17
+ # that out directly.
18
+ #
19
+ # (2) THE CLAIM-STATE REVALIDATION. Re-reading only the POLICY moves the race
20
+ # instead of closing it, because the writer this waits on is
21
+ # set_board_claim_policy and since #400 that same call CLAMPS this board's
22
+ # ownerless leases on the revert. The row r read BEFORE the wait is
23
+ # exactly the row w changed DURING it, and `v_current_expires` — the
24
+ # baseline the convergence clamp least()s against — would still hold the
25
+ # pre-clamp horizon. w records the clamped lease in `cpr_renew_mark` while
26
+ # still uncommitted, and the checker asserts the renew left it EXACTLY
27
+ # there.
28
+ #
29
+ # WHY THE SECOND SIGNAL IS AN EQUALITY AND NOT A MAGNITUDE. `now()` is
30
+ # transaction time. The reset writes w's `now() + 30 days`; the TTL cap computes
31
+ # r's `now() + 30 days`, and r's transaction starts later, so a stale baseline
32
+ # lets `least()` pick r's clock instead of w's and the lease creeps forward by
33
+ # the gap between the two BEGINs. That gap is real but small, so the checker
34
+ # scores `lease_expires_at = the recorded value` rather than a duration — a
35
+ # boolean is deterministic where a difference of milliseconds is not. The
36
+ # honest reading of that: this property's blast radius is one inter-transaction
37
+ # gap per revert, not a full horizon. Property (1) is the large one; (2) is
38
+ # pinned because "re-read the policy" and "revalidate the claim" are different
39
+ # fixes and only one of them is complete.
40
+ #
41
+ # THE AGENT IS DELIBERATELY OVER QUOTA (quota + 1 active ownerless claims), because
42
+ # the convergence clamp is the only consumer of `v_current_expires`. With the agent
43
+ # at or under the ceiling that clamp never runs and signal (2) cannot exist.
44
+ # The 50 filler claims carry NO thread, so #400's board-scoped reset does not touch
45
+ # them (BCP-14) and the only claim whose lease moves is the one under test.
46
+ #
47
+ # MUTATION SIGNALS, MEASURED — and the first one is not what it was predicted to be:
48
+ # * delete `perform agent_board._claimgate_lock_board_policy(v_board_id)` from
49
+ # renew_claim -> over_cap_lease = t, lease_kept_by_revert = f. THE `<waiting ...>`
50
+ # LINE DOES NOT MOVE. Without the board lock the renew still blocks — later, on the
51
+ # claims ROW lock #400's cap reset is holding — but it has already resolved the stale
52
+ # 'advisory' by then. A probe that scored the wait rather than the outcome would have
53
+ # passed this mutation clean, which is why the checker reports state and not timing.
54
+ # * delete the REVALIDATION select (keeping the lock and the policy re-read)
55
+ # -> over_cap_lease stays f, lease_kept_by_revert = f. Caught 10 times out of 10, so
56
+ # the inter-transaction gap this reads out is small but reliably nonzero.
57
+ #
58
+ # THE TWIN: the same renew with no revert at all. The board is advisory, so the
59
+ # opt-out must still work and the 400-day renew must be GRANTED. A guard that
60
+ # over-serialises, or one that resolved every board to 'inherit', passes the race
61
+ # permutation and fails here.
62
+
63
+ setup
64
+ {
65
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
66
+ on conflict (scope_key) do update set enabled = true;
67
+ create table if not exists public.cpr_renew_mark(lease timestamptz);
68
+ grant select, insert on public.cpr_renew_mark to authenticated;
69
+ delete from public.cpr_renew_mark;
70
+ insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000b1');
71
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
72
+ values ('40500000-0000-4000-8000-000000000002', 'agent', 'agent',
73
+ 'cpr-ren-owner', 'CPR Renew Owner', 'active',
74
+ '40500000-0000-4000-8000-0000000000b1', '40500000-0000-4000-8000-0000000000b1');
75
+ select count(*) from agent_board.create_board(
76
+ '40500000-0000-4000-8000-000000000002',
77
+ 'cpr-ren-board', 'CPR Renew Board',
78
+ 'message-board#405 renew stale-policy probe', jsonb_build_object(), 'private');
79
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
80
+ values ('40500000-0000-4000-8000-000000000020', 'coordination',
81
+ '40500000-0000-4000-8000-000000000002', 'CPR renew thread',
82
+ (select id from agent_board.boards where slug = 'cpr-ren-board'));
83
+ update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-ren-board';
84
+ -- The claim under test: on the board, holding a horizon only the opt-out could have granted.
85
+ insert into agent_board.claims(id, agent_id, thread_id, resource_kind, resource_key, lease_token, lease_expires_at)
86
+ values ('40500000-0000-4000-8000-000000000030', '40500000-0000-4000-8000-000000000002',
87
+ '40500000-0000-4000-8000-000000000020', 'file', 'cpr-ren-key',
88
+ '40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
89
+ -- Filler that puts the holder OVER quota so the convergence clamp runs. No thread, so the
90
+ -- board-scoped reset leaves them alone and the only moving lease is the one under test.
91
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
92
+ select '40500000-0000-4000-8000-000000000002', 'file', 'cpr-ren-filler-' || g, now() + interval '1 hour'
93
+ from generate_series(1, agent_board._claimgate_ownerless_quota()) g;
94
+ }
95
+
96
+ teardown
97
+ {
98
+ delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000002';
99
+ delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000020';
100
+ delete from agent_board.board_membership_events where board_id in (
101
+ select id from agent_board.boards where slug = 'cpr-ren-board');
102
+ delete from agent_board.board_memberships where board_id in (
103
+ select id from agent_board.boards where slug = 'cpr-ren-board');
104
+ delete from agent_board.boards where slug = 'cpr-ren-board';
105
+ delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000002';
106
+ delete from auth.users where id = '40500000-0000-4000-8000-0000000000b1';
107
+ delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
108
+ drop table if exists public.cpr_renew_mark;
109
+ }
110
+
111
+ # w: the owner reverting the opt-out. w_mark records what #400's reset actually wrote, from inside
112
+ # the still-uncommitted transaction — the value the renew must not move.
113
+ session "w"
114
+ step "w_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000b1'; }
115
+ step "w_flip" {
116
+ select claim_policy
117
+ from agent_board.set_board_claim_policy(
118
+ (select id from agent_board.boards where slug = 'cpr-ren-board'), 'inherit');
119
+ }
120
+ step "w_mark" {
121
+ insert into public.cpr_renew_mark(lease)
122
+ select lease_expires_at from agent_board.claims where id = '40500000-0000-4000-8000-000000000030';
123
+ }
124
+ step "w_commit" { COMMIT; }
125
+
126
+ # r: the in-flight renew, asking for the full horizon again.
127
+ session "r"
128
+ step "r_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000b1'; }
129
+ step "r_renew" {
130
+ select resource_key
131
+ from agent_board.renew_claim('40500000-0000-4000-8000-0000000000f1', now() + interval '400 days');
132
+ }
133
+ step "r_commit" { COMMIT; }
134
+
135
+ # checker: booleans only. `lease_kept_by_revert` is null in the twin (nothing was marked), which is
136
+ # itself part of the golden.
137
+ session "chk"
138
+ step "chk" {
139
+ select (c.lease_expires_at > now() + interval '31 days') as over_cap_lease,
140
+ (c.lease_expires_at = (select m.lease from public.cpr_renew_mark m)) as lease_kept_by_revert
141
+ from agent_board.claims c
142
+ where c.id = '40500000-0000-4000-8000-000000000030';
143
+ }
144
+
145
+ # THE RACE: the renew is issued while the revert is uncommitted.
146
+ permutation "w_begin" "w_flip" "w_mark" "r_begin" "r_renew" "w_commit" "r_commit" "chk"
147
+
148
+ # THE TWIN: no revert. The board is advisory, the opt-out still works, the 400-day renew is granted
149
+ # and nothing waits.
150
+ permutation "r_begin" "r_renew" "r_commit" "chk"
@@ -0,0 +1,107 @@
1
+ # Consiliency/message-board#405 — the WRITER-SIDE lock-order question, MEASURED
2
+ # rather than argued (PR #397's standard).
3
+ #
4
+ # THE SHAPE OF THE CYCLE THAT WOULD EXIST. `set_board_claim_policy` now takes the
5
+ # board policy advisory lock and THEN wants a row lock on `agent_board.boards`.
6
+ # Any transaction that references that board row -- inserting a thread, a
7
+ # membership, an invite, an inbox item: twelve tables carry an FK to `boards` --
8
+ # holds `FOR KEY SHARE` on it, and if that same transaction then enters an
9
+ # ownerless claim path it wants the advisory lock. advisory -> row versus row ->
10
+ # advisory is a cycle, and #159 is open on lock ordering in this schema, so this
11
+ # is exactly the class that has already produced a revert here (ab38805, a 40P01
12
+ # on a comparable path).
13
+ #
14
+ # WHY IT IS NOT A CYCLE, AND WHY THAT IS AN OBSERVATION AND NOT A CLAIM. #400
15
+ # reads the outgoing policy `for no key update`, which does NOT conflict with
16
+ # `FOR KEY SHARE` -- so the writer never waits on the FK holder, and the wait
17
+ # graph has one edge, not two. This spec drives the arrangement and records that
18
+ # the writer COMPLETES: `t` holds KEY SHARE on the board row, `w` flips the
19
+ # policy anyway, `t`'s acquire then queues on `w`'s advisory lock and proceeds
20
+ # once `w` commits.
21
+ #
22
+ # THE PROBE IS DISCRIMINATING, which is the part that makes it evidence.
23
+ # MUTATION SIGNAL (verified in the PR): change `for no key update` to
24
+ # `for update` in set_board_claim_policy -> `w_flip` prints `<waiting ...>` on
25
+ # t's KEY SHARE, `t_acq` then queues on w's advisory lock, and PostgreSQL
26
+ # reports `ERROR: deadlock detected` (40P01). The same permutation, the same
27
+ # fixture, one lock mode apart. CPR-10 pins the source spelling so the two fail
28
+ # together.
29
+ #
30
+ # THE LEASE IS DELIBERATELY WITHIN THE CAP. This spec is about the wait graph,
31
+ # not about which policy won: the acquire must succeed under either answer, so
32
+ # any diff in the golden is a lock-order diff.
33
+
34
+ setup
35
+ {
36
+ insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
37
+ on conflict (scope_key) do update set enabled = true;
38
+ insert into auth.users (id) values ('40500000-0000-4000-8000-0000000000e1');
39
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
40
+ values ('40500000-0000-4000-8000-000000000005', 'agent', 'agent',
41
+ 'cpr-ks-owner', 'CPR KeyShare Owner', 'active',
42
+ '40500000-0000-4000-8000-0000000000e1', '40500000-0000-4000-8000-0000000000e1');
43
+ select count(*) from agent_board.create_board(
44
+ '40500000-0000-4000-8000-000000000005', 'cpr-ks-board', 'CPR KeyShare Board',
45
+ 'message-board#405 writer-side KEY SHARE lock-order probe', jsonb_build_object(), 'private');
46
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
47
+ values ('40500000-0000-4000-8000-000000000060', 'coordination',
48
+ '40500000-0000-4000-8000-000000000005', 'CPR keyshare thread',
49
+ (select id from agent_board.boards where slug = 'cpr-ks-board'));
50
+ update agent_board.boards set claim_policy = 'advisory' where slug = 'cpr-ks-board';
51
+ }
52
+
53
+ teardown
54
+ {
55
+ delete from agent_board.claims where agent_id = '40500000-0000-4000-8000-000000000005';
56
+ delete from agent_board.threads where id = '40500000-0000-4000-8000-000000000060';
57
+ delete from agent_board.board_membership_events where board_id in (
58
+ select id from agent_board.boards where slug = 'cpr-ks-board');
59
+ delete from agent_board.board_memberships where board_id in (
60
+ select id from agent_board.boards where slug = 'cpr-ks-board');
61
+ delete from agent_board.boards where slug = 'cpr-ks-board';
62
+ delete from agent_board.agents where id = '40500000-0000-4000-8000-000000000005';
63
+ delete from auth.users where id = '40500000-0000-4000-8000-0000000000e1';
64
+ delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
65
+ }
66
+
67
+ # t: the FK holder. `for key share` is taken directly rather than by inserting a referencing row,
68
+ # because it is EXACTLY the lock mode an FK reference takes and it names the board without dragging
69
+ # a second RPC's own locking into the probe. Taken as the connecting role (not `authenticated`,
70
+ # which holds no UPDATE on boards and so cannot take a row lock at all); the acquire that follows
71
+ # drops into `authenticated`, which is the role the ownerless guards actually run for.
72
+ session "t"
73
+ step "t_begin" { BEGIN; }
74
+ step "t_keyshare" { select slug from agent_board.boards where slug = 'cpr-ks-board' for key share; }
75
+ step "t_acq" {
76
+ SET LOCAL ROLE authenticated;
77
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
78
+ SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1';
79
+ select resource_key
80
+ from agent_board.acquire_claim(null, 'file', 'cpr-ks-key', now() + interval '1 hour',
81
+ null, '40500000-0000-4000-8000-000000000060');
82
+ }
83
+ step "t_commit" { COMMIT; }
84
+
85
+ # w: the policy flip. Takes the advisory lock, then the `for no key update` row lock on the very
86
+ # row t is holding KEY SHARE on.
87
+ session "w"
88
+ step "w_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = '40500000-0000-4000-8000-0000000000e1'; }
89
+ step "w_flip" {
90
+ select claim_policy
91
+ from agent_board.set_board_claim_policy(
92
+ (select id from agent_board.boards where slug = 'cpr-ks-board'), 'inherit');
93
+ }
94
+ step "w_commit" { COMMIT; }
95
+
96
+ session "chk"
97
+ step "chk" {
98
+ select coalesce(string_agg(resource_key, ',' order by resource_key), '<none>') as claims,
99
+ (select claim_policy from agent_board.boards where slug = 'cpr-ks-board') as policy
100
+ from agent_board.claims
101
+ where agent_id = '40500000-0000-4000-8000-000000000005'
102
+ and status = 'active' and released_at is null;
103
+ }
104
+
105
+ # KEY SHARE held, then the flip, then the acquire. With `for no key update` the writer does not
106
+ # queue behind the FK holder, so there is exactly one wait edge and no cycle.
107
+ permutation "t_begin" "t_keyshare" "w_begin" "w_flip" "t_acq" "w_commit" "t_commit" "chk"
@@ -0,0 +1,99 @@
1
+ -- Consiliency/message-board#405 — the ADVERSARIAL PRE-STATE for 20261001000100.
2
+ --
3
+ -- WHAT THIS GATES, and why an assertion could not. CPR-22 checks the lock helper's final ACL in the
4
+ -- ordinary fixture — where there is no delegated grant — so it stays GREEN if the migration's
5
+ -- `CASCADE` were removed or its sweep narrowed back to a list of known roles. The migration's
6
+ -- claimed behaviour is about a state the fixture never contains, so the fixture has to contain it.
7
+ --
8
+ -- THE STATE: `service_role` holds EXECUTE **WITH GRANT OPTION** and has granted it onward to
9
+ -- `anon`. That is the adopter shape the migration exists to survive: a plain `REVOKE` defaults to
10
+ -- RESTRICT and fails `dependent privileges exist` against it, which on an adopter database is a
11
+ -- FAILED UPGRADE rather than a tidier ACL. Measured against this bundle: the plain form errors, the
12
+ -- shipped form exits 0 and leaves `{postgres=X/postgres}`.
13
+ --
14
+ -- HOW THE GATE READS. This runs BEFORE 20261001000100 (the runner's tests/seeds/<migration>.pre.sql
15
+ -- hook, #103). Afterwards, either
16
+ -- * the migration swept the chain and CPR-22 reports an empty ACL — the claim holds; or
17
+ -- * the migration RAISED, and the whole bundle replay fails loudly at this migration.
18
+ -- There is no third outcome in which a narrowed sweep passes quietly.
19
+ --
20
+ -- ONLY PRE-EXISTING ROLES ARE USED — no CREATE ROLE. Roles are cluster-global, so a seed that
21
+ -- created them would leak them into a shared cluster and could collide with a concurrent run;
22
+ -- service_role and anon are part of the adopter base this bundle already assumes.
23
+ --
24
+ -- WHAT THIS FIXTURE GATES, precisely — and the second clause is NARROWER than an earlier version of
25
+ -- this paragraph claimed. It gates that the sweep CASCADEs, and that it reaches a grantee OUTSIDE
26
+ -- THE HISTORICAL HAND LIST (public/anon/authenticated/service_role). It does NOT gate that the
27
+ -- sweep is catalog-DERIVED: a migration hard-coded to those four PLUS `authenticator` clears this
28
+ -- fixture and its guard below. A fixture names specific roles by construction, so only a randomly
29
+ -- generated grantee defeats any hand list.
30
+ --
31
+ -- A DISPOSABLE RANDOM GRANTEE WAS CONSIDERED AND REJECTED, checked rather than reasoned about. The
32
+ -- runner has a `.pre.sql` hook and no post hook, so a role created here is never dropped; a seed
33
+ -- that swept `mb_seed405_%` at its start to compensate would be dropping roles belonging to any
34
+ -- CONCURRENT run on the same cluster, and this cluster is shared. Trading a stated gap for
35
+ -- cross-run interference is the worse deal. Derivation itself stays mutation-verified: narrowing
36
+ -- the sweep to known roles was injected and CPR-22 named the survivor.
37
+ --
38
+ -- WHAT IT DOES NOT GATE: that the sweep derives its SIGNATURES. That needs a pre-existing overload,
39
+ -- and a seeded overload PERSISTS — the runner has a `.pre.sql` hook and no post hook, so nothing
40
+ -- drops it, and tests/catalog/p_metadata_parameters.json enumerates every agent_board signature and
41
+ -- would go red. Checked rather than assumed. So a migration narrowed back to the `(uuid)` signature
42
+ -- stays green here; that half is mutation-verified only, and this paragraph is the record of it
43
+ -- rather than a silence someone has to rediscover.
44
+
45
+ do $$
46
+ begin
47
+ -- Idempotent: a re-replay onto a database that already carries the chain must not fail here, or
48
+ -- the seed becomes a worse failure mode than the one it tests for.
49
+ if not exists (select 1 from pg_roles where rolname = 'service_role')
50
+ or not exists (select 1 from pg_roles where rolname = 'anon') then
51
+ raise exception
52
+ 'claimpolicy lock-grant seed: service_role/anon absent — the adopter base this bundle assumes is not present'
53
+ using errcode = '42704';
54
+ end if;
55
+
56
+ -- A GRANTEE OUTSIDE THE FOUR NAMES THE OLD HAND LIST USED. Without this the fixture gates only
57
+ -- "CASCADE is present": a migration hard-coded to revoke public/anon/authenticated/service_role
58
+ -- with CASCADE would sweep the chain below, pass, and still abandon every arbitrary adopter
59
+ -- grantee — which is the entire reason the sweep was made catalog-derived. `authenticator` is
60
+ -- outside that list and is part of the Supabase adopter base, so nothing has to be created:
61
+ -- a role invented here would linger cluster-wide, since a seed has no post hook to drop it.
62
+ execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to authenticator';
63
+
64
+ execute 'grant execute on function agent_board._claimgate_lock_board_policy(uuid) to service_role with grant option';
65
+ end $$;
66
+
67
+ -- The onward grant must be made BY service_role, so the dependency really exists.
68
+ --
69
+ -- `SET ROLE`, NOT `SET LOCAL ROLE`, and that distinction is the whole gate. The runner applies this
70
+ -- file with `psql -f` in AUTOCOMMIT, so every statement is its own implicit transaction and a
71
+ -- `SET LOCAL` is discarded before the next one runs. The first version used SET LOCAL: the grant was
72
+ -- then made by `postgres`, recorded as `anon=X/postgres` with no dependency on service_role's grant,
73
+ -- and a plain RESTRICT revoke would have swept it happily — a gate that passed whether or not the
74
+ -- migration cascaded. The check below caught exactly that, which is why it is here.
75
+ set role service_role;
76
+ grant execute on function agent_board._claimgate_lock_board_policy(uuid) to anon;
77
+ reset role;
78
+
79
+ -- Proof the pre-state is what this file claims, checked rather than assumed: if the chain did not
80
+ -- form, the migration would sweep a state no harder than the ordinary one and the gate would be
81
+ -- vacuous — green for the wrong reason, which is the failure mode this whole effort keeps finding.
82
+ do $$
83
+ declare v_acl text;
84
+ begin
85
+ select coalesce(proacl::text, '<null>') into v_acl
86
+ from pg_proc where oid = to_regprocedure('agent_board._claimgate_lock_board_policy(uuid)');
87
+ if v_acl not like '%anon=X/service_role%' then
88
+ raise exception
89
+ 'claimpolicy lock-grant seed: the delegated grant did not form (acl=%) — refusing to leave a VACUOUS gate that would pass whether or not the migration cascades',
90
+ v_acl
91
+ using errcode = '55000';
92
+ end if;
93
+ if v_acl not like '%authenticator=%' then
94
+ raise exception
95
+ 'claimpolicy lock-grant seed: the out-of-list grantee did not take (acl=%) — without it this fixture gates only CASCADE, and a migration narrowed to the four historical role names would pass',
96
+ v_acl
97
+ using errcode = '55000';
98
+ end if;
99
+ end $$;