@consiliency/agent-board-schema 1.12.0 → 1.23.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 (156) hide show
  1. package/fixtures/already-applied.json +160 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  34. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  35. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  36. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  37. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  38. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  39. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  40. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  41. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  42. package/package.json +1 -1
  43. package/schema-manifest.json +363 -3
  44. package/src/tooling.test.mjs +113 -2
  45. package/tests/agent_board_actorspoof.test.sql +349 -0
  46. package/tests/agent_board_agentsdir.test.sql +154 -0
  47. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  48. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  49. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  50. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  51. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  52. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  53. package/tests/agent_board_claimkey.test.sql +229 -0
  54. package/tests/agent_board_consent_backfill.test.sql +48 -0
  55. package/tests/agent_board_consent_emission.test.sql +601 -0
  56. package/tests/agent_board_consent_gating.test.sql +489 -0
  57. package/tests/agent_board_consent_substrate.test.sql +720 -0
  58. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  59. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  60. package/tests/agent_board_delegation_policies.test.sql +107 -1
  61. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  62. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  63. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  64. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  65. package/tests/agent_board_fedsession.test.sql +51 -5
  66. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  67. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  68. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  69. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  70. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  71. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  72. package/tests/agent_board_fleet_policy.test.sql +2 -2
  73. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  74. package/tests/agent_board_identity_providers.test.sql +9 -0
  75. package/tests/agent_board_inbox_reads.test.sql +45 -31
  76. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  77. package/tests/agent_board_invitepolicy.test.sql +205 -0
  78. package/tests/agent_board_leaveidem.test.sql +194 -0
  79. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  80. package/tests/agent_board_mint_harden.test.sql +303 -0
  81. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  82. package/tests/agent_board_obsgate.test.sql +178 -0
  83. package/tests/agent_board_obsgate2.test.sql +153 -0
  84. package/tests/agent_board_obsgate3.test.sql +170 -0
  85. package/tests/agent_board_obsgate4.test.sql +196 -0
  86. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  87. package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
  88. package/tests/agent_board_pubexec_class.test.sql +53 -0
  89. package/tests/agent_board_rejectsem.test.sql +271 -0
  90. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  91. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  92. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  93. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  94. package/tests/agent_board_rolereset.test.sql +351 -0
  95. package/tests/agent_board_roomdeleg.test.sql +5 -3
  96. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  97. package/tests/agent_board_roomlink.test.sql +27 -11
  98. package/tests/agent_board_rowid.test.sql +1274 -0
  99. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  100. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  101. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  102. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  103. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  104. package/tests/agent_board_signing_store.test.sql +6 -6
  105. package/tests/agent_board_wake_recovery.test.sql +756 -0
  106. package/tests/fixtures/isolation-substrate.sql +93 -0
  107. package/tests/fixtures/test-prereqs.sql +33 -0
  108. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  109. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  110. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  111. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  112. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  113. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  114. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  115. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  116. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  117. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  118. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  119. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  120. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  121. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  122. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  123. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  124. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  125. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  126. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  127. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  128. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  129. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  130. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  131. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  132. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  133. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  134. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  135. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  136. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  137. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  138. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  139. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  140. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  141. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  142. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  143. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  144. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  145. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  146. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  147. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  148. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  149. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  150. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  151. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  152. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  153. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  154. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  155. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
  156. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,63 @@
