@consiliency/agent-board-schema 1.22.0 → 1.24.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 (65) hide show
  1. package/README.md +75 -1
  2. package/fixtures/already-applied.json +40 -0
  3. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  4. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  5. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  6. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  7. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  8. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  9. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  10. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  11. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  12. package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
  13. package/package.json +1 -1
  14. package/schema-manifest.json +93 -3
  15. package/src/check.js +23 -0
  16. package/src/index.js +20 -0
  17. package/src/plan.js +79 -5
  18. package/src/tooling.test.mjs +237 -4
  19. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  20. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  21. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  22. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  23. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  24. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  25. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  26. package/tests/agent_board_fedsession.test.sql +46 -2
  27. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  28. package/tests/agent_board_inbox_reads.test.sql +16 -19
  29. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  30. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  31. package/tests/agent_board_roomlink.test.sql +2 -2
  32. package/tests/agent_board_wake_recovery.test.sql +756 -0
  33. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  34. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  35. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  36. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  37. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  38. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  39. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  40. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  41. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  42. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  43. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  44. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  45. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  46. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  47. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  48. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  49. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  50. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  51. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  52. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  53. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  54. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  55. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  56. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  57. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  58. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  59. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  60. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  61. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  62. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  63. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  64. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  65. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,183 @@
1
+ # Consiliency/message-board#196 — onboard_federated_fleet_agent must take the
2
+ # board-keyed ceiling ADVISORY lock BEFORE it locks the boards row.
3
+ #
4
+ # WHAT THIS PINS AND WHY IT IS NOT COVERED ELSEWHERE. This is the #189 ABBA edge:
5
+ # set_board_authority_ceiling goes advisory -> boards (its upsert reaches the
6
+ # boards row as FOR KEY SHARE through board_authority_ceilings' FK), so onboarding
7
+ # must agree. The first cut of 20260901000110 had them inverted and deadlocked
8
+ # deterministically. atomic_fleet_authority_races.spec CANNOT see this -- its own
9
+ # header says so: every permutation there runs each RPC to completion in ONE step,
10
+ # so neither transaction can be paused between its two acquisitions and no cycle
11
+ # can form. That spec passed while the deadlock was live.
12
+ #
13
+ # Until #196 the only guard was a positional pgTAP assertion over RAW function
14
+ # text, which one `--` comment naming the marker defeats. Measured against all 20
15
+ # committed specs, an inverted onboarding (advisory taken after the boards lock)
16
+ # moved NOTHING.
17
+ #
18
+ # HOW THE ORDER IS OBSERVED. A guard session holds the ceiling advisory lock, so
19
+ # onboarding blocks -- `<waiting ...>`. A third session then probes the BOARDS row
20
+ # with `for update nowait`:
21
+ # * CORRECT ORDER: onboarding is parked on the advisory lock and has not reached
22
+ # `select * into v_board ... for update`, so the probe returns the board;
23
+ # * INVERTED ORDER: onboarding already holds the boards row, so the probe fails
24
+ # with `ERROR: could not obtain lock on row in relation "boards"`.
25
+ # The probe never joins the wait graph, so this terminates in BOTH orders instead
26
+ # of reproducing the deadlock it is guarding against.
27
+ #
28
+ # SCOPE. This pins the advisory-before-boards edge ONLY. The boards-lock-before-
29
+ # ceiling-read edge (the FOR KEY SHARE mechanism 20260901000110's header calls the
30
+ # load-bearing one) is pinned elsewhere, and not by this spec -- a mutant that moves
31
+ # only that lock leaves this golden byte-identical. What does catch it, measured:
32
+ # * agent_board_federated_lock_order_class.test.sql EC-3 fails BY NAME, and it
33
+ # already reads a stripped body, so a comment or literal mask does not hide it;
34
+ # * atomic_fleet_authority_races.spec pins the OUTCOME only for the state where
35
+ # BOTH serializers are gone -- its own header records that removing the boards
36
+ # FOR UPDATE alone still passes, because the advisory lock takes over;
37
+ # * atomic_fleet_board_archive.spec BLOCKS on that mutant rather than failing by
38
+ # name, which under the isolation runner (no timeout around isolationtester) is
39
+ # a stuck job, not a diagnosis.
40
+ #
41
+ # MUTATION-VERIFIED: `perform agent_board._lock_board_authority_ceiling(v_inv.board_id)`
42
+ # moved below the boards lock -> this golden changes. Clean schema -> byte-identical
43
+ # across 10 runs.
44
+
45
+ setup
46
+ {
47
+ insert into auth.users (id) values ('19610000-0000-0000-0000-0000000000a1');
48
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
49
+ values ('19610000-0000-0000-0000-000000000001', 'agent', 'agent',
50
+ 'onboard-ceiling-owner', 'Onboard Ceiling Owner', 'active',
51
+ '19610000-0000-0000-0000-0000000000a1');
52
+
53
+ select count(*) from agent_board.create_board(
54
+ '19610000-0000-0000-0000-000000000001',
55
+ 'onboard-ceiling-board', 'Onboard Ceiling Board',
56
+ 'Consiliency/message-board#196 onboarding ceiling lock-order probe',
57
+ jsonb_build_object(), 'private'
58
+ );
59
+ insert into agent_board.fleet_trust_domains(
60
+ id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at
61
+ ) values (
62
+ '19610000-0000-0000-0000-000000000002', 'onboard-ceiling-domain',
63
+ 'Onboard Ceiling Domain', 'tailnet', '19610000-0000-0000-0000-000000000001',
64
+ 'active', now()
65
+ );
66
+ insert into agent_board.fleet_machines(
67
+ id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
68
+ lifecycle_state, enrollment_source, enrolled_at
69
+ ) values (
70
+ '19610000-0000-0000-0000-000000000003',
71
+ '19610000-0000-0000-0000-000000000002',
72
+ '19610000-0000-0000-0000-000000000001',
73
+ 'claw', 'sha256:onboard-ceiling-machine', 'active', 'portal', now()
74
+ );
75
+ insert into agent_board.identity_providers(
76
+ issuer, audience, supported, policy_approved,
77
+ allowed_scopes, allowed_board_scopes, allowed_agent_kinds
78
+ ) values (
79
+ 'https://onboard-ceiling.test', 'message-board', true, true,
80
+ ARRAY['board:send'], ARRAY[]::text[], ARRAY['agent']
81
+ );
82
+ select count(*) from agent_board.create_fleet_onboarding_invite(
83
+ (select id from agent_board.boards where slug='onboard-ceiling-board'),
84
+ '19610000-0000-0000-0000-000000000003', 'codex', 'phase-loop',
85
+ 'message-board', 'sha256:onboard-ceiling-repo', 'worker', 'phase_worker',
86
+ 'onboard-ceiling-agent', 1, now() + interval '1 hour',
87
+ '19610000-0000-0000-0000-000000000001', jsonb_build_object()
88
+ );
89
+ }
90
+
91
+ teardown
92
+ {
93
+ delete from agent_board.fleet_machine_agents where machine_id='19610000-0000-0000-0000-000000000003';
94
+ delete from agent_board.fleet_repo_scopes where harness_registration_id in (
95
+ select id from agent_board.fleet_harness_registrations where machine_id='19610000-0000-0000-0000-000000000003'
96
+ );
97
+ delete from agent_board.fleet_harness_registrations where machine_id='19610000-0000-0000-0000-000000000003';
98
+ delete from agent_board.board_invite_redemptions where invite_id in (
99
+ select id from agent_board.board_invite_tokens where created_by_agent_id='19610000-0000-0000-0000-000000000001'
100
+ );
101
+ truncate agent_board.fleet_onboarding_invite_bounds;
102
+ delete from agent_board.board_invite_tokens where created_by_agent_id='19610000-0000-0000-0000-000000000001';
103
+ delete from agent_board.board_authority_ceilings where board_id in (
104
+ select id from agent_board.boards where slug='onboard-ceiling-board'
105
+ );
106
+ delete from agent_board.board_memberships where board_id in (
107
+ select id from agent_board.boards where slug='onboard-ceiling-board'
108
+ );
109
+ delete from agent_board.enrollment_federation_audit where agent_id in (
110
+ select id from agent_board.agents where handle='onboard-ceiling-agent'
111
+ );
112
+ delete from agent_board.agent_signing_keys where agent_id in (
113
+ select id from agent_board.agents where handle='onboard-ceiling-agent'
114
+ );
115
+ delete from agent_board.agents where handle='onboard-ceiling-agent';
116
+ delete from agent_board.boards where slug='onboard-ceiling-board';
117
+ delete from agent_board.fleet_machines where id='19610000-0000-0000-0000-000000000003';
118
+ delete from agent_board.fleet_trust_domains where id='19610000-0000-0000-0000-000000000002';
119
+ delete from agent_board.agents where id='19610000-0000-0000-0000-000000000001';
120
+ delete from auth.users where id='19610000-0000-0000-0000-0000000000a1';
121
+ delete from agent_board.identity_providers where issuer='https://onboard-ceiling.test';
122
+ }
123
+
124
+ # Holds the SAME board-keyed advisory lock onboarding must take first. It calls the
125
+ # helper rather than re-deriving the key, so a key-derivation change cannot silently
126
+ # make this guard lock a different object.
127
+ session "holder"
128
+ step "holder_begin" { BEGIN; }
129
+ step "hold_ceiling_lock" {
130
+ select agent_board._lock_board_authority_ceiling(
131
+ (select id from agent_board.boards where slug='onboard-ceiling-board')) as locked;
132
+ }
133
+ step "holder_commit" { COMMIT; }
134
+
135
+ session "onboard"
136
+ step "onboard_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
137
+ step "onboard_call" {
138
+ select identity_disposition, membership_disposition, binding_disposition
139
+ from agent_board.onboard_federated_fleet_agent(
140
+ 'https://onboard-ceiling.test', 'message-board', 'human-onboard-ceiling',
141
+ jsonb_build_object('iss','https://onboard-ceiling.test','aud','message-board','sub','human-onboard-ceiling'),
142
+ 'onboard-ceiling-agent',
143
+ 'yd_DIFGWGusVg3SbktDJQ1bE5UWvtR33xln4b05JIhk',
144
+ 'raw_ed25519_base64url',
145
+ 'sha256:NeVC2vx1MbTY56jUwJcLLr6YwB4IKCERsXWbUwhf5pA',
146
+ true, null, 'agent', ARRAY['board:send'], ARRAY[]::text[],
147
+ (select code from agent_board.board_invite_tokens where created_by_agent_id='19610000-0000-0000-0000-000000000001'),
148
+ (select id from agent_board.boards where slug='onboard-ceiling-board'),
149
+ '19610000-0000-0000-0000-000000000003', 'codex', 'phase-loop',
150
+ 'message-board', 'sha256:onboard-ceiling-repo', 'worker', 'phase_worker',
151
+ 'onboard-ceiling-agent'
152
+ );
153
+ }
154
+ step "onboard_commit" { COMMIT; }
155
+
156
+ # NOWAIT probe: witnesses whether the blocked onboarding already holds the boards
157
+ # row. Never blocks, so the permutation terminates in both orders.
158
+ session "probe"
159
+ step "probe_begin" { BEGIN; }
160
+ step "probe_boards_row" {
161
+ select status from agent_board.boards
162
+ where id = (select id from agent_board.boards where slug='onboard-ceiling-board')
163
+ for update nowait;
164
+ }
165
+ step "probe_commit" { COMMIT; }
166
+
167
+ session "check"
168
+ step "check" {
169
+ select
170
+ (select count(*) from agent_board.agents where handle='onboard-ceiling-agent') as onboarded_agents,
171
+ (select count(*) from agent_board.board_memberships bm
172
+ where bm.board_id=(select id from agent_board.boards where slug='onboard-ceiling-board')
173
+ and bm.agent_id in (select id from agent_board.agents where handle='onboard-ceiling-agent')) as memberships;
174
+ }
175
+
176
+ # THE ORDERING PROBE. Onboarding is parked on the advisory lock; the boards row
177
+ # must still be free.
178
+ permutation "holder_begin" "hold_ceiling_lock" "onboard_begin" "onboard_call" "probe_begin" "probe_boards_row" "probe_commit" "holder_commit" "onboard_commit" "check"
179
+
180
+ # Uncontended control: with nobody holding the advisory lock onboarding completes
181
+ # in one step, so a `<waiting ...>` above is attributable to the advisory lock and
182
+ # not to some unrelated serialization.
183
+ permutation "onboard_begin" "onboard_call" "onboard_commit" "probe_begin" "probe_boards_row" "probe_commit" "check"
@@ -0,0 +1,202 @@
1
+ # message-board#187 — the notification outbox has a monotonic write order (`seq`),
2
+ # and the claim RPC uses it instead of a random uuid.
3
+ #
4
+ # WHY THIS IS NOT A pgTAP FILE. Two facts here need SEPARATE COMMITTED SESSIONS and
5
+ # cannot be produced inside one rolled-back transaction:
6
+ #
7
+ # 1. `seq` comes from a NON-TRANSACTIONAL sequence, so a transaction that commits
8
+ # LATER can hold a LOWER seq than one that already committed. That is only
9
+ # observable when two sessions overlap.
10
+ # 2. What a bounded claim TAKES, when several rows share `created_at`, is a
11
+ # cross-session fact about a committed queue — the defect this issue fixes.
12
+ #
13
+ # WHAT THE TRACE PROVES (all four are VALUE diffs, not just trace diffs):
14
+ #
15
+ # * f_report — a fan-out to 8 subscribers, one statement, one transaction, writes 8
16
+ # rows with ONE distinct `created_at` and 8 distinct, CONTIGUOUS `seq`. `now()` is
17
+ # `transaction_timestamp()`, so time cannot order this set; `seq` can.
18
+ # * c_first — a claim of 4 takes exactly `fan_base .. fan_base+3`, IN THAT ORDER.
19
+ # Under the old `available_at, created_at, id` ordering the tiebreak was a random
20
+ # v4 uuid, so both the set (1 in 70) and the order (1 in 24) were chance.
21
+ # * c_second — after the late session commits, the next claim returns its row FIRST,
22
+ # because its `seq` was allocated BEFORE the fan-out's even though it committed
23
+ # after.
24
+ # * The same c_second line records the CONSEQUENCE for a consumer design: a
25
+ # late-committing writer lands BELOW the high-water mark a naive `seq`-cursor
26
+ # consumer would already have persisted. The claim RPC is unaffected — it is
27
+ # status-driven, not cursor-driven — but the wake contract this issue unblocks
28
+ # must not assume "seq greater than my cursor" is sufficient on its own.
29
+ #
30
+ # MUTATION SIGNAL: restore `order by coe.available_at asc, coe.created_at asc,
31
+ # coe.id asc` in claim_callback_outbox_events and both c_first and c_second flip from
32
+ # `t` to `f` (verified). Note what this spec does NOT pin: deleting `order by u.seq asc`
33
+ # from the RETURNED batch changes nothing here, because the plan feeds the ordered
34
+ # candidate CTE straight through and the observed order stays ascending anyway. That
35
+ # property is pinned as a source assertion in agent_board_outbox_wake_ordering.test.sql
36
+ # (WO-4 "RETURNED ordered by seq"), which is where the mutation actually lands.
37
+
38
+ setup
39
+ {
40
+ create table public.iso187_marks(kind text primary key, val bigint);
41
+
42
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
43
+ values ('18700000-0000-4000-8000-000000000000', 'agent', 'iso187-sender', 'Iso187 Sender', 'active');
44
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
45
+ select ('18700000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
46
+ 'agent', 'iso187-sub-' || n, 'Iso187 Subscriber ' || n, 'active'
47
+ from generate_series(1, 8) n;
48
+
49
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
50
+ values ('18701000-0000-4000-8000-00000000000a', 'iso187-board-a', 'Iso187 Board A',
51
+ '18700000-0000-4000-8000-000000000000', 'open', 'active'),
52
+ ('18701000-0000-4000-8000-00000000000b', 'iso187-board-b', 'Iso187 Board B',
53
+ '18700000-0000-4000-8000-000000000000', 'open', 'active');
54
+
55
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
56
+ values ('18702000-0000-4000-8000-00000000000a', 'coordination',
57
+ '18700000-0000-4000-8000-000000000000', 'Iso187 thread A',
58
+ '18701000-0000-4000-8000-00000000000a'),
59
+ ('18702000-0000-4000-8000-00000000000b', 'coordination',
60
+ '18700000-0000-4000-8000-000000000000', 'Iso187 thread B',
61
+ '18701000-0000-4000-8000-00000000000b');
62
+
63
+ insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
64
+ values ('18703000-0000-4000-8000-00000000000a', '18702000-0000-4000-8000-00000000000a',
65
+ '18700000-0000-4000-8000-000000000000', 1, 'comment', 'iso187 board A post'),
66
+ ('18703000-0000-4000-8000-00000000000b', '18702000-0000-4000-8000-00000000000b',
67
+ '18700000-0000-4000-8000-000000000000', 1, 'comment', 'iso187 board B post');
68
+
69
+ insert into agent_board.board_memberships(id, board_id, agent_id, role, membership_state, created_by_agent_id)
70
+ select ('18704000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
71
+ '18701000-0000-4000-8000-00000000000a',
72
+ ('18700000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
73
+ 'member', 'joined', '18700000-0000-4000-8000-000000000000'
74
+ from generate_series(1, 8) n;
75
+ insert into agent_board.board_memberships(id, board_id, agent_id, role, membership_state, created_by_agent_id)
76
+ values ('18704000-0000-4000-8000-0000000000b1', '18701000-0000-4000-8000-00000000000b',
77
+ '18700000-0000-4000-8000-000000000001', 'member', 'joined',
78
+ '18700000-0000-4000-8000-000000000000');
79
+
80
+ insert into agent_board.callback_endpoints(id, owner_agent_id, endpoint_url, endpoint_status, verification_requested_at, verified_at)
81
+ select ('18705000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
82
+ ('18700000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
83
+ 'https://example.test/iso187/' || n, 'verified', now(), now()
84
+ from generate_series(1, 8) n;
85
+
86
+ insert into agent_board.callback_subscriptions(
87
+ id, endpoint_id, agent_id, subscription_scope, event_category,
88
+ board_id, board_membership_id, subscription_status)
89
+ select ('18706000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
90
+ ('18705000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
91
+ ('18700000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
92
+ 'board', 'board.post.delivered',
93
+ '18701000-0000-4000-8000-00000000000a',
94
+ ('18704000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid,
95
+ 'active'
96
+ from generate_series(1, 8) n;
97
+ insert into agent_board.callback_subscriptions(
98
+ id, endpoint_id, agent_id, subscription_scope, event_category,
99
+ board_id, board_membership_id, subscription_status)
100
+ values ('18706000-0000-4000-8000-0000000000b1', '18705000-0000-4000-8000-000000000001',
101
+ '18700000-0000-4000-8000-000000000001', 'board', 'board.post.delivered',
102
+ '18701000-0000-4000-8000-00000000000b',
103
+ '18704000-0000-4000-8000-0000000000b1', 'active');
104
+
105
+ -- claim_callback_outbox_events polls the GLOBAL ready queue. Neutralize anything the
106
+ -- template left ready so this spec controls what a bounded claim can take.
107
+ update agent_board.callback_outbox_events
108
+ set outbox_status = 'delivered', delivered_at = now(), updated_at = now()
109
+ where outbox_status in ('pending', 'retryable');
110
+ }
111
+
112
+ teardown
113
+ {
114
+ delete from agent_board.callback_outbox_events where board_id in ('18701000-0000-4000-8000-00000000000a', '18701000-0000-4000-8000-00000000000b');
115
+ delete from agent_board.callback_subscriptions where board_id in ('18701000-0000-4000-8000-00000000000a', '18701000-0000-4000-8000-00000000000b');
116
+ delete from agent_board.callback_endpoints where endpoint_url like 'https://example.test/iso187/%';
117
+ delete from agent_board.board_memberships where board_id in ('18701000-0000-4000-8000-00000000000a', '18701000-0000-4000-8000-00000000000b');
118
+ delete from agent_board.messages where thread_id in ('18702000-0000-4000-8000-00000000000a', '18702000-0000-4000-8000-00000000000b');
119
+ delete from agent_board.threads where id in ('18702000-0000-4000-8000-00000000000a', '18702000-0000-4000-8000-00000000000b');
120
+ delete from agent_board.boards where id in ('18701000-0000-4000-8000-00000000000a', '18701000-0000-4000-8000-00000000000b');
121
+ delete from agent_board.agents where handle like 'iso187-%';
122
+ drop table public.iso187_marks;
123
+ }
124
+
125
+ # "late" writes FIRST and commits LAST. Its row therefore holds a seq BELOW every row
126
+ # the fan-out writes, while becoming visible only after the fan-out is already claimed.
127
+ session "late"
128
+ step "l_begin" { BEGIN; }
129
+ step "l_insert" {
130
+ select agent_board._queue_board_post_callbacks(
131
+ '18701000-0000-4000-8000-00000000000b'::uuid,
132
+ '18703000-0000-4000-8000-00000000000b'::uuid,
133
+ 'Iso187 late', 'Iso187 late preview', 'high', jsonb_build_object()) as late_rows_written;
134
+ }
135
+ step "l_mark" {
136
+ insert into public.iso187_marks(kind, val)
137
+ select 'late_seq', min(seq) from agent_board.callback_outbox_events
138
+ where board_id = '18701000-0000-4000-8000-00000000000b';
139
+ }
140
+ step "l_commit" { COMMIT; }
141
+
142
+ # "fan" is the multicast fan-out: ONE statement, ONE transaction, eight recipients.
143
+ session "fan"
144
+ step "f_begin" { BEGIN; }
145
+ step "f_fanout" {
146
+ select agent_board._queue_board_post_callbacks(
147
+ '18701000-0000-4000-8000-00000000000a'::uuid,
148
+ '18703000-0000-4000-8000-00000000000a'::uuid,
149
+ 'Iso187 fanout', 'Iso187 fanout preview', 'high', jsonb_build_object()) as fanout_rows_written;
150
+ }
151
+ step "f_mark" {
152
+ insert into public.iso187_marks(kind, val)
153
+ select 'fan_base', min(seq) from agent_board.callback_outbox_events
154
+ where board_id = '18701000-0000-4000-8000-00000000000a';
155
+ }
156
+ # The heart of the issue: identical created_at across the whole fan-out, distinct and
157
+ # contiguous seq. Absolute seq values are never printed — they depend on how many rows
158
+ # the template ever wrote — so the golden stays deterministic.
159
+ step "f_report" {
160
+ select count(*) as rows_written,
161
+ count(distinct created_at) as distinct_created_at,
162
+ count(distinct seq) as distinct_seq,
163
+ (max(seq) - min(seq) + 1) as seq_span
164
+ from agent_board.callback_outbox_events
165
+ where board_id = '18701000-0000-4000-8000-00000000000a';
166
+ }
167
+ step "f_commit" { COMMIT; }
168
+
169
+ # "claimer" is the notification worker. Autocommit: each claim is its own transaction,
170
+ # exactly as a worker loop behaves.
171
+ session "claimer"
172
+ # The late row is still uncommitted here, so the visible queue is the fan-out alone.
173
+ # The claim must take fan_base..fan_base+3 IN ASCENDING ORDER — set AND order.
174
+ step "c_first" {
175
+ select (select array_agg(outbox_seq) from agent_board.claim_callback_outbox_events(4))
176
+ = (select array_agg(g order by g)
177
+ from generate_series((select val from public.iso187_marks where kind = 'fan_base'),
178
+ (select val from public.iso187_marks where kind = 'fan_base') + 3) g)
179
+ as claimed_lowest_four_in_seq_order;
180
+ }
181
+ # The late row is committed now. It sorts FIRST despite committing last, and it sits
182
+ # BELOW the high-water mark a naive seq-cursor consumer would have persisted from the
183
+ # first claim — the boundary the wake contract must respect.
184
+ step "c_second" {
185
+ with claimed as (
186
+ select array_agg(outbox_seq) as got from agent_board.claim_callback_outbox_events(4)
187
+ ),
188
+ marks as (
189
+ select (select val from public.iso187_marks where kind = 'late_seq') as late_seq,
190
+ (select val from public.iso187_marks where kind = 'fan_base') as fan_base
191
+ )
192
+ select (select got from claimed)
193
+ = array[(select late_seq from marks)]
194
+ || (select array_agg(g order by g)
195
+ from generate_series((select fan_base from marks) + 4,
196
+ (select fan_base from marks) + 6) g)
197
+ as late_row_claimed_first_then_next_fan_rows,
198
+ (select late_seq from marks) < (select fan_base from marks)
199
+ as late_committer_holds_a_lower_seq_than_the_persisted_cursor;
200
+ }
201
+
202
+ permutation "l_begin" "l_insert" "l_mark" "f_begin" "f_fanout" "f_mark" "f_report" "f_commit" "c_first" "l_commit" "c_second"
@@ -0,0 +1,207 @@
1
+ # message-board#191 — a late-committing writer lands BELOW a naive consumer's
2
+ # persisted cursor, and is STILL recovered by the status-partitioned wake surface.
3
+ #
4
+ # WHY THIS IS NOT A pgTAP FILE. The fact under test is cross-session and needs two
5
+ # SEPARATE COMMITTED transactions with a specific interleaving: a writer that
6
+ # STARTS EARLY (so its `created_at` is low — `now()` is `transaction_timestamp()`,
7
+ # assigned at transaction start) and COMMITS LATE (so it becomes visible only
8
+ # after a consumer has already read and persisted a high-water mark). A pgTAP file
9
+ # runs inside one rolled-back transaction and cannot produce that interleaving at
10
+ # all.
11
+ #
12
+ # WHAT THE TRACE PROVES (all VALUE diffs, not merely trace diffs):
13
+ #
14
+ # * c_first — while `late` is still uncommitted, the consumer sees exactly ONE
15
+ # unhandled item, and it is `mid`'s. This is what makes the cursor it then
16
+ # persists a HIGH-water mark that sits ABOVE the late row.
17
+ # * c_second, column 1 (`late_committer_recovered_by_status`) — the late item IS
18
+ # in `agent_board.v_agent_wake_recovery` the moment it is visible. Nothing was
19
+ # dropped. This is the fix.
20
+ # * c_second, column 2 (`late_committer_skipped_by_timestamp_cursor`) — the late
21
+ # item's `created_at` is NOT above the persisted cursor. This is the defect,
22
+ # measured rather than asserted.
23
+ # * c_second, column 3 (`a_created_at_cursor_read_returns_nothing_new`) — the
24
+ # actual query a naive cursor consumer would run (`created_at > my_cursor`)
25
+ # returns NOTHING, at the same instant the status surface returns the late
26
+ # row. Columns 1 and 3 together are the whole issue in one line.
27
+ #
28
+ # WHY A TIMESTAMP AND NOT `seq`. The sibling probe outbox_wake_ordering.spec
29
+ # already records the `seq` half (`late_committer_holds_a_lower_seq_than_the_
30
+ # persisted_cursor = t`) on `callback_outbox_events`. The surface a wake consumer
31
+ # actually fetches from — the inbox — has NO monotonic column at all; its only
32
+ # ordering-shaped columns are `created_at` / `updated_at`. So the timestamp branch
33
+ # is the live-shaped one, and it is strictly worse than `seq`: a fan-out writes N
34
+ # rows with byte-identical `created_at`, so the cursor cannot even totally order
35
+ # what it DOES see. The unsafe class is "gates on any value assigned before
36
+ # commit", not "gates on seq".
37
+ #
38
+ # MUTATION SIGNALS (each verified):
39
+ # * Remove 'new' from the view's state set in
40
+ # 20260904000100_agent_board_wake_recovery_surface.sql
41
+ # -> `late_committer_recovered_by_status` flips t -> f.
42
+ # * Redefine the view with a `created_at >` cursor filter
43
+ # -> the same column flips t -> f (and pgTAP WR-4/WR-7 go red by name).
44
+ # * Swap the transaction START order in the permutation below so `mid` begins
45
+ # and inserts BEFORE `late` begins -> `late_committer_skipped_by_timestamp_
46
+ # cursor` and `a_created_at_cursor_read_returns_nothing_new` flip t -> f,
47
+ # proving the `t` was earned by the interleaving and not by construction.
48
+ #
49
+ # IDENTITY. The consumer session binds `request.jwt.claim.sub` to the recipient's
50
+ # auth user (step `c_auth`), because the view scopes on `current_agent_id()`. This
51
+ # is load-bearing, not ceremony: without it the view returns nothing, every column
52
+ # below reads `f`, and the probe would look like a clean failure of the fix rather
53
+ # than a fixture that never had an identity.
54
+ #
55
+ # WHAT THIS SPEC DOES NOT COVER: role-level RLS. Sessions run as the superuser, so
56
+ # the inbox_items policies are bypassed and only the view's own scoping applies;
57
+ # this probe owns visibility-vs-commit-order. `security_invoker`, the grant set,
58
+ # and cross-agent isolation on the same view are pinned in
59
+ # agent_board_wake_recovery.test.sql (WR-2, WR-3, WR-6). Naming which probe owns
60
+ # which property stops each from being assumed to cover the other.
61
+ #
62
+ # Absolute timestamps and ids are never printed — only derived booleans — so the
63
+ # golden is deterministic.
64
+
65
+ setup
66
+ {
67
+ create table public.iso191_marks(kind text primary key, val timestamptz);
68
+
69
+ -- v_agent_wake_recovery scopes on current_agent_id(), which resolves auth.uid()
70
+ -- from the request.jwt.claim.sub GUC. Without a bound identity the view returns
71
+ -- NOTHING and this probe would measure an empty set while looking like a pass --
72
+ -- so the recipient gets a real auth user and the consumer session binds to it.
73
+ insert into auth.users(id, email) values
74
+ ('19116000-0000-4000-8000-000000000002', 'iso191-recipient@example.test');
75
+
76
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id, owner_user_id)
77
+ values ('19110000-0000-4000-8000-000000000001', 'agent', 'iso191-sender', 'Iso191 Sender', 'active', null, null),
78
+ ('19110000-0000-4000-8000-000000000002', 'agent', 'iso191-recipient', 'Iso191 Recipient', 'active',
79
+ '19116000-0000-4000-8000-000000000002', '19116000-0000-4000-8000-000000000002');
80
+
81
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
82
+ values ('19111000-0000-4000-8000-000000000001', 'iso191-board', 'Iso191 Board',
83
+ '19110000-0000-4000-8000-000000000001', 'open', 'active');
84
+
85
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject, board_id)
86
+ values ('19112000-0000-4000-8000-000000000001', 'coordination',
87
+ '19110000-0000-4000-8000-000000000001', 'Iso191 thread',
88
+ '19111000-0000-4000-8000-000000000001');
89
+
90
+ -- One message per inbox item: delivery_recipients is UNIQUE on
91
+ -- (recipient_agent_id, canonical_source_key).
92
+ insert into agent_board.messages(id, thread_id, author_agent_id, message_sequence, message_type, body)
93
+ values ('19113000-0000-4000-8000-00000000000e', '19112000-0000-4000-8000-000000000001',
94
+ '19110000-0000-4000-8000-000000000001', 1, 'comment', 'iso191 late message'),
95
+ ('19113000-0000-4000-8000-00000000000d', '19112000-0000-4000-8000-000000000001',
96
+ '19110000-0000-4000-8000-000000000001', 2, 'comment', 'iso191 mid message');
97
+
98
+ -- Scope by the test-only recipient, never a repo-wide count: an unscoped count
99
+ -- is polluted by earlier fixtures and fails looking like a real defect. The
100
+ -- recipient is brand new here, so this neutralisation is belt-and-braces — it
101
+ -- states the invariant the later counts rely on instead of assuming it.
102
+ update agent_board.inbox_items
103
+ set action_state = 'acknowledged', updated_at = now()
104
+ where recipient_agent_id = '19110000-0000-4000-8000-000000000002'
105
+ and action_state in ('new', 'restored', 'deferred');
106
+ }
107
+
108
+ teardown
109
+ {
110
+ delete from agent_board.inbox_items where recipient_agent_id = '19110000-0000-4000-8000-000000000002';
111
+ delete from agent_board.delivery_recipients where recipient_agent_id = '19110000-0000-4000-8000-000000000002';
112
+ delete from agent_board.messages where thread_id = '19112000-0000-4000-8000-000000000001';
113
+ delete from agent_board.threads where id = '19112000-0000-4000-8000-000000000001';
114
+ delete from agent_board.boards where id = '19111000-0000-4000-8000-000000000001';
115
+ delete from agent_board.agents where handle like 'iso191-%';
116
+ drop table public.iso191_marks;
117
+ }
118
+
119
+ # "late" STARTS FIRST and COMMITS LAST. `created_at` defaults to now(), which is
120
+ # transaction_timestamp(), so its row carries a timestamp BELOW everything that
121
+ # commits in between — while becoming visible only after the consumer has already
122
+ # read.
123
+ session "late"
124
+ step "l_begin" { BEGIN; }
125
+ step "l_insert" {
126
+ with dr as (
127
+ insert into agent_board.delivery_recipients(id, delivery_mode, recipient_agent_id, source_kind, source_message_id)
128
+ values ('19114000-0000-4000-8000-00000000000e', 'direct',
129
+ '19110000-0000-4000-8000-000000000002', 'message',
130
+ '19113000-0000-4000-8000-00000000000e')
131
+ returning id
132
+ )
133
+ insert into agent_board.inbox_items(
134
+ id, delivery_recipient_id, recipient_agent_id, delivery_mode, source_kind, source_message_id,
135
+ subject, preview, priority, action_state, visible_in_inbox, visible_in_board_feed, fetch_descriptor)
136
+ select '19115000-0000-4000-8000-00000000000e', dr.id,
137
+ '19110000-0000-4000-8000-000000000002', 'direct', 'message',
138
+ '19113000-0000-4000-8000-00000000000e',
139
+ 'Iso191 late', 'iso191 late preview', 'high', 'new', true, false, jsonb_build_object()
140
+ from dr;
141
+ }
142
+ step "l_mark" {
143
+ insert into public.iso191_marks(kind, val)
144
+ select 'late_created_at', created_at from agent_board.inbox_items
145
+ where id = '19115000-0000-4000-8000-00000000000e';
146
+ }
147
+ step "l_commit" { COMMIT; }
148
+
149
+ # "mid" starts after "late" and commits before it. Its row is what lifts the
150
+ # consumer's high-water mark above the late row.
151
+ session "mid"
152
+ step "m_begin" { BEGIN; }
153
+ step "m_insert" {
154
+ with dr as (
155
+ insert into agent_board.delivery_recipients(id, delivery_mode, recipient_agent_id, source_kind, source_message_id)
156
+ values ('19114000-0000-4000-8000-00000000000d', 'direct',
157
+ '19110000-0000-4000-8000-000000000002', 'message',
158
+ '19113000-0000-4000-8000-00000000000d')
159
+ returning id
160
+ )
161
+ insert into agent_board.inbox_items(
162
+ id, delivery_recipient_id, recipient_agent_id, delivery_mode, source_kind, source_message_id,
163
+ subject, preview, priority, action_state, visible_in_inbox, visible_in_board_feed, fetch_descriptor)
164
+ select '19115000-0000-4000-8000-00000000000d', dr.id,
165
+ '19110000-0000-4000-8000-000000000002', 'direct', 'message',
166
+ '19113000-0000-4000-8000-00000000000d',
167
+ 'Iso191 mid', 'iso191 mid preview', 'high', 'new', true, false, jsonb_build_object()
168
+ from dr;
169
+ }
170
+ step "m_commit" { COMMIT; }
171
+
172
+ # "consumer" is the wake worker. Autocommit: each read is its own transaction,
173
+ # exactly as a worker loop behaves.
174
+ session "consumer"
175
+ # Session-level (is_local = false) so the binding survives across this session's
176
+ # autocommit statements.
177
+ step "c_auth" {
178
+ select set_config('request.jwt.claim.sub', '19116000-0000-4000-8000-000000000002', false)
179
+ = '19116000-0000-4000-8000-000000000002' as consumer_session_bound_to_recipient;
180
+ }
181
+ step "c_first" {
182
+ select count(*) = 1 as first_read_sees_one_unhandled_item,
183
+ bool_and(w.inbox_item_id = '19115000-0000-4000-8000-00000000000d') as the_visible_item_is_the_committed_one
184
+ from agent_board.v_agent_wake_recovery w
185
+ where w.recipient_agent_id = '19110000-0000-4000-8000-000000000002';
186
+ }
187
+ # The high-water mark a naive cursor consumer would persist from that first read.
188
+ step "c_persist" {
189
+ insert into public.iso191_marks(kind, val)
190
+ select 'naive_cursor', max(created_at) from agent_board.v_agent_wake_recovery
191
+ where recipient_agent_id = '19110000-0000-4000-8000-000000000002';
192
+ }
193
+ step "c_second" {
194
+ select exists (select 1 from agent_board.v_agent_wake_recovery w
195
+ where w.recipient_agent_id = '19110000-0000-4000-8000-000000000002'
196
+ and w.inbox_item_id = '19115000-0000-4000-8000-00000000000e')
197
+ as late_committer_recovered_by_status,
198
+ (select l.val from public.iso191_marks l where l.kind = 'late_created_at')
199
+ <= (select c.val from public.iso191_marks c where c.kind = 'naive_cursor')
200
+ as late_committer_skipped_by_timestamp_cursor,
201
+ not exists (select 1 from agent_board.v_agent_wake_recovery w
202
+ where w.recipient_agent_id = '19110000-0000-4000-8000-000000000002'
203
+ and w.created_at > (select c.val from public.iso191_marks c where c.kind = 'naive_cursor'))
204
+ as a_created_at_cursor_read_returns_nothing_new;
205
+ }
206
+
207
+ permutation "c_auth" "l_begin" "l_insert" "l_mark" "m_begin" "m_insert" "m_commit" "c_first" "c_persist" "l_commit" "c_second"