@consiliency/agent-board-schema 1.27.0 → 1.35.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 (35) hide show
  1. package/fixtures/already-applied.json +32 -0
  2. package/migrations/20260909000100_agent_board_ledger_revoked_authority.sql +1036 -0
  3. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  4. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  5. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  6. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  7. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  8. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  9. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  10. package/package.json +1 -1
  11. package/schema-manifest.json +75 -3
  12. package/src/tooling.test.mjs +47 -2
  13. package/tests/agent_board_atomic_fleet_onboarding.test.sql +161 -30
  14. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  15. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  16. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  17. package/tests/agent_board_fedsession.test.sql +17 -2
  18. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  19. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  20. package/tests/agent_board_ledger_authority.test.sql +1081 -0
  21. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  22. package/tests/agent_board_signer_lifecycle.test.sql +29 -6
  23. package/tests/agent_board_source_strip_helper.test.sql +157 -1
  24. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  25. package/tests/catalog/p_metadata_parameters.json +20 -0
  26. package/tests/fixtures/test-prereqs.sql +420 -11
  27. package/tests/isolation/expected/ledger_authority_revoke.out +701 -0
  28. package/tests/isolation/expected/signer_lifecycle_revoke.out +1 -1
  29. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  30. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  31. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  32. package/tests/isolation/specs/ledger_authority_revoke.spec +405 -0
  33. package/tests/isolation/specs/signer_lifecycle_revoke.spec +10 -0
  34. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  35. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -92,7 +92,7 @@ step auth_grant:
92
92
  <waiting ...>
93
93
  step revoke_commit: COMMIT;
94
94
  step auth_grant: <... completed>
95
- ERROR: signer_not_active: signed authority write signer is revoked, not active
95
+ ERROR: fleet_revoked: grant_board_role is not permitted for a fleet-revoked agent
96
96
  step auth_commit: COMMIT;
97
97
  step check:
98
98
  select
