@consiliency/agent-board-schema 1.12.0 → 1.22.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 (104) hide show
  1. package/fixtures/already-applied.json +124 -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/package.json +1 -1
  34. package/schema-manifest.json +282 -3
  35. package/src/tooling.test.mjs +113 -2
  36. package/tests/agent_board_actorspoof.test.sql +349 -0
  37. package/tests/agent_board_agentsdir.test.sql +154 -0
  38. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  39. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  40. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  41. package/tests/agent_board_claimkey.test.sql +229 -0
  42. package/tests/agent_board_consent_backfill.test.sql +48 -0
  43. package/tests/agent_board_consent_emission.test.sql +601 -0
  44. package/tests/agent_board_consent_gating.test.sql +489 -0
  45. package/tests/agent_board_consent_substrate.test.sql +720 -0
  46. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  47. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  48. package/tests/agent_board_delegation_policies.test.sql +107 -1
  49. package/tests/agent_board_fedsession.test.sql +6 -4
  50. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  51. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  52. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  53. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  54. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  55. package/tests/agent_board_fleet_policy.test.sql +2 -2
  56. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  57. package/tests/agent_board_identity_providers.test.sql +9 -0
  58. package/tests/agent_board_inbox_reads.test.sql +29 -12
  59. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  60. package/tests/agent_board_invitepolicy.test.sql +205 -0
  61. package/tests/agent_board_leaveidem.test.sql +194 -0
  62. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  63. package/tests/agent_board_mint_harden.test.sql +303 -0
  64. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  65. package/tests/agent_board_obsgate.test.sql +178 -0
  66. package/tests/agent_board_obsgate2.test.sql +153 -0
  67. package/tests/agent_board_obsgate3.test.sql +170 -0
  68. package/tests/agent_board_obsgate4.test.sql +196 -0
  69. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  70. package/tests/agent_board_pubexec_class.test.sql +53 -0
  71. package/tests/agent_board_rejectsem.test.sql +271 -0
  72. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  73. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  74. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  75. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  76. package/tests/agent_board_rolereset.test.sql +351 -0
  77. package/tests/agent_board_roomdeleg.test.sql +5 -3
  78. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  79. package/tests/agent_board_roomlink.test.sql +25 -9
  80. package/tests/agent_board_rowid.test.sql +1274 -0
  81. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  82. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  83. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  84. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  85. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  86. package/tests/agent_board_signing_store.test.sql +6 -6
  87. package/tests/fixtures/isolation-substrate.sql +93 -0
  88. package/tests/fixtures/test-prereqs.sql +33 -0
  89. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  90. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  91. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  92. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  93. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  94. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  95. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  96. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  97. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  98. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  99. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  100. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  101. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  102. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  103. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  104. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,67 @@