1
+ # Consiliency/message-board#193 — enroll_federated_human, with the provider ABSENT,
2
+ # must fail BEFORE it takes the auth.users owner lock.
3
+ #
4
+ # WHY A LOCK TRACE AND NOT A RETURN VALUE. The defect is an ORDER, and the outcome
5
+ # of the absent-provider call is 42501 either way (pre-fix from the delegate's
6
+ # step (4), post-fix from the pre-lock gate). What differs is what the transaction
7
+ # is HOLDING when it gets there. A guard session holds the owner's auth.users row,
8
+ # so:
9
+ # * PRE-FIX the call blocks -- isolationtester prints `<waiting ...>` -- which is
10
+ # the observable form of "it took the owner lock while the provider ordering was
11
+ # skipped entirely". That is the state from which the delegate, on its fresh READ
12
+ # COMMITTED snapshot, could see a concurrently-committed provider and reach its
13
+ # default board while still holding the owner: owner -> board, inverted against
14
+ # fleet onboarding's board -> owner.
15
+ # * POST-FIX it raises immediately, with NO `<waiting ...>` line at all.
16
+ # Re-introducing the skip therefore changes the golden. Mutation-verified both ways.
17
+ #
18
+ # The owner row is materialized directly through _ensure_agent_auth_user for an
19
+ # (issuer, subject) whose provider NEVER exists, which is the shape the existing
20
+ # atomic_fedsession_fleet_lock_order fixture cannot express: it creates the provider
21
+ # and the human before concurrency begins.
22
+
23
+ setup
24
+ {
25
+ create table lo193_owner as
26
+ 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;
31
+ }
32
+
33
+ # Holds the human owner row that the absent-provider path must never reach.
34
+ session "ohold"
35
+ step "ohold_begin" { BEGIN; }
36
+ step "ohold_lock" {
37
+ select 1 as locked from auth.users u
38
+ where u.id = (select uid from lo193_owner)
39
+ for update;
40
+ }
41
+ step "ohold_commit" { COMMIT; }
42
+
43
+ session "portal"
44
+ step "portal_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
45
+ step "portal_enroll" {
46
+ select status from agent_board.enroll_federated_human(
47
+ 'https://lo193-iso.test', 'message-board', 'iso-orphan',
48
+ jsonb_build_object('sub','iso-orphan'), 'Iso Orphan'
49
+ );
50
+ }
51
+ step "portal_commit" { COMMIT; }
52
+
53
+ session "check"
54
+ step "check" {
55
+ select
56
+ (select count(*) from agent_board.agents a
57
+ where a.kind = 'human'
58
+ and a.metadata #>> ARRAY['federation','subject'] = 'iso-orphan') as human_agents,
59
+ (select count(*) from agent_board.identity_providers ip
60
+ where ip.issuer = 'https://lo193-iso.test') as providers;
61
+ }
62
+
63
+ permutation "ohold_begin" "ohold_lock" "portal_begin" "portal_enroll" "ohold_commit" "portal_commit" "check"
@@ -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,115 @@
1
+ # #146 MUST-FIX 3 (concurrency) — reactivate_fleet_machine serializes against a concurrent
2
+ # revoke, and re-checks the revoked precondition UNDER a row lock.
3
+ #
4
+ # reactivate_fleet_machine's read-decide-write was unconditional and unlocked: a revoke that
5
+ # committed mid-operation could be silently overwritten (machine ends active with reactivated_at
6
+ # newer than the revoke's event — the revoke, possibly a security response, is lost). The fix
7
+ # takes `select ... for update` on the fleet_machines row before the still-revoked re-check, so
8
+ # the whole operation serializes against revoke_fleet_subject (whose fleet_machines UPDATE also
9
+ # locks that row).
10
+ #
11
+ # Two permutations, both service_role (reactivate is operator-only; revoke is operator here):
12
+ #
13
+ # P1 — reactivate-vs-revoke (the panel's requested pin). reactivate takes the lock and holds
14
+ # it; the concurrent revoke BLOCKS (isolationtester prints `<waiting ...>`), then applies after
15
+ # reactivate commits. FINAL: the machine is revoked and evaluate denies (machine_revoked) — the
16
+ # revoke is NOT lost. Combined with MUST-FIX 1 (revoke advances effective_at=now()), the late
17
+ # revoke both flips lifecycle AND post-dates reactivated_at, so both guards deny.
18
+ #
19
+ # P2 — reactivate-vs-reactivate (the for-update MUTATION signal). The second reactivate blocks
20
+ # on the first's lock, then RE-READS under the lock: the machine is now active, so its
21
+ # still-revoked re-check fails and it RAISES `fleet machine is not revoked; nothing to
22
+ # reactivate`. MUTATION SIGNAL: drop `for update` from reactivate's read → the second reactivate
23
+ # reads the stale (pre-commit) revoked snapshot, its re-check passes, and it succeeds instead of
24
+ # raising — the `<waiting>`/error trace diverges from this golden.
25
+
26
+ setup
27
+ {
28
+ insert into agent_board.agents(id, agent_type, handle, display_name, status) values
29
+ ('a0000000-0000-4000-8000-000000000001', 'agent', 'react-iso-owner', 'React Iso Owner', 'active'),
30
+ ('a0000000-0000-4000-8000-000000000002', 'agent', 'react-iso-sender', 'React Iso Sender', 'active'),
31
+ ('a0000000-0000-4000-8000-000000000003', 'agent', 'react-iso-recip', 'React Iso Recipient', 'active');
32
+
33
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
34
+ values ('d0000000-0000-4000-8000-000000000001', 'react-iso-domain', 'React Iso Domain', 'tailnet',
35
+ 'a0000000-0000-4000-8000-000000000001', 'active', now());
36
+
37
+ -- A machine that is already revoked (with a PRIOR machine-revocation event, effective 1h ago).
38
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
39
+ lifecycle_state, enrollment_source, enrolled_at, revoked_at, reactivated_at, metadata)
40
+ values ('60000000-0000-4000-8000-000000000001', 'd0000000-0000-4000-8000-000000000001',
41
+ 'a0000000-0000-4000-8000-000000000001', 'react-iso-m', 'sha256:react-iso-m',
42
+ 'revoked', 'portal', now(), now(), null, jsonb_build_object('redacted', true, 'metadata-safe', true));
43
+
44
+ insert into agent_board.fleet_machine_agents(machine_id, agent_id, agent_role)
45
+ values ('60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000002', 'phase_worker');
46
+
47
+ insert into agent_board.fleet_revocation_events(id, subject_kind, subject_id, machine_id, revoked_by_agent_id, revocation_reason, effective_at, metadata)
48
+ values ('e0000000-0000-4000-8000-000000000001', 'machine', '60000000-0000-4000-8000-000000000001', '60000000-0000-4000-8000-000000000001',
49
+ 'a0000000-0000-4000-8000-000000000001', 'iso initial revoke', now() - interval '1 hour',
50
+ jsonb_build_object('redacted', true, 'metadata-safe', true));
51
+
52
+ -- Allow-by-default receive policy so a non-revoked bound machine would pass; the only lever
53
+ -- the checker exercises is the machine-revocation state.
54
+ insert into agent_board.fleet_receive_policies(recipient_agent_id, policy_name, default_action, require_signed_sender, lifecycle_state, metadata)
55
+ values ('a0000000-0000-4000-8000-000000000003', 'react iso allow', 'allow', false, 'active', jsonb_build_object());
56
+ }
57
+
58
+ teardown
59
+ {
60
+ delete from agent_board.fleet_revocation_events where subject_id = '60000000-0000-4000-8000-000000000001';
61
+ delete from agent_board.fleet_machine_agents where machine_id = '60000000-0000-4000-8000-000000000001';
62
+ delete from agent_board.fleet_machines where id = '60000000-0000-4000-8000-000000000001';
63
+ delete from agent_board.fleet_receive_policies where recipient_agent_id = 'a0000000-0000-4000-8000-000000000003';
64
+ delete from agent_board.fleet_trust_domains where id = 'd0000000-0000-4000-8000-000000000001';
65
+ delete from agent_board.agents where id in (
66
+ 'a0000000-0000-4000-8000-000000000001',
67
+ 'a0000000-0000-4000-8000-000000000002',
68
+ 'a0000000-0000-4000-8000-000000000003');
69
+ }
70
+
71
+ # ra: the reactivation. Its `select ... for update` locks the machine row until ra commits.
72
+ session "ra"
73
+ step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
74
+ step "ra_react" {
75
+ select lifecycle_state
76
+ from agent_board.reactivate_fleet_machine(
77
+ '60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001', 'iso reactivate');
78
+ }
79
+ step "ra_commit" { COMMIT; }
80
+
81
+ # rb: a competing writer — either a revoke (P1) or a second reactivate (P2). Its row write
82
+ # blocks on ra's lock until ra commits.
83
+ session "rb"
84
+ step "rb_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
85
+ step "rb_revoke" {
86
+ select revocation_event_id
87
+ from agent_board.revoke_fleet_subject(
88
+ 'machine', '60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001',
89
+ 'iso re-revoke', jsonb_build_object('redacted', true));
90
+ }
91
+ step "rb_react" {
92
+ select lifecycle_state
93
+ from agent_board.reactivate_fleet_machine(
94
+ '60000000-0000-4000-8000-000000000001', 'a0000000-0000-4000-8000-000000000001', 'iso second reactivate');
95
+ }
96
+ step "rb_commit" { COMMIT; }
97
+
98
+ # checker: final machine lifecycle + the policy verdict for the bound sender.
99
+ session "chk"
100
+ step "chk" {
101
+ select fm.lifecycle_state,
102
+ (select denial_class
103
+ from agent_board.evaluate_fleet_sender_policy(
104
+ 'a0000000-0000-4000-8000-000000000002', 'a0000000-0000-4000-8000-000000000003',
105
+ fm.id, null, 'send_direct_message', null, false)) as denial_class
106
+ from agent_board.fleet_machines fm
107
+ where fm.id = '60000000-0000-4000-8000-000000000001';
108
+ }
109
+
110
+ # P1: reactivate holds the lock; the revoke blocks then applies → final revoked + denied.
111
+ permutation "ra_begin" "ra_react" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"
112
+
113
+ # P2: reactivate holds the lock; the second reactivate blocks, re-reads under the lock, and
114
+ # raises (nothing to reactivate) → the for-update mutation signal.
115
+ permutation "ra_begin" "ra_react" "rb_begin" "rb_react" "ra_commit" "rb_commit" "chk"
@@ -0,0 +1,94 @@
1
+ # #150 MUST-FIX 1 (codex) — reclaim_fleet_machine serializes against a concurrent squatter
2
+ # revoke('machine'), and revoke re-checks machine ownership UNDER a row lock.
3
+ #
4
+ # revoke_fleet_subject's machine-ownership authz check was UNLOCKED: a squatter could call
5
+ # revoke('machine', M); its ownership SELECT reads owner=squatter (passes) with no lock, then its
6
+ # lifecycle UPDATE blocks on reclaim's `select ... for update` and — pre-fix — applies AFTER
7
+ # reclaim commits (owner=new_owner), revoking the machine the operator just handed over (a
8
+ # DoS-flavoured defeat of reclaim's atomicity). The fix takes `select ... for update` on the owning
9
+ # fleet_machines row BEFORE the ownership check, so revoke serializes behind reclaim and the
10
+ # re-read sees committed ownership (owner=new_owner) → the squatter's revoke is refused 42501.
11
+ #
12
+ # P1 — reclaim-vs-squatter-revoke (the pin). reclaim (service_role) takes the lock and holds it;
13
+ # the squatter's authenticated revoke BLOCKS at its own FOR UPDATE (isolationtester prints
14
+ # `<waiting ...>`), then after reclaim commits RE-READS ownership under the lock, sees owner=
15
+ # new_owner, and RAISES `machine is not owned by current_agent_id()`. FINAL: owner=new_owner,
16
+ # lifecycle=active — the reclaimed machine is NOT nuked.
17
+ #
18
+ # MUTATION SIGNAL: drop the FOR UPDATE lock from revoke_fleet_subject → the squatter's ownership
19
+ # SELECT reads the stale (pre-commit) owner=squatter and PASSES without waiting; revoke then blocks
20
+ # at its lifecycle UPDATE, and after reclaim commits it SUCCEEDS (returns an event id) and the
21
+ # checker shows lifecycle=revoked / owner=new_owner — the trace diverges from this golden.
22
+
23
+ setup
24
+ {
25
+ insert into auth.users(id) values ('b1000000-0000-4000-8000-000000000002');
26
+
27
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id) values
28
+ ('a1000000-0000-4000-8000-000000000001', 'agent', 'reclaim-iso-operator', 'Reclaim Iso Operator', 'active', null),
29
+ ('a1000000-0000-4000-8000-000000000002', 'agent', 'reclaim-iso-squatter', 'Reclaim Iso Squatter', 'active', 'b1000000-0000-4000-8000-000000000002'),
30
+ ('a1000000-0000-4000-8000-000000000003', 'agent', 'reclaim-iso-newowner', 'Reclaim Iso New Owner', 'active', null);
31
+
32
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
33
+ values ('d1000000-0000-4000-8000-000000000001', 'reclaim-iso-domain', 'Reclaim Iso Domain', 'tailnet',
34
+ 'a1000000-0000-4000-8000-000000000002', 'active', now());
35
+
36
+ -- An active machine owned by the squatter (the pre-registered/squatted fingerprint).
37
+ insert into agent_board.fleet_machines(id, trust_domain_id, owner_agent_id, machine_label, machine_fingerprint,
38
+ lifecycle_state, enrollment_source, enrolled_at, revoked_at, metadata)
39
+ values ('61000000-0000-4000-8000-000000000001', 'd1000000-0000-4000-8000-000000000001',
40
+ 'a1000000-0000-4000-8000-000000000002', 'reclaim-iso-m', 'sha256:reclaim-iso-m',
41
+ 'active', 'portal', now(), null, jsonb_build_object('redacted', true, 'metadata-safe', true));
42
+ }
43
+
44
+ teardown
45
+ {
46
+ delete from agent_board.fleet_revocation_events where subject_id = '61000000-0000-4000-8000-000000000001';
47
+ delete from agent_board.fleet_machines where id = '61000000-0000-4000-8000-000000000001';
48
+ delete from agent_board.fleet_trust_domains where id = 'd1000000-0000-4000-8000-000000000001';
49
+ delete from agent_board.agents where id in (
50
+ 'a1000000-0000-4000-8000-000000000001',
51
+ 'a1000000-0000-4000-8000-000000000002',
52
+ 'a1000000-0000-4000-8000-000000000003');
53
+ delete from auth.users where id = 'b1000000-0000-4000-8000-000000000002';
54
+ }
55
+
56
+ # ra: the operator reclaim (service_role). Its `select ... for update` locks the machine row until
57
+ # ra commits, reassigning ownership to the new owner.
58
+ session "ra"
59
+ step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
60
+ step "ra_reclaim" {
61
+ select owner_agent_id
62
+ from agent_board.reclaim_fleet_machine(
63
+ '61000000-0000-4000-8000-000000000001', 'a1000000-0000-4000-8000-000000000003',
64
+ 'a1000000-0000-4000-8000-000000000001', 'iso operator reclaim');
65
+ }
66
+ step "ra_commit" { COMMIT; }
67
+
68
+ # rb: the squatter's revoke via the authenticated domain-owner path. With the fix it BLOCKS at its
69
+ # own FOR UPDATE, then re-reads committed ownership and is refused 42501.
70
+ session "rb"
71
+ step "rb_begin" {
72
+ BEGIN;
73
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
74
+ SET LOCAL "request.jwt.claim.sub" = 'b1000000-0000-4000-8000-000000000002';
75
+ }
76
+ step "rb_revoke" {
77
+ select revocation_event_id
78
+ from agent_board.revoke_fleet_subject(
79
+ 'machine', '61000000-0000-4000-8000-000000000001', 'a1000000-0000-4000-8000-000000000002',
80
+ 'iso squatter revoke', jsonb_build_object('redacted', true));
81
+ }
82
+ step "rb_commit" { COMMIT; }
83
+
84
+ # checker: final ownership + lifecycle of the contested machine.
85
+ session "chk"
86
+ step "chk" {
87
+ select owner_agent_id, lifecycle_state
88
+ from agent_board.fleet_machines
89
+ where id = '61000000-0000-4000-8000-000000000001';
90
+ }
91
+
92
+ # P1: reclaim holds the lock; the squatter's revoke blocks then is refused → machine stays active
93
+ # under the new owner (the revoke is NOT applied).
94
+ permutation "ra_begin" "ra_reclaim" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"