@@ -0,0 +1,203 @@
1
+ Parsed test spec with 5 sessions
2
+
3
+ starting permutation: probe_begin probe_threads append_begin append_run probe_tasks probe_commit append_commit check
4
+ step probe_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
5
+ step probe_threads:
6
+ select count(*)::int as locked_threads from (
7
+ select 1 from agent_board.threads t
8
+ where t.id = '7a820000-0000-4000-8000-000000000001' for update) s;
9
+
10
+ locked_threads
11
+ --------------
12
+ 1
13
+ (1 row)
14
+
15
+ step append_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
16
+ step append_run:
17
+ select e.event_sequence,
18
+ (e.thread_id = '7a820000-0000-4000-8000-000000000001') as thread_is_task_thread
19
+ from agent_board.append_task_event(
20
+ '7a830000-0000-4000-8000-000000000001',
21
+ '7a810000-0000-4000-8000-000000000001',
22
+ 'commented') e;
23
+ <waiting ...>
24
+ step probe_tasks:
25
+ select count(*)::int as locked_tasks from (
26
+ select 1 from agent_board.tasks tk
27
+ where tk.id = '7a830000-0000-4000-8000-000000000001' for update) s;
28
+
29
+ locked_tasks
30
+ ------------
31
+ 1
32
+ (1 row)
33
+
34
+ step probe_commit: COMMIT;
35
+ step append_run: <... completed>
36
+ event_sequence|thread_is_task_thread
37
+ --------------+---------------------
38
+ 1|t
39
+ (1 row)
40
+
41
+ step append_commit: COMMIT;
42
+ step check:
43
+ select (select array_agg(te.event_sequence order by te.event_sequence)
44
+ from agent_board.task_events te
45
+ where te.task_id = '7a830000-0000-4000-8000-000000000001') as event_sequences,
46
+ (select count(*)::int from agent_board.messages
47
+ where thread_id = '7a820000-0000-4000-8000-000000000001') as messages,
48
+ (select count(*)::int from agent_board.task_events te
49
+ where te.task_id = '7a830000-0000-4000-8000-000000000001'
50
+ and te.thread_id <> '7a820000-0000-4000-8000-000000000001') as events_on_wrong_thread;
51
+
52
+ event_sequences|messages|events_on_wrong_thread
53
+ ---------------+--------+----------------------
54
+ {1} | 0| 0
55
+ (1 row)
56
+
57
+
58
+ starting permutation: append_begin append_run post_begin post_run append_commit post_commit check
59
+ step append_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
60
+ step append_run:
61
+ select e.event_sequence,
62
+ (e.thread_id = '7a820000-0000-4000-8000-000000000001') as thread_is_task_thread
63
+ from agent_board.append_task_event(
64
+ '7a830000-0000-4000-8000-000000000001',
65
+ '7a810000-0000-4000-8000-000000000001',
66
+ 'commented') e;
67
+
68
+ event_sequence|thread_is_task_thread
69
+ --------------+---------------------
70
+ 1|t
71
+ (1 row)
72
+
73
+ step post_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
74
+ step post_run:
75
+ select m.message_sequence,
76
+ (m.task_id = '7a830000-0000-4000-8000-000000000001') as task_attached
77
+ from agent_board.post_message(
78
+ '7a820000-0000-4000-8000-000000000001',
79
+ '7a810000-0000-4000-8000-000000000001',
80
+ 'r1 iso body',
81
+ 'comment',
82
+ '7a830000-0000-4000-8000-000000000001') m;
83
+ <waiting ...>
84
+ step append_commit: COMMIT;
85
+ step post_run: <... completed>
86
+ message_sequence|task_attached
87
+ ----------------+-------------
88
+ 1|t
89
+ (1 row)
90
+
91
+ step post_commit: COMMIT;
92
+ step check:
93
+ select (select array_agg(te.event_sequence order by te.event_sequence)
94
+ from agent_board.task_events te
95
+ where te.task_id = '7a830000-0000-4000-8000-000000000001') as event_sequences,
96
+ (select count(*)::int from agent_board.messages
97
+ where thread_id = '7a820000-0000-4000-8000-000000000001') as messages,
98
+ (select count(*)::int from agent_board.task_events te
99
+ where te.task_id = '7a830000-0000-4000-8000-000000000001'
100
+ and te.thread_id <> '7a820000-0000-4000-8000-000000000001') as events_on_wrong_thread;
101
+
102
+ event_sequences|messages|events_on_wrong_thread
103
+ ---------------+--------+----------------------
104
+ {1} | 1| 0
105
+ (1 row)
106
+
107
+
108
+ starting permutation: post_begin post_run append_begin append_run post_commit append_commit check
109
+ step post_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
110
+ step post_run:
111
+ select m.message_sequence,
112
+ (m.task_id = '7a830000-0000-4000-8000-000000000001') as task_attached
113
+ from agent_board.post_message(
114
+ '7a820000-0000-4000-8000-000000000001',
115
+ '7a810000-0000-4000-8000-000000000001',
116
+ 'r1 iso body',
117
+ 'comment',
118
+ '7a830000-0000-4000-8000-000000000001') m;
119
+
120
+ message_sequence|task_attached
121
+ ----------------+-------------
122
+ 1|t
123
+ (1 row)
124
+
125
+ step append_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
126
+ step append_run:
127
+ select e.event_sequence,
128
+ (e.thread_id = '7a820000-0000-4000-8000-000000000001') as thread_is_task_thread
129
+ from agent_board.append_task_event(
130
+ '7a830000-0000-4000-8000-000000000001',
131
+ '7a810000-0000-4000-8000-000000000001',
132
+ 'commented') e;
133
+ <waiting ...>
134
+ step post_commit: COMMIT;
135
+ step append_run: <... completed>
136
+ event_sequence|thread_is_task_thread
137
+ --------------+---------------------
138
+ 1|t
139
+ (1 row)
140
+
141
+ step append_commit: COMMIT;
142
+ step check:
143
+ select (select array_agg(te.event_sequence order by te.event_sequence)
144
+ from agent_board.task_events te
145
+ where te.task_id = '7a830000-0000-4000-8000-000000000001') as event_sequences,
146
+ (select count(*)::int from agent_board.messages
147
+ where thread_id = '7a820000-0000-4000-8000-000000000001') as messages,
148
+ (select count(*)::int from agent_board.task_events te
149
+ where te.task_id = '7a830000-0000-4000-8000-000000000001'
150
+ and te.thread_id <> '7a820000-0000-4000-8000-000000000001') as events_on_wrong_thread;
151
+
152
+ event_sequences|messages|events_on_wrong_thread
153
+ ---------------+--------+----------------------
154
+ {1} | 1| 0
155
+ (1 row)
156
+
157
+
158
+ starting permutation: append_begin append_run append2_begin append2_run append_commit append2_commit check
159
+ step append_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
160
+ step append_run:
161
+ select e.event_sequence,
162
+ (e.thread_id = '7a820000-0000-4000-8000-000000000001') as thread_is_task_thread
163
+ from agent_board.append_task_event(
164
+ '7a830000-0000-4000-8000-000000000001',
165
+ '7a810000-0000-4000-8000-000000000001',
166
+ 'commented') e;
167
+
168
+ event_sequence|thread_is_task_thread
169
+ --------------+---------------------
170
+ 1|t
171
+ (1 row)
172
+
173
+ step append2_begin: BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role';
174
+ step append2_run:
175
+ select e.event_sequence
176
+ from agent_board.append_task_event(
177
+ '7a830000-0000-4000-8000-000000000001',
178
+ '7a810000-0000-4000-8000-000000000001',
179
+ 'status_changed') e;
180
+ <waiting ...>
181
+ step append_commit: COMMIT;
182
+ step append2_run: <... completed>
183
+ event_sequence
184
+ --------------
185
+ 2
186
+ (1 row)
187
+
188
+ step append2_commit: COMMIT;
189
+ step check:
190
+ select (select array_agg(te.event_sequence order by te.event_sequence)
191
+ from agent_board.task_events te
192
+ where te.task_id = '7a830000-0000-4000-8000-000000000001') as event_sequences,
193
+ (select count(*)::int from agent_board.messages
194
+ where thread_id = '7a820000-0000-4000-8000-000000000001') as messages,
195
+ (select count(*)::int from agent_board.task_events te
196
+ where te.task_id = '7a830000-0000-4000-8000-000000000001'
197
+ and te.thread_id <> '7a820000-0000-4000-8000-000000000001') as events_on_wrong_thread;
198
+
199
+ event_sequences|messages|events_on_wrong_thread
200
+ ---------------+--------+----------------------
201
+ {1,2} | 0| 0
202
+ (1 row)
203
+
@@ -60,12 +60,13 @@ setup
60
60
  -- Materialize the owner auth.users row the enrollment will derive, so the guard