1
+ # CLAIMGATE/CLAIMKEY (#133) MUST-FIX 1 — renew_claim canonicalization is race-safe.
2
+ #
3
+ # Two ACTIVE claims name the SAME physical machine via distinct legacy keys that both
4
+ # canonicalize to 'fpRace' ('machine:fpRace' and ' fpRace '). Renewed concurrently, each
5
+ # session's canonicalizing UPDATE targets 'fpRace' on claims_active_resource_unique. A
6
+ # check-then-act guard would let both observe the slot free at READ COMMITTED and one would
7
+ # raise 23505 — the panel's must-fix. The shipped renew wraps the UPDATE in a unique_violation
8
+ # handler that retries ONCE keeping the current key, so the loser does NOT raise: it renews the
9
+ # lease and keeps its legacy key (the deferred alias ENFORCE closes).
10
+ #
11
+ # WHAT THIS PINS (a TRACE, per Postgres's own isolation suite): session rb's renew BLOCKS on
12
+ # ra's uncommitted index entry (isolationtester prints `<waiting ...>`), and after ra commits rb
13
+ # COMPLETES returning its kept legacy key ' fpRace ' — no error line. Both leases stay active.
14
+ #
15
+ # MUTATION SIGNAL (verified in the PR): drop the `exception when unique_violation ... retry`
16
+ # handler (or stop it catching unique_violation) → rb no longer completes cleanly; the trace
17
+ # shows `ERROR: duplicate key value violates unique constraint "claims_active_resource_unique"`
18
+ # and the golden diverges. Both sessions run as service_role (renew's ownership check is bypassed
19
+ # for the operator carve-out; canonicalization is actor-independent).
20
+
21
+ setup
22
+ {
23
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
24
+ values ('c1a70000-0000-4000-8000-000000000001', 'agent', 'ck-race-agent', 'CK Race Agent', 'active');
25
+ insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_token, lease_expires_at) values
26
+ ('c1a70000-0000-4000-8000-000000000001', 'machine', 'machine:fpRace',
27
+ '00000000-0000-4000-8000-0000000000c1', now() + interval '1 hour'),
28
+ ('c1a70000-0000-4000-8000-000000000001', 'machine', ' fpRace ',
29
+ '00000000-0000-4000-8000-0000000000c2', now() + interval '1 hour');
30
+ }
31
+
32
+ teardown
33
+ {
34
+ delete from agent_board.claims where agent_id = 'c1a70000-0000-4000-8000-000000000001';
35
+ delete from agent_board.agents where id = 'c1a70000-0000-4000-8000-000000000001';
36
+ }
37
+
38
+ # ra renews the 'machine:fpRace' row -> canonical 'fpRace' is free at its snapshot, so it takes
39
+ # the slot and holds the uncommitted index entry.
40
+ session "ra"
41
+ step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
42
+ step "ra_renew" {
43
+ select resource_key
44
+ from agent_board.renew_claim('00000000-0000-4000-8000-0000000000c1', now() + interval '2 hours');
45
+ }
46
+ step "ra_commit" { COMMIT; }
47
+
48
+ # rb renews the ' fpRace ' row -> canonicalizes to the SAME 'fpRace'; its UPDATE blocks on ra's
49
+ # index entry, then on ra commit hits unique_violation and the handler retries keeping ' fpRace '.
50
+ session "rb"
51
+ step "rb_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
52
+ step "rb_renew" {
53
+ select resource_key
54
+ from agent_board.renew_claim('00000000-0000-4000-8000-0000000000c2', now() + interval '2 hours');
55
+ }
56
+ step "rb_commit" { COMMIT; }
57
+
58
+ # checker: both leases remain active; ra healed to 'fpRace', rb kept its legacy ' fpRace '.
59
+ session "chk"
60
+ step "chk" {
61
+ select resource_key
62
+ from agent_board.claims
63
+ where lease_token in ('00000000-0000-4000-8000-0000000000c1','00000000-0000-4000-8000-0000000000c2')
64
+ order by resource_key;
65
+ }
66
+
67
+ permutation "ra_begin" "ra_renew" "rb_begin" "rb_renew" "ra_commit" "rb_commit" "chk"
@@ -0,0 +1,98 @@
1
+ # message-board#115 — the consent-approval SET-lock serialises a concurrent withdrawal.
2
+ #
3
+ # resolve_board_request opens with an UNFILTERED, id-ordered `for update` over the
4
+ # WHOLE (board, agent) membership set, BEFORE it reads consent or writes. That
5
+ # breadth is load-bearing: a concurrent self-withdrawal on a DIFFERENT physical row
6
+ # (the active one) than the pending row being resolved must serialise behind it
7
+ # (message-board#109). A pending-only / filtered / LIMITed lock would leave the
8
+ # active row free and reopen the race.
9
+ #
10
+ # WHAT THIS PINS (and, deliberately, what it does NOT): on current code a lost
11
+ # UPDATE is independently prevented by membership_id threading (the sink acts on the
12
+ # exact row the resolver validated), so dropping the lock does NOT corrupt the final
13
+ # state — it changes SERIALIZATION. The observable that regresses is the BLOCK
14
+ # itself: with the lock, the withdrawer waits; without it (dropped OR narrowed), the
15
+ # withdrawer proceeds immediately. isolationtester records that as the presence vs
16
+ # absence of `<waiting ...>` in the trace, and the golden diff is the signal. This is
17
+ # exactly how Postgres's own isolation suite pins lock behaviour.
18
+ #
19
+ # THE SURGICAL PERMUTATION: the subject S carries a legal (active A, pending P)
20
+ # duplicate. The approver resolves S to 'joined' — with an active row present this is
21
+ # the Option-1 path: resolve acquires the SET-lock and returns the ACTIVE row A
22
+ # untouched. (Post-#116 it also retires the dangling pending row P via the sink — a
23
+ # write to P, never to A.) So resolve never WRITES A; the SET-lock is the ONLY thing
24
+ # it does to A, isolating the lock as the single variable. The withdrawer withdraws
25
+ # the ACTIVE row A; it blocks on the SET-lock, then applies after commit.
26
+ #
27
+ # Both sessions run as service_role — the SET-lock sits AFTER the actor check and is
28
+ # actor-independent, so this needs no auth.users identity substrate; the lock trace
29
+ # is identical to the authenticated path.
30
+ #
31
+ # MUTATION SIGNAL (a TRACE diff): drop the `for update` block, OR narrow it with
32
+ # `and bm.membership_state in ('requested','invited')` — either leaves A unlocked, so
33
+ # w_leave no longer shows `<waiting ...>` and the golden diverges. Both mutations are
34
+ # verified in the PR.
35
+
36
+ setup
37
+ {
38
+ insert into agent_board.agents(id, agent_type, handle, display_name, status) values
39
+ ('115a0000-0000-4000-8000-000000000001', 'agent', 'iso115-owner', 'Iso115 Owner', 'active'),
40
+ ('115a0000-0000-4000-8000-000000000002', 'agent', 'iso115-subject', 'Iso115 Subject', 'active');
41
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
42
+ values ('115b0000-0000-4000-8000-000000000001', 'iso115-board', 'Iso115 Board',
43
+ '115a0000-0000-4000-8000-000000000001', 'private', 'active');
44
+ -- Legal (active, pending) duplicate for the subject: A is joined (active), P is a
45
+ -- separate pending request. The active-unique index permits it (only A is active).
46
+ insert into agent_board.board_memberships(id, board_id, agent_id, membership_state, role, created_by_agent_id) values
47
+ ('115c0000-0000-4000-8000-0000000000aa', '115b0000-0000-4000-8000-000000000001',
48
+ '115a0000-0000-4000-8000-000000000002', 'joined', 'member', '115a0000-0000-4000-8000-000000000002'),
49
+ ('115c0000-0000-4000-8000-0000000000bb', '115b0000-0000-4000-8000-000000000001',
50
+ '115a0000-0000-4000-8000-000000000002', 'requested', 'member', '115a0000-0000-4000-8000-000000000002');
51
+ }
52
+
53
+ teardown
54
+ {
55
+ delete from agent_board.board_membership_events where board_id = '115b0000-0000-4000-8000-000000000001';
56
+ delete from agent_board.board_memberships where board_id = '115b0000-0000-4000-8000-000000000001';
57
+ delete from agent_board.boards where id = '115b0000-0000-4000-8000-000000000001';
58
+ delete from agent_board.agents where id in ('115a0000-0000-4000-8000-000000000001','115a0000-0000-4000-8000-000000000002');
59
+ }
60
+
61
+ # approver resolves the subject to 'joined'. With active row A present this is the
62
+ # Option-1 path — it acquires the whole-set `for update` and returns A untouched
63
+ # (retiring the dangling pending row P via the sink, but never writing A).
64
+ session "approver"
65
+ step "a_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
66
+ step "a_resolve" {
67
+ select membership_state
68
+ from agent_board.resolve_board_request(
69
+ '115b0000-0000-4000-8000-000000000001',
70
+ '115a0000-0000-4000-8000-000000000002',
71
+ 'joined',
72
+ '115a0000-0000-4000-8000-000000000001');
73
+ }
74
+ step "a_commit" { COMMIT; }
75
+
76
+ # withdrawer withdraws the subject's ACTIVE membership (row A). Its write blocks on
77
+ # the approver's whole-set lock until the approver commits.
78
+ session "withdrawer"
79
+ step "w_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
80
+ step "w_leave" {
81
+ select membership_state
82
+ from agent_board.update_board_membership(
83
+ '115b0000-0000-4000-8000-000000000001',
84
+ '115a0000-0000-4000-8000-000000000002',
85
+ 'left');
86
+ }
87
+ step "w_commit" { COMMIT; }
88
+
89
+ # checker asserts the active row landed 'left' — the withdrawal applied, serialised
90
+ # after the approval, not overwritten.
91
+ session "checker"
92
+ step "chk" {
93
+ select membership_state
94
+ from agent_board.board_memberships
95
+ where id = '115c0000-0000-4000-8000-0000000000aa';
96
+ }
97
+
98
+ permutation "a_begin" "a_resolve" "w_begin" "w_leave" "a_commit" "w_commit" "chk"
@@ -0,0 +1,79 @@
1
+ # message-board#105 — membership writers emit the ACTUAL row state, not a literal.
2
+ #
3
+ # bootstrap_board_owner_memberships upserts the owner membership with
4
+ # `INSERT ... ON CONFLICT (board_id, agent_id) WHERE (active) DO UPDATE SET role='owner'`
5
+ # and emits `RETURNING bm.membership_state` (the ACTUAL post-upsert state), NOT a
6
+ # literal 'joined'. The ON CONFLICT branch lifts only the role, so a membership a
7
+ # concurrent session already made 'subscribed' STAYS subscribed — and the emitted
8
+ # ledger event must say 'subscribed', not 'joined'.
9
+ #
10
+ # That conflict branch is only reachable under concurrency (a single-transaction
11
+ # pgTAP file cannot produce it): bootstrap's eligibility read must see the owner as
12
+ # having NO active membership (so it enters the loop and attempts the INSERT), yet
13
+ # its INSERT must collide with an active row. We force it deterministically:
14
+ #
15
+ # maker holds an UNCOMMITTED 'subscribed' owner membership. Its row is invisible
16
+ # to boot's eligibility read (READ COMMITTED), so boot enters the loop; boot's
17
+ # INSERT then BLOCKS on maker's uncommitted entry in the partial active-unique
18
+ # index (isolationtester reports <waiting>). maker commits; boot's ON CONFLICT
19
+ # resolves against the now-committed 'subscribed' row and emits result_state
20
+ # 'subscribed'.
21
+ #
22
+ # MUTATION SIGNAL (a VALUE diff): revert the emit to a literal 'joined' and the
23
+ # checker step reports 'joined' instead of 'subscribed' — the golden diverges.
24
+ #
25
+ # bootstrap()'s output is filtered to this board and projects only the two FIXED
26
+ # UUIDs (never the random membership_id) so the golden is deterministic.
27
+
28
+ setup
29
+ {
30
+ insert into agent_board.agents(id, agent_type, handle, display_name, status)
31
+ values ('105a0000-0000-4000-8000-000000000001', 'agent', 'iso105-owner', 'Iso105 Owner', 'active');
32
+ insert into agent_board.boards(id, slug, name, created_by_agent_id, visibility, status)
33
+ values ('105b0000-0000-4000-8000-000000000001', 'iso105-board', 'Iso105 Board',
34
+ '105a0000-0000-4000-8000-000000000001', 'private', 'active');
35
+ }
36
+
37
+ teardown
38
+ {
39
+ delete from agent_board.board_membership_events where board_id = '105b0000-0000-4000-8000-000000000001';
40
+ delete from agent_board.board_memberships where board_id = '105b0000-0000-4000-8000-000000000001';
41
+ delete from agent_board.boards where id = '105b0000-0000-4000-8000-000000000001';
42
+ delete from agent_board.agents where id = '105a0000-0000-4000-8000-000000000001';
43
+ }
44
+
45
+ # maker creates (but does not yet commit) an ACTIVE 'subscribed' owner membership.
46
+ session "maker"
47
+ step "m_begin" { BEGIN; }
48
+ step "m_sub" {
49
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, role, created_by_agent_id)
50
+ values ('105b0000-0000-4000-8000-000000000001', '105a0000-0000-4000-8000-000000000001',
51
+ 'subscribed', 'member', '105a0000-0000-4000-8000-000000000001');
52
+ }
53
+ step "m_commit" { COMMIT; }
54
+
55
+ # boot runs the operator bootstrap (service_role-only). Projects only the two fixed
56
+ # UUIDs for THIS board so the trace is deterministic.
57
+ session "boot"
58
+ step "b_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
59
+ step "b_boot" {
60
+ select board_id, owner_agent_id
61
+ from agent_board.bootstrap_board_owner_memberships()
62
+ where board_id = '105b0000-0000-4000-8000-000000000001';
63
+ }
64
+ step "b_commit" { COMMIT; }
65
+
66
+ # checker reads the emitted ledger event: its result_state must be the ACTUAL row
67
+ # state ('subscribed'), not the literal 'joined'.
68
+ session "checker"
69
+ step "chk" {
70
+ select result_state, event_kind, provenance
71
+ from agent_board.board_membership_events
72
+ where board_id = '105b0000-0000-4000-8000-000000000001'
73
+ and agent_id = '105a0000-0000-4000-8000-000000000001'
74
+ and event_kind = 'operator_repair'
75
+ order by seq desc
76
+ limit 1;
77
+ }
78
+
79
+ permutation "m_begin" "m_sub" "b_begin" "b_boot" "m_commit" "b_commit" "chk"
@@ -0,0 +1,88 @@
1
+ # #153 TOCTOU — reclaim_fleet_trust_domain serializes against a concurrent squatter
2
+ # revoke('trust_domain'), and revoke re-checks domain ownership UNDER a row lock.
3
+ #
4
+ # revoke_fleet_subject's trust_domain-ownership authz check was UNLOCKED: a squatter could call
5
+ # revoke('trust_domain', D); its ownership SELECT reads owner=squatter (passes) with no lock, then
6
+ # its lifecycle UPDATE blocks on reclaim's `select ... for update` and — pre-fix — applies AFTER
7
+ # reclaim commits (owner=new_owner), revoking the domain the operator just handed over (a
8
+ # DoS-flavoured defeat of reclaim's atomicity — the exact whole-domain analogue of #150's machine
9
+ # TOCTOU). The fix takes `select ... for update` on the owning fleet_trust_domains row BEFORE the
10
+ # ownership check, so revoke serializes behind reclaim and the re-read sees committed ownership
11
+ # (owner=new_owner) → the squatter's revoke is refused 42501.
12
+ #
13
+ # P1 — reclaim-vs-squatter-revoke (the pin). reclaim (service_role) takes the lock and holds it;
14
+ # the squatter's authenticated revoke BLOCKS at its own FOR UPDATE (isolationtester prints
15
+ # `<waiting ...>`), then after reclaim commits RE-READS ownership under the lock, sees owner=
16
+ # new_owner, and RAISES `trust domain is not owned by current_agent_id()`. FINAL: owner=new_owner,
17
+ # lifecycle=active — the reclaimed domain is NOT nuked.
18
+ #
19
+ # MUTATION SIGNAL: drop the trust_domain FOR UPDATE lock from revoke_fleet_subject → the squatter's
20
+ # ownership SELECT reads the stale (pre-commit) owner=squatter and PASSES without waiting; revoke
21
+ # then blocks at its lifecycle UPDATE, and after reclaim commits it SUCCEEDS (returns an event id)
22
+ # and the checker shows lifecycle=revoked / owner=new_owner — the trace diverges from this golden.
23
+
24
+ setup
25
+ {
26
+ insert into auth.users(id) values ('b2000000-0000-4000-8000-000000000002');
27
+
28
+ insert into agent_board.agents(id, agent_type, handle, display_name, status, auth_user_id) values
29
+ ('a2000000-0000-4000-8000-000000000001', 'agent', 'domreclaim-iso-operator', 'Dom Reclaim Iso Operator', 'active', null),
30
+ ('a2000000-0000-4000-8000-000000000002', 'agent', 'domreclaim-iso-squatter', 'Dom Reclaim Iso Squatter', 'active', 'b2000000-0000-4000-8000-000000000002'),
31
+ ('a2000000-0000-4000-8000-000000000003', 'agent', 'domreclaim-iso-newowner', 'Dom Reclaim Iso New Owner', 'active', null);
32
+
33
+ -- An active domain owned by the squatter (the squatted domain).
34
+ insert into agent_board.fleet_trust_domains(id, slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
35
+ values ('d2000000-0000-4000-8000-000000000001', 'domreclaim-iso-domain', 'Dom Reclaim Iso Domain', 'tailnet',
36
+ 'a2000000-0000-4000-8000-000000000002', 'active', now());
37
+ }
38
+
39
+ teardown
40
+ {
41
+ delete from agent_board.fleet_revocation_events where subject_id = 'd2000000-0000-4000-8000-000000000001';
42
+ delete from agent_board.fleet_trust_domains where id = 'd2000000-0000-4000-8000-000000000001';
43
+ delete from agent_board.agents where id in (
44
+ 'a2000000-0000-4000-8000-000000000001',
45
+ 'a2000000-0000-4000-8000-000000000002',
46
+ 'a2000000-0000-4000-8000-000000000003');
47
+ delete from auth.users where id = 'b2000000-0000-4000-8000-000000000002';
48
+ }
49
+
50
+ # ra: the operator reclaim (service_role). Its `select ... for update` locks the domain row until
51
+ # ra commits, reassigning ownership to the new owner.
52
+ session "ra"
53
+ step "ra_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
54
+ step "ra_reclaim" {
55
+ select owner_agent_id
56
+ from agent_board.reclaim_fleet_trust_domain(
57
+ 'd2000000-0000-4000-8000-000000000001', 'a2000000-0000-4000-8000-000000000003',
58
+ 'a2000000-0000-4000-8000-000000000001', 'iso operator domain reclaim');
59
+ }
60
+ step "ra_commit" { COMMIT; }
61
+
62
+ # rb: the squatter's revoke via the authenticated domain-owner path. With the fix it BLOCKS at its
63
+ # own FOR UPDATE, then re-reads committed ownership and is refused 42501.
64
+ session "rb"
65
+ step "rb_begin" {
66
+ BEGIN;
67
+ SET LOCAL "request.jwt.claim.role" = 'authenticated';
68
+ SET LOCAL "request.jwt.claim.sub" = 'b2000000-0000-4000-8000-000000000002';
69
+ }
70
+ step "rb_revoke" {
71
+ select revocation_event_id
72
+ from agent_board.revoke_fleet_subject(
73
+ 'trust_domain', 'd2000000-0000-4000-8000-000000000001', 'a2000000-0000-4000-8000-000000000002',
74
+ 'iso squatter domain revoke', jsonb_build_object('redacted', true));
75
+ }
76
+ step "rb_commit" { COMMIT; }
77
+
78
+ # checker: final ownership + lifecycle of the contested domain.
79
+ session "chk"
80
+ step "chk" {
81
+ select owner_agent_id, lifecycle_state
82
+ from agent_board.fleet_trust_domains
83
+ where id = 'd2000000-0000-4000-8000-000000000001';
84
+ }
85
+
86
+ # P1: reclaim holds the lock; the squatter's revoke blocks then is refused → domain stays active
87
+ # under the new owner (the revoke is NOT applied).
88
+ permutation "ra_begin" "ra_reclaim" "rb_begin" "rb_revoke" "ra_commit" "rb_commit" "chk"
@@ -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"
@@ -0,0 +1,19 @@
1
+ -- message-board#103 — pre-MBCONSENT-A seed: a legacy PENDING membership that predates the consent
2
+ -- ledger, so the shipped backfill (20260806000100, board-authored neutral event per pending
3
+ -- subject) has a real row to act on. Applied by the test runner immediately BEFORE that migration
4
+ -- (schema is at the migration-N-1 state here — board_membership_events does not exist yet).
5
+ --
6
+ -- Distinctive fixed ids (bf103-*) so the row does not collide with any test's own fixtures. The
7
+ -- board's creator differs from the member, satisfying the backfill's board-authored predicate.
8
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
9
+ values
10
+ ('bf103aaa-0000-0000-0000-000000000001', 'agent', 'agent', 'bf103-creator', 'BF103 Creator', 'active'),
11
+ ('bf103aaa-0000-0000-0000-000000000002', 'agent', 'agent', 'bf103-member', 'BF103 Member', 'active')
12
+ on conflict (id) do nothing;
13
+
14
+ insert into agent_board.boards(id, slug, name, description, created_by_agent_id)
15
+ values ('bf103bbb-0000-0000-0000-0000000000b1', 'bf103-board', 'BF103 Board', 'consent backfill seed', 'bf103aaa-0000-0000-0000-000000000001')
16
+ on conflict (id) do nothing;
17
+
18
+ insert into agent_board.board_memberships(board_id, agent_id, membership_state, created_by_agent_id, role)
19
+ values ('bf103bbb-0000-0000-0000-0000000000b1', 'bf103aaa-0000-0000-0000-000000000002', 'requested', 'bf103aaa-0000-0000-0000-000000000001', 'member');
@@ -0,0 +1,39 @@
1
+ -- Pre-migration seed (message-board#103 hook) for the MINTHARDEN fingerprint backfill.
2
+ --
3
+ -- Runs immediately BEFORE 20260822000200 — i.e. AFTER 20260822000100 installed the
4
+ -- integrity trigger and helpers. It simulates a row that predates the trigger: a
5
+ -- string-era mint child signing key with a CANONICAL public_key but a fingerprint
6
+ -- derived over the base64url STRING (the 20260718001000:203 bug). Such a row can only
7
+ -- exist by bypassing the new trigger, so it is inserted under session_replication_role=
8
+ -- replica — exactly reproducing a legacy row created before the trigger existed.
9
+ --
10
+ -- The backfill (20260822000200) must re-derive its fingerprint to the raw-byte value;
11
+ -- agent_board_mint_harden_backfill.test.sql asserts the SHIPPED backfill did so
12
+ -- (behavioural — a mutation removing the UPDATE leaves the stale string fingerprint and
13
+ -- the assertion fails). Distinctive fixed ids so nothing collides with other fixtures.
14
+ do $$
15
+ declare
16
+ v_owner uuid;
17
+ v_agent uuid := 'fb111111-0000-4b11-8000-000000000200';
18
+ v_key uuid := 'fb111111-0000-4b11-8000-000000000201';
19
+ v_pub text := public.test_pubkey('fbfill-legacy-1');
20
+ -- The OLD string-era derivation: sha256 over the base64url STRING bytes (not the raw key).
21
+ v_string_fp text := 'sha256:' || rtrim(translate(encode(
22
+ extensions.digest(convert_to(public.test_pubkey('fbfill-legacy-1'), 'UTF8'), 'sha256'), 'base64'), '+/', '-_'), '=');
23
+ begin
24
+ v_owner := public.ensure_github_user(
25
+ 'fbfill-legacy-owner', 'fbfill-legacy-owner@example.test', 'fbfill-legacy-owner', 'FB Legacy Owner');
26
+
27
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
28
+ values (v_agent, 'agent', 'agent'::agent_board.agent_kind, 'fbfill-legacy-agent', 'FB Legacy Agent',
29
+ 'active', v_owner, v_owner);
30
+
31
+ -- Bypass the 20260822000100 integrity trigger to plant the legacy (string-fingerprint) row.
32
+ set local session_replication_role = replica;
33
+ insert into agent_board.agent_signing_keys(
34
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state, metadata)
35
+ values (v_key, v_agent, 'delegated-ephemeral', 1, v_pub, 'raw_ed25519_base64url', v_string_fp,
36
+ 'active', 'proven', jsonb_build_object('legacyStringFingerprint', true));
37
+ set local session_replication_role = default;
38
+ end
39
+ $$;