@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.
- package/fixtures/already-applied.json +160 -0
- package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
- package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
- package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
- package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
- package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
- package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
- package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
- package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
- package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
- package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
- package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
- package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
- package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
- package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
- package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
- package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
- package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
- package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
- package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
- package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
- package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
- package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
- package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
- package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
- package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
- package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
- package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
- package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
- package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
- package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
- package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
- package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
- package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
- package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
- package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
- package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
- package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
- package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
- package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
- package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
- package/package.json +1 -1
- package/schema-manifest.json +363 -3
- package/src/tooling.test.mjs +113 -2
- package/tests/agent_board_actorspoof.test.sql +349 -0
- package/tests/agent_board_agentsdir.test.sql +154 -0
- package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
- package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
- package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
- package/tests/agent_board_authbound_boundary.test.sql +9 -1
- package/tests/agent_board_claimgate_enforce.test.sql +464 -0
- package/tests/agent_board_claimgate_predicate.test.sql +360 -0
- package/tests/agent_board_claimkey.test.sql +229 -0
- package/tests/agent_board_consent_backfill.test.sql +48 -0
- package/tests/agent_board_consent_emission.test.sql +601 -0
- package/tests/agent_board_consent_gating.test.sql +489 -0
- package/tests/agent_board_consent_substrate.test.sql +720 -0
- package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
- package/tests/agent_board_delegation_enforcement.test.sql +55 -6
- package/tests/agent_board_delegation_policies.test.sql +107 -1
- package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
- package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
- package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
- package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
- package/tests/agent_board_fedsession.test.sql +51 -5
- package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
- package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
- package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
- package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
- package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
- package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
- package/tests/agent_board_fleet_policy.test.sql +2 -2
- package/tests/agent_board_grants_hygiene.test.sql +408 -0
- package/tests/agent_board_identity_providers.test.sql +9 -0
- package/tests/agent_board_inbox_reads.test.sql +45 -31
- package/tests/agent_board_inbox_rpcs.test.sql +44 -34
- package/tests/agent_board_invitepolicy.test.sql +205 -0
- package/tests/agent_board_leaveidem.test.sql +194 -0
- package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
- package/tests/agent_board_mint_harden.test.sql +303 -0
- package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
- package/tests/agent_board_obsgate.test.sql +178 -0
- package/tests/agent_board_obsgate2.test.sql +153 -0
- package/tests/agent_board_obsgate3.test.sql +170 -0
- package/tests/agent_board_obsgate4.test.sql +196 -0
- package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
- package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
- package/tests/agent_board_pubexec_class.test.sql +53 -0
- package/tests/agent_board_rejectsem.test.sql +271 -0
- package/tests/agent_board_resigning_refresh.test.sql +1 -1
- package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
- package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
- package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
- package/tests/agent_board_rolereset.test.sql +351 -0
- package/tests/agent_board_roomdeleg.test.sql +5 -3
- package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
- package/tests/agent_board_roomlink.test.sql +27 -11
- package/tests/agent_board_rowid.test.sql +1274 -0
- package/tests/agent_board_signature_rls_reads.test.sql +3 -3
- package/tests/agent_board_signed_write_gateway.test.sql +3 -3
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
- package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
- package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
- package/tests/agent_board_signing_store.test.sql +6 -6
- package/tests/agent_board_wake_recovery.test.sql +756 -0
- package/tests/fixtures/isolation-substrate.sql +93 -0
- package/tests/fixtures/test-prereqs.sql +33 -0
- package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
- package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
- package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
- package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
- package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
- package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
- package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
- package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
- package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
- package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
- package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
- package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
- package/tests/isolation/expected/claimkey_renew_race.out +38 -0
- package/tests/isolation/expected/consent_approval_lock.out +43 -0
- package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
- package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
- package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
- package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
- package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
- package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
- package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
- package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
- package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
- package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
- package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
- package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
- package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
- package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
- package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
- package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
- package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
- package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
- package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
- package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
- package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
- package/tests/isolation/specs/consent_approval_lock.spec +98 -0
- package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
- package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
- package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
- package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
- package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
- package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
- package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
- package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
- package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
- package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
- package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
- package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Consiliency/message-board#196 — set_board_authority_ceiling must HOLD the
|
|
2
|
+
# board-keyed ceiling advisory lock BEFORE it touches board_authority_ceilings.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS SPEC EXISTS. The ordering was pinned only by a positional pgTAP
|
|
5
|
+
# assertion over RAW function text ('the ceiling setter holds the same lock
|
|
6
|
+
# before its owner gate and upsert'). Moving the `perform
|
|
7
|
+
# agent_board._lock_board_authority_ceiling(p_board_id)` to the END of the
|
|
8
|
+
# function made that assertion fail -- and adding ONE `--` comment naming the
|
|
9
|
+
# marker made the whole file pass 52/52 with the ordering violated. Measured
|
|
10
|
+
# against all 20 committed specs, NOTHING else moved: this ordering had no
|
|
11
|
+
# runtime backstop at all.
|
|
12
|
+
#
|
|
13
|
+
# HOW THE ORDER IS OBSERVED (a lock trace, not a return value). The outcome of
|
|
14
|
+
# the call is the same in both orders, so the observable has to be what the
|
|
15
|
+
# transaction is HOLDING while it is blocked:
|
|
16
|
+
# * a guard session holds the ceiling advisory lock, so the setter blocks --
|
|
17
|
+
# isolationtester prints `<waiting ...>`;
|
|
18
|
+
# * a third session then probes the ceiling ROW with `for update nowait`.
|
|
19
|
+
# CORRECT ORDER: the setter is parked on the advisory lock and has not
|
|
20
|
+
# reached its upsert, so the row is free and the probe returns 'admin'.
|
|
21
|
+
# LOCK-LAST ORDER: the setter already ran `on conflict do update`, so it
|
|
22
|
+
# holds the row and the probe fails immediately with
|
|
23
|
+
# `ERROR: could not obtain lock on row in relation "board_authority_ceilings"`.
|
|
24
|
+
# NOWAIT is what makes this terminate in BOTH states -- the probe never joins the
|
|
25
|
+
# wait graph, so a violated ordering produces a diff, never a hang.
|
|
26
|
+
#
|
|
27
|
+
# The ceiling row is SEEDED in setup on purpose: with no pre-existing row the
|
|
28
|
+
# upsert takes the INSERT path and there is no row for the probe to witness.
|
|
29
|
+
#
|
|
30
|
+
# MUTATION-VERIFIED: `perform agent_board._lock_board_authority_ceiling(p_board_id)`
|
|
31
|
+
# relocated after the upsert -> this golden changes (probe errors instead of
|
|
32
|
+
# returning 'admin'). Clean schema -> byte-identical across 10 runs.
|
|
33
|
+
|
|
34
|
+
setup
|
|
35
|
+
{
|
|
36
|
+
insert into auth.users (id) values ('19600000-0000-0000-0000-0000000000a1');
|
|
37
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id)
|
|
38
|
+
values ('19600000-0000-0000-0000-000000000001', 'agent', 'agent',
|
|
39
|
+
'ceiling-order-owner', 'Ceiling Order Owner', 'active',
|
|
40
|
+
'19600000-0000-0000-0000-0000000000a1');
|
|
41
|
+
select count(*) from agent_board.create_board(
|
|
42
|
+
'19600000-0000-0000-0000-000000000001',
|
|
43
|
+
'ceiling-order-board', 'Ceiling Order Board',
|
|
44
|
+
'Consiliency/message-board#196 ceiling setter lock-order probe',
|
|
45
|
+
jsonb_build_object(), 'private'
|
|
46
|
+
);
|
|
47
|
+
insert into agent_board.board_authority_ceilings(board_id, max_grantable_role)
|
|
48
|
+
values ((select id from agent_board.boards where slug='ceiling-order-board'), 'admin');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
teardown
|
|
52
|
+
{
|
|
53
|
+
delete from agent_board.board_authority_ceilings where board_id in (
|
|
54
|
+
select id from agent_board.boards where slug='ceiling-order-board'
|
|
55
|
+
);
|
|
56
|
+
delete from agent_board.board_memberships where board_id in (
|
|
57
|
+
select id from agent_board.boards where slug='ceiling-order-board'
|
|
58
|
+
);
|
|
59
|
+
delete from agent_board.boards where slug='ceiling-order-board';
|
|
60
|
+
delete from agent_board.agents where id='19600000-0000-0000-0000-000000000001';
|
|
61
|
+
delete from auth.users where id='19600000-0000-0000-0000-0000000000a1';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Holds the SAME board-keyed advisory lock the setter must take first. It calls
|
|
65
|
+
# the helper rather than re-deriving the key, so a key-derivation change cannot
|
|
66
|
+
# silently make this guard lock a different object.
|
|
67
|
+
session "holder"
|
|
68
|
+
step "holder_begin" { BEGIN; }
|
|
69
|
+
step "hold_ceiling_lock" {
|
|
70
|
+
select agent_board._lock_board_authority_ceiling(
|
|
71
|
+
(select id from agent_board.boards where slug='ceiling-order-board')) as locked;
|
|
72
|
+
}
|
|
73
|
+
step "holder_commit" { COMMIT; }
|
|
74
|
+
|
|
75
|
+
# The ceiling setter is owner-gated on current_agent_id(), so it runs as the owner
|
|
76
|
+
# under `authenticated` (service_role's current_agent_id() is null and the gate
|
|
77
|
+
# rejects it).
|
|
78
|
+
session "setter"
|
|
79
|
+
step "setter_begin" {
|
|
80
|
+
BEGIN;
|
|
81
|
+
SET LOCAL ROLE authenticated;
|
|
82
|
+
SET LOCAL "request.jwt.claim.role" = 'authenticated';
|
|
83
|
+
SET LOCAL "request.jwt.claim.sub" = '19600000-0000-0000-0000-0000000000a1';
|
|
84
|
+
}
|
|
85
|
+
step "set_ceiling" {
|
|
86
|
+
select ceiling_max_grantable_role from agent_board.set_board_authority_ceiling(
|
|
87
|
+
(select id from agent_board.boards where slug='ceiling-order-board'),
|
|
88
|
+
'member'
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
step "setter_commit" { COMMIT; }
|
|
92
|
+
|
|
93
|
+
# NOWAIT probe: witnesses whether the blocked setter is already holding the
|
|
94
|
+
# ceiling row. Never blocks, so the permutation terminates in both orders.
|
|
95
|
+
session "probe"
|
|
96
|
+
step "probe_begin" { BEGIN; }
|
|
97
|
+
step "probe_ceiling_row" {
|
|
98
|
+
select max_grantable_role from agent_board.board_authority_ceilings
|
|
99
|
+
where board_id = (select id from agent_board.boards where slug='ceiling-order-board')
|
|
100
|
+
for update nowait;
|
|
101
|
+
}
|
|
102
|
+
step "probe_commit" { COMMIT; }
|
|
103
|
+
|
|
104
|
+
session "check"
|
|
105
|
+
step "check" {
|
|
106
|
+
select max_grantable_role from agent_board.board_authority_ceilings
|
|
107
|
+
where board_id = (select id from agent_board.boards where slug='ceiling-order-board');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# THE ORDERING PROBE. The setter is parked on the advisory lock; the ceiling row
|
|
111
|
+
# must still be free.
|
|
112
|
+
permutation "holder_begin" "hold_ceiling_lock" "setter_begin" "set_ceiling" "probe_begin" "probe_ceiling_row" "probe_commit" "holder_commit" "setter_commit" "check"
|
|
113
|
+
|
|
114
|
+
# Uncontended control: with nobody holding the advisory lock the setter completes
|
|
115
|
+
# in one step, so a `<waiting ...>` above is attributable to the lock and not to
|
|
116
|
+
# some unrelated serialization.
|
|
117
|
+
permutation "setter_begin" "set_ceiling" "setter_commit" "probe_begin" "probe_ceiling_row" "probe_commit" "check"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# CLAIMGATE/SHADOW (#133) — the ownerless per-agent active-claim QUOTA is transactionally
|
|
2
|
+
# serialized. The agent already holds (quota - 1) = 49 active 'file' claims. Two sessions then
|
|
3
|
+
# each acquire ONE MORE ownerless claim on DISTINCT keys concurrently. The row-level uniqueness
|
|
4
|
+
# index does NOT serialize them (different keys), so a naive count-before-insert would let BOTH
|
|
5
|
+
# observe 49 (< 50) and BOTH insert — 51 active claims, quota bypassed.
|
|
6
|
+
#
|
|
7
|
+
# The shipped acquire_claim takes an actor-scoped pg_advisory_xact_lock BEFORE the count, so the
|
|
8
|
+
# two acquisitions by the same actor SERIALIZE: session sb BLOCKS on sa's lock until sa commits,
|
|
9
|
+
# then counts 50 (>= 50) and is DENIED (errcode 54000). Final state: exactly 50 active claims.
|
|
10
|
+
#
|
|
11
|
+
# WHAT THIS PINS (a TRACE): sb's acquire prints `<waiting ...>` on sa's advisory lock; after sa
|
|
12
|
+
# commits, sb COMPLETES with `ERROR: ownerless active-claim quota reached`. The checker sees 50.
|
|
13
|
+
#
|
|
14
|
+
# MUTATION SIGNAL (verified in the PR): remove the pg_advisory_xact_lock line from acquire_claim
|
|
15
|
+
# -> sb no longer waits, both count 49 and both INSERT, no error line, and the checker sees 51.
|
|
16
|
+
# The golden diverges on both the missing `<waiting ...>` and the 50-vs-51 count.
|
|
17
|
+
#
|
|
18
|
+
# Sessions run as `authenticated` (NOT service_role — the operator carve-out exempts service_role
|
|
19
|
+
# from the quota), with request.jwt.claim.sub selecting the quota agent.
|
|
20
|
+
|
|
21
|
+
setup
|
|
22
|
+
{
|
|
23
|
+
-- SHADOW ships the ownerless guards OFF by default; this spec pins the quota ENFORCEMENT path,
|
|
24
|
+
-- so enable the guard flag first (the state an operator/ENFORCE reaches post-census).
|
|
25
|
+
insert into agent_board.claimgate_enforcement_flags(scope_key, enabled) values ('ownerless_guards', true)
|
|
26
|
+
on conflict (scope_key) do update set enabled = true;
|
|
27
|
+
insert into auth.users (id) values ('c0a70000-0000-4000-8000-0000000000aa');
|
|
28
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id)
|
|
29
|
+
values ('c0a70000-0000-4000-8000-0000000000bb', 'agent', 'agent', 'cg-quota-iso', 'CG Quota Iso', 'active',
|
|
30
|
+
'c0a70000-0000-4000-8000-0000000000aa', 'c0a70000-0000-4000-8000-0000000000aa');
|
|
31
|
+
-- seed (quota - 1) active file claims for the agent.
|
|
32
|
+
insert into agent_board.claims(agent_id, resource_kind, resource_key, lease_expires_at)
|
|
33
|
+
select 'c0a70000-0000-4000-8000-0000000000bb', 'file', 'cg-iso-seed-' || g, now() + interval '1 hour'
|
|
34
|
+
from generate_series(1, agent_board._claimgate_ownerless_quota() - 1) g;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
teardown
|
|
38
|
+
{
|
|
39
|
+
delete from agent_board.claims where agent_id = 'c0a70000-0000-4000-8000-0000000000bb';
|
|
40
|
+
delete from agent_board.agents where id = 'c0a70000-0000-4000-8000-0000000000bb';
|
|
41
|
+
delete from auth.users where id = 'c0a70000-0000-4000-8000-0000000000aa';
|
|
42
|
+
delete from agent_board.claimgate_enforcement_flags where scope_key = 'ownerless_guards';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# sa acquires the 50th (=quota) claim: it takes the actor advisory lock, counts 49 (< 50), inserts,
|
|
46
|
+
# and holds the lock until commit.
|
|
47
|
+
session "sa"
|
|
48
|
+
step "sa_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = 'c0a70000-0000-4000-8000-0000000000aa'; }
|
|
49
|
+
step "sa_acq" {
|
|
50
|
+
select resource_key
|
|
51
|
+
from agent_board.acquire_claim(null, 'file', 'cg-iso-sa', now() + interval '1 hour', null, null);
|
|
52
|
+
}
|
|
53
|
+
step "sa_commit" { COMMIT; }
|
|
54
|
+
|
|
55
|
+
# sb acquires a DIFFERENT-key claim: its acquire blocks on sa's advisory lock, then on sa commit
|
|
56
|
+
# counts 50 (>= 50) and raises the quota error.
|
|
57
|
+
session "sb"
|
|
58
|
+
step "sb_begin" { BEGIN; SET LOCAL ROLE authenticated; SET LOCAL "request.jwt.claim.role" = 'authenticated'; SET LOCAL "request.jwt.claim.sub" = 'c0a70000-0000-4000-8000-0000000000aa'; }
|
|
59
|
+
step "sb_acq" {
|
|
60
|
+
select resource_key
|
|
61
|
+
from agent_board.acquire_claim(null, 'file', 'cg-iso-sb', now() + interval '1 hour', null, null);
|
|
62
|
+
}
|
|
63
|
+
step "sb_commit" { COMMIT; }
|
|
64
|
+
|
|
65
|
+
# checker: exactly 50 active file claims survive (sa's succeeded, sb's was denied).
|
|
66
|
+
session "chk"
|
|
67
|
+
step "chk" {
|
|
68
|
+
select count(*)::int as active_file_claims
|
|
69
|
+
from agent_board.claims
|
|
70
|
+
where agent_id = 'c0a70000-0000-4000-8000-0000000000bb'
|
|
71
|
+
and resource_kind = 'file'
|
|
72
|
+
and status = 'active'
|
|
73
|
+
and released_at is null
|
|
74
|
+
and lease_expires_at > now();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
permutation "sa_begin" "sa_acq" "sb_begin" "sb_acq" "sa_commit" "sb_commit" "chk"
|
|
@@ -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,117 @@
|
|
|
1
|
+
# Consiliency/message-board#194 — enroll_federated_identity is the THIRD federated
|
|
2
|
+
# write route and the one that inverted board/owner order. It was a session in NO
|
|
3
|
+
# lock-order spec, which is why the cycle shipped: atomic_fedsession_fleet_lock_order
|
|
4
|
+
# pairs enroll_federated_human against onboard_federated_fleet_agent only.
|
|
5
|
+
#
|
|
6
|
+
# WHAT IS ASSERTED, AND WHY IT IS NOT THE DEADLOCK ITSELF.
|
|
7
|
+
# The invariant is an ORDER: this route must hold the invite's board BEFORE it takes
|
|
8
|
+
# the auth.users owner lock. A guard session holds the owner row, so the enrollment
|
|
9
|
+
# blocks there in BOTH the pre-fix and post-fix schema -- and a third session then
|
|
10
|
+
# asks, with NOWAIT, whether the board is already held:
|
|
11
|
+
#
|
|
12
|
+
# pre-fix the board is FREE while the enrollment waits on the owner
|
|
13
|
+
# -> `bprobe_board_nowait` SUCCEEDS and returns the row
|
|
14
|
+
# post-fix the enrollment already holds the board FOR UPDATE
|
|
15
|
+
# -> it fails with 55P03 could not obtain lock on relation "boards"
|
|
16
|
+
#
|
|
17
|
+
# The `check` row is IDENTICAL in both runs (the enrollment completes and redeems
|
|
18
|
+
# once the guard commits). Do not read that as "no difference": the entire golden
|
|
19
|
+
# diff is the bprobe step, and that step IS the ordering claim.
|
|
20
|
+
#
|
|
21
|
+
# WHY NOT DRIVE THE ABBA DIRECTLY. A permutation pairing this route against
|
|
22
|
+
# enroll_federated_human does reproduce the real cycle -- measured on the pre-fix
|
|
23
|
+
# schema: pg_stat_database.deadlocks = 1, and with ONLY the class-40 re-raise applied
|
|
24
|
+
# the victim surfaces `ERROR: deadlock detected`. But as shipped, the victim SWALLOWED
|
|
25
|
+
# 40P01, returned success with invite_redeemed=f and no membership, and held the owner
|
|
26
|
+
# row to commit -- so the counterpart session waited on it indefinitely and the
|
|
27
|
+
# permutation had to be interrupted. An unbounded hang is not a failing test; it is
|
|
28
|
+
# indistinguishable from an environment fault, and this runner has no per-spec
|
|
29
|
+
# timeout. The shape below terminates in both schemas and discriminates in both.
|
|
30
|
+
|
|
31
|
+
setup
|
|
32
|
+
{
|
|
33
|
+
insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
|
|
34
|
+
values ('194b0000-0000-0000-0000-000000000001', 'agent', 'agent',
|
|
35
|
+
'lo194-iso-owner', 'LO194 Iso Owner', 'active');
|
|
36
|
+
|
|
37
|
+
select count(*) from agent_board.create_board(
|
|
38
|
+
'194b0000-0000-0000-0000-000000000001',
|
|
39
|
+
'lo194-iso-board', 'LO194 Iso Board',
|
|
40
|
+
'Consiliency/message-board#194 enroll lock-order proof',
|
|
41
|
+
jsonb_build_object(), 'private'
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
insert into agent_board.identity_providers(
|
|
45
|
+
issuer, audience, supported, policy_approved,
|
|
46
|
+
allowed_scopes, allowed_board_scopes, allowed_agent_kinds
|
|
47
|
+
) values (
|
|
48
|
+
'https://lo194-iso.test', 'message-board', true, true,
|
|
49
|
+
ARRAY[]::text[], ARRAY[]::text[], ARRAY['agent','human']
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
insert into agent_board.board_invite_tokens(
|
|
53
|
+
code, board_id, preset_role, preset_membership_state, max_uses,
|
|
54
|
+
expires_at, created_by_agent_id)
|
|
55
|
+
select 'lo194-iso-code', b.id, 'member', 'joined', 10,
|
|
56
|
+
now() + interval '1 hour', '194b0000-0000-0000-0000-000000000001'
|
|
57
|
+
from agent_board.boards b
|
|
58
|
+
where b.slug = 'lo194-iso-board';
|
|
59
|
+
|
|
60
|
+
-- Materialize the owner auth.users row the enrollment will derive, so the guard
|
|
61
|
+
-- holds the SAME row (a race to INSERT it would block on the unique index, not on
|
|
62
|
+
-- the lock order under test).
|
|
63
|
+
create table lo194_owner as
|
|
64
|
+
select agent_board._ensure_agent_auth_user(
|
|
65
|
+
'fed-human-' || substr(
|
|
66
|
+
encode(extensions.digest(
|
|
67
|
+
convert_to('https://lo194-iso.test' || '|' || 'iso-human', 'UTF8'), 'sha256'), 'hex'),
|
|
68
|
+
1, 32)) as uid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Holds the human owner row, so the enrollment parks on it in either schema.
|
|
72
|
+
session "ohold"
|
|
73
|
+
step "ohold_begin" { BEGIN; }
|
|
74
|
+
step "ohold_lock" {
|
|
75
|
+
select 1 as locked from auth.users u
|
|
76
|
+
where u.id = (select uid from lo194_owner)
|
|
77
|
+
for update;
|
|
78
|
+
}
|
|
79
|
+
step "ohold_commit" { COMMIT; }
|
|
80
|
+
|
|
81
|
+
session "enroll"
|
|
82
|
+
step "enroll_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
|
|
83
|
+
step "enroll_enroll" {
|
|
84
|
+
select agent_handle, invite_redeemed
|
|
85
|
+
from agent_board.enroll_federated_identity(
|
|
86
|
+
'https://lo194-iso.test', 'message-board', 'iso-human',
|
|
87
|
+
jsonb_build_object('iss','https://lo194-iso.test','aud','message-board','sub','iso-human'),
|
|
88
|
+
'lo194-iso-agent',
|
|
89
|
+
public.test_pubkey('lo194-iso-k1'), 'raw_ed25519_base64url',
|
|
90
|
+
public.test_key_fingerprint('lo194-iso-k1'),
|
|
91
|
+
true, null, 'agent', ARRAY[]::text[], ARRAY[]::text[],
|
|
92
|
+
'lo194-iso-code'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
step "enroll_commit" { COMMIT; }
|
|
96
|
+
|
|
97
|
+
# THE ORDERING ASSERTION. One unwrapped statement, so whatever lock it takes is
|
|
98
|
+
# released the instant it returns and no other session can ever wait on it.
|
|
99
|
+
session "bprobe"
|
|
100
|
+
step "bprobe_board_nowait" {
|
|
101
|
+
select slug from agent_board.boards where slug='lo194-iso-board' for update nowait;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
session "check"
|
|
105
|
+
step "check" {
|
|
106
|
+
select
|
|
107
|
+
(select count(*) from agent_board.board_memberships m
|
|
108
|
+
join agent_board.agents a on a.id = m.agent_id
|
|
109
|
+
where m.board_id = (select id from agent_board.boards where slug='lo194-iso-board')
|
|
110
|
+
and a.handle = 'lo194-iso-agent'
|
|
111
|
+
and m.membership_state = 'joined') as enrolled_agent_memberships,
|
|
112
|
+
(select count(*) from agent_board.board_invite_redemptions r
|
|
113
|
+
join agent_board.board_invite_tokens t on t.id = r.invite_id
|
|
114
|
+
where t.code = 'lo194-iso-code') as redemptions;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
permutation "ohold_begin" "ohold_lock" "enroll_begin" "enroll_enroll" "bprobe_board_nowait" "ohold_commit" "enroll_commit" "check"
|