61
61
  -- holds the SAME row (a race to INSERT it would block on the unique index, not on
62
62
  -- the lock order under test).
63
+ -- #246: through the shared derivation helper, deliberately. The point of this row is
64
+ -- that it is the SAME row the enrollment will resolve to; a copied expression here
65
+ -- would silently stop matching the moment the derivation moved, and this spec would
66
+ -- go on "passing" while holding a lock on an unrelated row.
63
67
  create table lo194_owner as
64
68
  select agent_board._ensure_agent_auth_user(
65
- 'fed-human-' || substr(
66
- encode(extensions.digest(
67
- convert_to('https://lo194-iso.test' || '|' || 'iso-human', 'UTF8'), 'sha256'), 'hex'),
68
- 1, 32)) as uid;
69
+ agent_board._federated_identity_slug('https://lo194-iso.test', 'iso-human')) as uid;
69
70
  }
70
71
 
71
72
  # Holds the human owner row, so the enrollment parks on it in either schema.
@@ -22,12 +22,12 @@
22
22
 
23
23
  setup
24
24
  {
25
+ -- #246: through the shared derivation helper, deliberately -- see the note in
26
+ -- enroll_identity_board_owner_lock_order.spec. The row must be the one the path under
27
+ -- test would resolve to, not a copy of an expression that can drift away from it.
25
28
  create table lo193_owner as
26
29
  select agent_board._ensure_agent_auth_user(
27
- 'fed-human-' || substr(
28
- encode(extensions.digest(
29
- convert_to('https://lo193-iso.test' || '|' || 'iso-orphan', 'UTF8'), 'sha256'), 'hex'),
30
- 1, 32)) as uid;
30
+ agent_board._federated_identity_slug('https://lo193-iso.test', 'iso-orphan')) as uid;
31
31
  }
32
32
 
33
33
  # Holds the human owner row that the absent-provider path must never reach.
@@ -0,0 +1,405 @@
1
+ # Consiliency/message-board#276 — a board AUTHORITY write reads the fleet revocation ledger
2
+ # under a share lock on the actor's agents row, so a concurrent revoke_fleet_subject('agent')
3
+ # serializes against the write instead of racing it.
4
+ #
5
+ # WHY THIS CANNOT BE A pgTAP FILE. The predicate ("a ledger-revoked grantor is refused") is
6
+ # provable in one transaction and is proved in agent_board_ledger_authority.test.sql. The
7
+ # property THIS file exists for is not the predicate: it is that the read is LOCKED. An
8
+ # unlocked `exists (select 1 from fleet_revocation_events ...)` passes every pgTAP assertion in
9
+ # that file and still leaves the TOCTOU wide open — the authority write reads "not revoked", a
10
+ # revocation commits, and the grant lands anyway. A single rolled-back transaction cannot
11
+ # produce that interleaving. isolationtester prints `<waiting ...>` / `<... completed>` whenever
12
+ # a step blocks, so the lock is a byte in the golden rather than a claim in a comment.
13
+ #
14
+ # THE ADVERSARY IS THE SHIPPED FUNCTION, not a stand-in. `revoke_fleet_subject('agent', X)`
15
+ # (20260901000100:1062) is the exact call the issue is about: it locks the machines X is bound
16
+ # to, then the agents row FOR UPDATE, then the bindings, then writes the agent-subject ledger
17
+ # row. That `agents` FOR UPDATE is what the gate's FOR SHARE conflicts with, and it is the sole
18
+ # reason the two orders below produce different outcomes.
19
+ #
20
+ # BOTH ROUTES ARE DRIVEN, because the whole point of siting the check inside grant_board_role
21
+ # was that the wide `authenticated` door and the narrow signed one could not diverge:
22
+ # ugrant — grant_board_role called directly by the authenticated owner. No gateway.
23
+ # sgrant — signed_grant_board_role called by the service_role gateway, with a decision
24
+ # naming a live, owned, verified key so a refusal can only be #276's gate and
25
+ # never #269's signer revalidation.
26
+ #
27
+ # The check session also reads agents.status: it must stay 'active' throughout. If a future
28
+ # change made fleet revocation flip the status, these permutations would still pass — but for
29
+ # a different reason — and that column in the golden is what makes the substitution visible.
30
+ #
31
+ # MUTATION-VERIFIED: dropping `for share` from _assert_actor_not_fleet_revoked (leaving the
32
+ # ledger read unlocked) changes permutations 2 and 4 — the authority write no longer waits.
33
+ # Clean schema -> byte-identical across 10 runs.
34
+ #
35
+ # #280 RE-RAN THAT MUTATION and measured which of the four new permutations it moves. The result
36
+ # is the honest form of this migration's lock claim, so it is recorded here rather than argued:
37
+ # * Permutations 9 and 10 (the FRESH-BOARD branch) are BYTE-IDENTICAL with the lock removed.
38
+ # That is expected and is the point: the owner-seat INSERT's FK already holds KEY SHARE on
39
+ # the actor's agents row before the gate runs, so the FK — not the FOR SHARE — is what makes
40
+ # that branch wait. These two permutations pin the OUTCOME (blocks, fails closed, no board
41
+ # and no seat) and CANNOT pin the lock. Nothing here should be read as though they do.
42
+ # * Permutations 11 and 12 (the SLUG-COLLISION branch) BOTH move. With the lock removed,
43
+ # permutation 11 stops waiting entirely and the reopen SUCCEEDS on its pre-revocation
44
+ # snapshot: the check row goes from `K276 ISO Board | private` to `K280 ISO REOPENED | open`
45
+ # — a ledger-revoked owner reopening an existing board and making it publicly visible across
46
+ # a committing revocation. That is the TOCTOU the FOR SHARE closes, and permutation 11 is the
47
+ # only place in this spec where the lock itself is discriminated.
48
+
49
+ setup
50
+ {
51
+ insert into auth.users (id) values
52
+ ('27700000-0000-0000-0000-0000000000a1'),
53
+ ('27700000-0000-0000-0000-0000000000a2'),
54
+ ('27700000-0000-0000-0000-0000000000a3');
55
+
56
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
57
+ values
58
+ ('27710000-0000-4000-8000-000000000001','agent','agent','k276iso-owner','K276 ISO Owner','active','27700000-0000-0000-0000-0000000000a1'),
59
+ ('27710000-0000-4000-8000-000000000002','agent','agent','k276iso-grantee','K276 ISO Grantee','active','27700000-0000-0000-0000-0000000000a2'),
60
+ ('27710000-0000-4000-8000-000000000003','agent','agent','k276iso-revoker','K276 ISO Revoker','active','27700000-0000-0000-0000-0000000000a3');
61
+
62
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
63
+ values ('27720000-0000-4000-8000-000000000001','k276-iso-board','K276 ISO Board',
64
+ '27710000-0000-4000-8000-000000000001','private','active');
65
+
66
+ insert into agent_board.board_memberships(board_id, agent_id, role, membership_state, created_by_agent_id)
67
+ values
68
+ ('27720000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000001','owner','joined','27710000-0000-4000-8000-000000000001'),
69
+ ('27720000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000002','member','joined','27710000-0000-4000-8000-000000000001');
70
+
71
+ insert into agent_board.agent_signing_keys(
72
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
73
+ values ('27730000-0000-4000-8000-000000000001','27710000-0000-4000-8000-000000000001',
74
+ 'primary', 1, public.test_pubkey('k276-iso'), 'raw_ed25519_base64url',
75
+ public.test_key_fingerprint('k276-iso'), 'active', 'proven');
76
+
77
+ -- A real fleet binding, so revoke_fleet_subject('agent') walks its full path (machine locks,
78
+ -- agents lock, binding revocation, ledger write) rather than a degenerate one.
79
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
80
+ values ('27740000-0000-4000-8000-000000000001','k276-iso-domain','K276 ISO Domain','tailnet',
81
+ '27710000-0000-4000-8000-000000000003','active', now());
82
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint, lifecycle_state, enrolled_at)
83
+ values ('27750000-0000-4000-8000-000000000001','27740000-0000-4000-8000-000000000001',
84
+ '27710000-0000-4000-8000-000000000003','k276-iso-machine','fp-k276-iso','active', now());
85
+ insert into agent_board.fleet_machine_agents(id, machine_id, agent_id, agent_role, lifecycle_state)
86
+ values ('27760000-0000-4000-8000-000000000001','27750000-0000-4000-8000-000000000001',
87
+ '27710000-0000-4000-8000-000000000001','phase_worker','active');
88
+
89
+ -- An OUTSTANDING admin link, modelled as a pre-existing row because that is what it is: a
90
+ -- link minted before its creator was revoked, which the mint gate structurally cannot reach.
91
+ -- Seeded rather than minted through create_invite_link for two reasons, both mechanical: the
92
+ -- generated code is random (a golden must not contain one), and board_invite_tokens carries
93
+ -- a CREATOR-ONLY read policy (board_invite_tokens_creator_read), so a redeem session running
94
+ -- as `authenticated` cannot look the code up -- it holds the code out of band, as a real
95
+ -- redeemer does. An earlier revision of this spec did look it up and every redemption failed
96
+ -- 'invite is not usable' on a null code, which the uniform error made indistinguishable from
97
+ -- the gate firing. The code is a literal here for exactly that reason.
98
+ insert into agent_board.board_invite_tokens(
99
+ code, board_id, preset_role, preset_membership_state, max_uses, expires_at, created_by_agent_id)
100
+ values ('k276isolink', '27720000-0000-4000-8000-000000000001', 'admin', 'joined', 5,
101
+ now() + interval '2 days', '27710000-0000-4000-8000-000000000001');
102
+ }
103
+
104
+ teardown
105
+ {
106
+ delete from agent_board.signature_evidence
107
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
108
+ delete from agent_board.board_invite_redemptions
109
+ where invite_id in (select id from agent_board.board_invite_tokens
110
+ where board_id = '27720000-0000-4000-8000-000000000001');
111
+ delete from agent_board.board_invite_tokens
112
+ where board_id = '27720000-0000-4000-8000-000000000001';
113
+ delete from agent_board.board_membership_events
114
+ where board_id in (select id from agent_board.boards where slug like 'k280-iso-%');
115
+ delete from agent_board.board_memberships
116
+ where board_id in (select id from agent_board.boards where slug like 'k280-iso-%');
117
+ delete from agent_board.boards where slug like 'k280-iso-%';
118
+ delete from agent_board.board_membership_events
119
+ where board_id = '27720000-0000-4000-8000-000000000001';
120
+ delete from agent_board.fleet_revocation_events
121
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%')
122
+ or subject_id in (select id from agent_board.agents where handle like 'k276iso-%')
123
+ or revoked_by_agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
124
+ delete from agent_board.fleet_machine_agents where id='27760000-0000-4000-8000-000000000001';
125
+ delete from agent_board.fleet_machines where id='27750000-0000-4000-8000-000000000001';
126
+ delete from agent_board.fleet_trust_domains where id='27740000-0000-4000-8000-000000000001';
127
+ delete from agent_board.agent_signing_keys
128
+ where agent_id in (select id from agent_board.agents where handle like 'k276iso-%');
129
+ delete from agent_board.board_authority_ceilings where board_id='27720000-0000-4000-8000-000000000001';
130
+ delete from agent_board.board_memberships where board_id='27720000-0000-4000-8000-000000000001';
131
+ delete from agent_board.boards where id='27720000-0000-4000-8000-000000000001';
132
+ delete from agent_board.agents where handle like 'k276iso-%';
133
+ delete from auth.users where id in (
134
+ '27700000-0000-0000-0000-0000000000a1',
135
+ '27700000-0000-0000-0000-0000000000a2',
136
+ '27700000-0000-0000-0000-0000000000a3');
137
+ }
138
+
139
+ # The WIDE door: the authenticated owner calling the chokepoint directly, no gateway involved.
140
+ session "ugrant"
141
+ step "ugrant_begin" {
142
+ BEGIN;
143
+ SET LOCAL ROLE authenticated;
144
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
145
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
146
+ }
147
+ step "ugrant_grant" {
148
+ select g.role as granted_role
149
+ from agent_board.grant_board_role(
150
+ '27720000-0000-4000-8000-000000000001',
151
+ '27710000-0000-4000-8000-000000000002',
152
+ 'admin') g;
153
+ }
154
+ step "ugrant_commit" { COMMIT; }
155
+
156
+ # The NARROW door. Only the deterministic column is selected -- signature_evidence_id is a
157
+ # fresh uuid and a golden must not contain a random value.
158
+ session "sgrant"
159
+ step "sgrant_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
160
+ step "sgrant_grant" {
161
+ select w.role as granted_role
162
+ from agent_board.signed_grant_board_role(
163
+ jsonb_build_object(
164
+ 'p_board_id', '27720000-0000-4000-8000-000000000001',
165
+ 'p_agent_id', '27710000-0000-4000-8000-000000000002',
166
+ 'p_grantee_role', 'admin'),
167
+ jsonb_build_object(
168
+ 'agent_id', '27710000-0000-4000-8000-000000000001',
169
+ 'signing_key_id', '27730000-0000-4000-8000-000000000001',
170
+ 'key_id', 'primary',
171
+ 'key_version', 1,
172
+ 'algorithm', 'Ed25519',
173
+ 'verification_status', 'verified',
174
+ 'payload_digest', 'sha256:k276',
175
+ 'enforcement_mode', 'require_for_agent_writes')) w;
176
+ }
177
+ step "sgrant_commit" { COMMIT; }
178
+
179
+ session "revoke"
180
+ step "revoke_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
181
+ step "revoke_agent" {
182
+ select subject_kind
183
+ from agent_board.revoke_fleet_subject(
184
+ 'agent',
185
+ '27710000-0000-4000-8000-000000000001',
186
+ '27710000-0000-4000-8000-000000000003',
187
+ 'k276 iso fleet revocation');
188
+ }
189
+ step "revoke_commit" { COMMIT; }
190
+
191
+ # The invite MINT half (#276 G3). Locked like the three chokepoints: the actor IS the principal
192
+ # whose authority is being spent, since an admin-preset link is a deferred grant of admin.
193
+ session "mint"
194
+ step "mint_begin" {
195
+ BEGIN;
196
+ SET LOCAL ROLE authenticated;
197
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
198
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
199
+ }
200
+ step "mint_link" {
201
+ select m.preset_role
202
+ from agent_board.create_invite_link(
203
+ '27720000-0000-4000-8000-000000000001', 'admin', 'joined', 5,
204
+ now() + interval '2 days') m;
205
+ }
206
+ step "mint_commit" { COMMIT; }
207
+
208
+ # The invite REDEEM half. DELIBERATELY UNLOCKED -- every way of row-locking the creator here
209
+ # closes a cycle (enroll_federated_identity reaches this function holding a boards lock; see the
210
+ # migration header). This session exists to record the ACCEPTED window as bytes rather than
211
+ # prose: with the revocation in flight and uncommitted, the redemption does NOT block and
212
+ # succeeds on its own snapshot. Permutation 6 then shows the very next redemption refused.
213
+ session "redeem"
214
+ step "redeem_begin" {
215
+ BEGIN;
216
+ SET LOCAL ROLE authenticated;
217
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
218
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a2';
219
+ }
220
+ step "redeem_link" {
221
+ select r.role as redeemed_role
222
+ from agent_board.redeem_invite_link('k276isolink') r;
223
+ }
224
+ step "redeem_commit" { COMMIT; }
225
+
226
+ # A SECOND redeemer of the SAME token. Exists to bound the accepted window: two redemptions of
227
+ # one token cannot both sit between their ledger read and their commit, because the token
228
+ # FOR UPDATE is the redeem path's first statement. Permutation 8 drives exactly that.
229
+ session "redeem2"
230
+ step "redeem2_begin" {
231
+ BEGIN;
232
+ SET LOCAL ROLE authenticated;
233
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
234
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a3';
235
+ }
236
+ step "redeem2_link" {
237
+ select r.role as redeemed_role
238
+ from agent_board.redeem_invite_link('k276isolink') r;
239
+ }
240
+ step "redeem2_commit" { COMMIT; }
241
+
242
+ # ---------------------------------------------------------------------------------------------
243
+ # #280 — create_board. TWO sessions, because the function is two operations behind one name and
244
+ # the FOR SHARE means something different on each branch (see 20260911000100's header).
245
+ #
246
+ # cboard — the FRESH-BOARD branch. The owner seat INSERT takes FK KEY SHARE on the actor's
247
+ # agents row BEFORE the gate runs, so the FK — not the gate — is what makes this
248
+ # branch wait. Permutations 9 and 10 therefore pin the OUTCOME (blocks, then fails
249
+ # closed, leaving NO board and NO seat) and structurally cannot pin the lock.
250
+ # creopen — the SLUG-COLLISION branch, re-opening the board the actor already owns. The boards
251
+ # UPDATE does not touch created_by_agent_id, so no FK fires and the gate's FOR SHARE
252
+ # is the ONLY agents lock on that path. Permutation 11 is the genuine lock
253
+ # discriminator this migration has and the mint path could not provide: remove the
254
+ # FOR SHARE and the reopen stops waiting and SUCCEEDS on its own snapshot.
255
+ # ---------------------------------------------------------------------------------------------
256
+ session "cboard"
257
+ step "cboard_begin" {
258
+ BEGIN;
259
+ SET LOCAL ROLE authenticated;
260
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
261
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
262
+ }
263
+ step "cboard_create" {
264
+ select c.slug
265
+ from agent_board.create_board(
266
+ '27710000-0000-4000-8000-000000000001', 'k280-iso-fresh', 'K280 ISO Fresh') c;
267
+ }
268
+ step "cboard_commit" { COMMIT; }
269
+
270
+ session "creopen"
271
+ step "creopen_begin" {
272
+ BEGIN;
273
+ SET LOCAL ROLE authenticated;
274
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
275
+ SET LOCAL "request.jwt.claim.sub" = '27700000-0000-0000-0000-0000000000a1';
276
+ }
277
+ # p_metadata is passed as NULL below rather than as an empty-object jsonb literal, and this note
278
+ # lives OUTSIDE the step for the same reason: the isolationtester spec lexer matches BRACES to
279
+ # delimit a step body, so an empty-object literal anywhere inside the SQL closes the step early
280
+ # and the parse dies on the next quote. create_board coalesces a null p_metadata to that same
281
+ # empty object, so the call is identical. (Backticks break the lexer here too.)
282
+ step "creopen_call" {
283
+ select c.name, c.visibility
284
+ from agent_board.create_board(
285
+ '27710000-0000-4000-8000-000000000001', 'k276-iso-board', 'K280 ISO REOPENED',
286
+ null, null, 'open') c;
287
+ }
288
+ step "creopen_commit" { COMMIT; }
289
+
290
+ # The #280 state row. Kept separate from "check" so the existing golden rows keep their shape and
291
+ # a diff in permutations 1-8 can only be a real change.
292
+ session "cbcheck"
293
+ step "cbcheck" {
294
+ select
295
+ (select count(*) from agent_board.boards where slug = 'k280-iso-fresh') as fresh_boards,
296
+ (select count(*) from agent_board.board_memberships bm
297
+ join agent_board.boards b on b.id = bm.board_id
298
+ where b.slug = 'k280-iso-fresh') as fresh_seats,
299
+ (select name from agent_board.boards
300
+ where id = '27720000-0000-4000-8000-000000000001') as existing_board_name,
301
+ (select visibility from agent_board.boards
302
+ where id = '27720000-0000-4000-8000-000000000001') as existing_board_visibility,
303
+ (select status from agent_board.agents
304
+ where id = '27710000-0000-4000-8000-000000000001') as owner_status,
305
+ (select count(*) from agent_board.fleet_revocation_events
306
+ where subject_kind = 'agent'
307
+ and subject_id = '27710000-0000-4000-8000-000000000001') as agent_ledger_rows;
308
+ }
309
+
310
+ session "check"
311
+ step "check" {
312
+ select
313
+ (select role from agent_board.board_memberships
314
+ where board_id = '27720000-0000-4000-8000-000000000001'
315
+ and agent_id = '27710000-0000-4000-8000-000000000002') as grantee_role,
316
+ (select status from agent_board.agents
317
+ where id = '27710000-0000-4000-8000-000000000001') as owner_status,
318
+ (select count(*) from agent_board.fleet_revocation_events
319
+ where subject_kind = 'agent'
320
+ and subject_id = '27710000-0000-4000-8000-000000000001') as agent_ledger_rows,
321
+ (select count(*) from agent_board.fleet_machine_agents
322
+ where agent_id = '27710000-0000-4000-8000-000000000001'
323
+ and lifecycle_state = 'active') as active_bindings,
324
+ (select count(*) from agent_board.board_invite_tokens
325
+ where board_id = '27720000-0000-4000-8000-000000000001'
326
+ and code <> 'k276isolink') as minted_tokens,
327
+ (select uses_count from agent_board.board_invite_tokens
328
+ where code = 'k276isolink') as outstanding_link_uses;
329
+ }
330
+
331
+ # 1. Write first (UNSIGNED). The revocation must WAIT on the share-locked agents row rather
332
+ # than committing underneath a grant that has already been authorized. The grant stands and
333
+ # the revocation lands after it -- a grant that beat the revocation, which is the same
334
+ # outcome as a grant one millisecond earlier.
335
+ permutation "ugrant_begin" "ugrant_grant" "revoke_begin" "revoke_agent" "ugrant_commit" "revoke_commit" "check"
336
+
337
+ # 2. THE DEFECT'S OWN INTERLEAVING, unsigned route. The revocation is in flight when the
338
+ # authority write runs; the write blocks on the agents row, then observes the committed
339
+ # revocation and fails closed. Before #276 this permutation COMPLETED the grant: the
340
+ # chokepoint read no ledger at all, so there was nothing to block on and nothing to see.
341
+ permutation "revoke_begin" "revoke_agent" "ugrant_begin" "ugrant_grant" "revoke_commit" "ugrant_commit" "check"
342
+
343
+ # 3. Write first, SIGNED route. Same property through the gateway wrapper, which reaches the
344
+ # gate only because the check lives inside the inner chokepoint.
345
+ permutation "sgrant_begin" "sgrant_grant" "revoke_begin" "revoke_agent" "sgrant_commit" "revoke_commit" "check"
346
+
347
+ # 4. The defect's interleaving through the SIGNED route. #269 already made this wrapper block
348
+ # on the agents row for a STATUS change; this permutation is what proves it also fails
349
+ # closed on a LEDGER revocation, which leaves agents.status untouched.
350
+ permutation "revoke_begin" "revoke_agent" "sgrant_begin" "sgrant_grant" "revoke_commit" "sgrant_commit" "check"
351
+
352
+ # 5. The MINT half, revoke-in-flight. The mint blocks on the creator's agents row, then observes
353
+ # the committed revocation and fails closed -- and leaves no token, so no code escapes. This
354
+ # is the permutation the FOR SHARE in _assert_actor_not_fleet_revoked exists for on this path.
355
+ permutation "revoke_begin" "revoke_agent" "mint_begin" "mint_link" "revoke_commit" "mint_commit" "check"
356
+
357
+ # 6. THE ACCEPTED WINDOW, recorded rather than described. The outstanding link exists, the
358
+ # revocation is IN FLIGHT and uncommitted, and the redemption does NOT block -- it reads its
359
+ # own snapshot and succeeds. That is the TOCTOU this migration deliberately accepts on the
360
+ # redeem path, because every row lock that would close it closes a deadlock cycle instead
361
+ # (see the header). The check row shows the use consumed; the pgTAP file shows the NEXT
362
+ # redemption, after the revocation commits, refused.
363
+ permutation "revoke_begin" "revoke_agent" "redeem_begin" "redeem_link" "redeem_commit" "revoke_commit" "check"
364
+
365
+ # 7. ...and the same interleaving with the revocation COMMITTED first: now the redemption is
366
+ # refused. The pair is what makes permutation 6 a bounded window rather than an open door.
367
+ permutation "revoke_begin" "revoke_agent" "revoke_commit" "redeem_begin" "redeem_link" "redeem_commit" "check"
368
+
369
+ # 8. THE RESIDUAL BOUND, driven rather than argued. Two redeemers of the SAME token with the
370
+ # revocation in flight: the first reads the ledger before the revocation commits and slips
371
+ # through; the second BLOCKS on the token row -- board_invite_tokens FOR UPDATE is the redeem
372
+ # path's first statement -- and by the time it proceeds the revocation has committed, so its
373
+ # own ledger read refuses it. That is why the disclosure says "at most one redemption per
374
+ # outstanding token" and not "one redemption": tokens serialise redemptions of themselves,
375
+ # but a creator holding K links has K independent windows. outstanding_link_uses = 1 in the
376
+ # check row is the byte that says exactly one slipped.
377
+ permutation "revoke_begin" "revoke_agent" "redeem_begin" "redeem_link" "redeem2_begin" "redeem2_link" "revoke_commit" "redeem_commit" "redeem2_commit" "check"
378
+
379
+ # 9. #280, FRESH branch, create first. The revocation must WAIT rather than commit underneath a
380
+ # board creation that has already run its gate. The board stands and the revocation lands
381
+ # after it -- a creation that beat the revocation, the same outcome as a creation a
382
+ # millisecond earlier. What makes the revoker wait here is the seat INSERT's FK KEY SHARE on
383
+ # the actor's agents row, not the gate; recorded so nobody reads this permutation as a lock
384
+ # pin for the FOR SHARE.
385
+ permutation "cboard_begin" "cboard_create" "revoke_begin" "revoke_agent" "cboard_commit" "revoke_commit" "cbcheck"
386
+
387
+ # 10. #280's OWN interleaving, FRESH branch: the revocation is in flight when create_board runs.
388
+ # The creation blocks, then observes the committed revocation and fails closed -- fresh_boards
389
+ # and fresh_seats are both 0, so the boards INSERT, the owner seat and the owner_seed event
390
+ # all rolled back together. Before 20260911000100 this permutation COMPLETED the creation:
391
+ # create_board read no ledger at all, so there was nothing to see. This is the OUTCOME pin.
392
+ permutation "revoke_begin" "revoke_agent" "cboard_begin" "cboard_create" "revoke_commit" "cboard_commit" "cbcheck"
393
+
394
+ # 11. #280, SLUG-COLLISION branch, revoke in flight -- THE LOCK DISCRIMINATOR. The boards UPDATE
395
+ # leaves created_by_agent_id alone, so no FK takes an agents lock and the gate's FOR SHARE is
396
+ # the only one on this path. The reopen blocks on it, then fails closed, leaving the existing
397
+ # board's name and visibility untouched. Drop the FOR SHARE from
398
+ # _assert_actor_not_fleet_revoked and this permutation stops waiting and SUCCEEDS on its own
399
+ # pre-revocation snapshot -- which permutations 9 and 10 cannot show, and neither can the
400
+ # mint path (permutation 5), where the FK always gets there first.
401
+ permutation "revoke_begin" "revoke_agent" "creopen_begin" "creopen_call" "revoke_commit" "creopen_commit" "cbcheck"
402
+
403
+ # 12. ...and the collision branch write-first, for the pair: the reopen is authorized and stands,
404
+ # the revocation waits on the share-locked agents row and lands after it.
405
+ permutation "creopen_begin" "creopen_call" "revoke_begin" "revoke_agent" "creopen_commit" "revoke_commit" "cbcheck"