@consiliency/agent-board-schema 1.10.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 (109) hide show
  1. package/fixtures/already-applied.json +136 -0
  2. package/migrations/20260729000100_agent_board_fedsession_autojoin_default_board.sql +364 -0
  3. package/migrations/20260730000100_agent_board_gatenull_service_role_bypass.sql +369 -0
  4. package/migrations/20260730000200_agent_board_membstate_authz.sql +195 -0
  5. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  6. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  7. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  8. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  9. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  10. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  11. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  12. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  13. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  14. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  15. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  16. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  17. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  18. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  19. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  20. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  21. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  22. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  23. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  24. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  25. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  26. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  27. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  28. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  29. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  30. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  31. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  32. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  33. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  34. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  35. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  36. package/package.json +1 -1
  37. package/schema-manifest.json +309 -3
  38. package/src/tooling.test.mjs +123 -2
  39. package/tests/agent_board_actorspoof.test.sql +349 -0
  40. package/tests/agent_board_agentsdir.test.sql +154 -0
  41. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  42. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  43. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  44. package/tests/agent_board_claimkey.test.sql +229 -0
  45. package/tests/agent_board_consent_backfill.test.sql +48 -0
  46. package/tests/agent_board_consent_emission.test.sql +601 -0
  47. package/tests/agent_board_consent_gating.test.sql +489 -0
  48. package/tests/agent_board_consent_substrate.test.sql +720 -0
  49. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  50. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  51. package/tests/agent_board_delegation_policies.test.sql +107 -1
  52. package/tests/agent_board_fedsession.test.sql +6 -4
  53. package/tests/agent_board_fedsession_autojoin.test.sql +181 -0
  54. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  55. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  56. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  57. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  58. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  59. package/tests/agent_board_fleet_policy.test.sql +2 -2
  60. package/tests/agent_board_gatenull_membstate.test.sql +352 -0
  61. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  62. package/tests/agent_board_identity_providers.test.sql +9 -0
  63. package/tests/agent_board_inbox_reads.test.sql +29 -12
  64. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  65. package/tests/agent_board_invitepolicy.test.sql +205 -0
  66. package/tests/agent_board_leaveidem.test.sql +194 -0
  67. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  68. package/tests/agent_board_mint_harden.test.sql +303 -0
  69. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  70. package/tests/agent_board_obsgate.test.sql +178 -0
  71. package/tests/agent_board_obsgate2.test.sql +153 -0
  72. package/tests/agent_board_obsgate3.test.sql +170 -0
  73. package/tests/agent_board_obsgate4.test.sql +196 -0
  74. package/tests/agent_board_portal_offline_invariant.test.sql +13 -2
  75. package/tests/agent_board_pubexec_class.test.sql +53 -0
  76. package/tests/agent_board_rejectsem.test.sql +271 -0
  77. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  78. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  79. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  80. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  81. package/tests/agent_board_rolereset.test.sql +351 -0
  82. package/tests/agent_board_roomdeleg.test.sql +5 -3
  83. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  84. package/tests/agent_board_roomlink.test.sql +25 -9
  85. package/tests/agent_board_rowid.test.sql +1274 -0
  86. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  87. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  88. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  89. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  90. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  91. package/tests/agent_board_signing_store.test.sql +6 -6
  92. package/tests/fixtures/isolation-substrate.sql +93 -0
  93. package/tests/fixtures/test-prereqs.sql +33 -0
  94. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  95. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  96. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  97. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  98. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  99. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  100. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  101. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  102. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  103. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  104. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  105. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  106. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  107. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  108. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  109. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
@@ -0,0 +1,190 @@
1
+ -- #141 register_fleet_machine fleet-ownership hijack.
2
+ --
3
+ -- machine_fingerprint is globally unique, so a fingerprint conflict can target ANY owner's
4
+ -- machine. The fix gates the `on conflict do update` with `where owner_agent_id = <resolved
5
+ -- actor>` and raises 42501 when the conflicting row belongs to a different owner. These
6
+ -- assertions pin: (a) a cross-owner re-register RAISES and leaves the victim row untouched,
7
+ -- (b) a same-owner re-register still updates descriptive fields, and (c) a same-owner
8
+ -- re-register does NOT resurrect a deliberately revoked machine.
9
+ --
10
+ -- Tests run as session_user=postgres => _is_service_role() is true => _resolved_actor_agent_id
11
+ -- returns the supplied p_owner_agent_id verbatim, so owner A and owner B are addressed
12
+ -- directly. (The authenticated-actor path -- p_owner must match current_agent_id() -- is
13
+ -- already covered by agent_board_fleet_policy.test.sql's impersonation block.)
14
+
15
+ create extension if not exists pgtap with schema extensions;
16
+ set search_path = extensions, public, agent_board;
17
+
18
+ begin;
19
+
20
+ select plan(14);
21
+
22
+ select has_function(
23
+ 'agent_board', 'register_fleet_machine',
24
+ array['uuid', 'uuid', 'text', 'text', 'text', 'jsonb'],
25
+ 'register_fleet_machine exists'
26
+ );
27
+
28
+ create temp table _hijack_fx (
29
+ owner_a uuid,
30
+ owner_b uuid,
31
+ domain_a uuid,
32
+ domain_b uuid,
33
+ machine uuid
34
+ ) on commit drop;
35
+
36
+ grant all on table _hijack_fx to authenticated, service_role;
37
+
38
+ do $$
39
+ declare
40
+ v_owner_a uuid := gen_random_uuid();
41
+ v_owner_b uuid := gen_random_uuid();
42
+ v_domain_a uuid;
43
+ v_domain_b uuid;
44
+ v_machine uuid;
45
+ begin
46
+ insert into agent_board.agents(id, agent_type, handle, display_name)
47
+ values
48
+ (v_owner_a, 'agent', 'fleet-hijack-owner-a', 'Fleet Hijack Owner A'),
49
+ (v_owner_b, 'agent', 'fleet-hijack-owner-b', 'Fleet Hijack Owner B');
50
+
51
+ insert into agent_board.fleet_trust_domains(slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
52
+ values
53
+ ('fleet-hijack-domain-a', 'Hijack Domain A', 'tailnet', v_owner_a, 'active', now()),
54
+ ('fleet-hijack-domain-b', 'Hijack Domain B', 'external', v_owner_b, 'active', now());
55
+
56
+ select id into v_domain_a from agent_board.fleet_trust_domains where slug = 'fleet-hijack-domain-a';
57
+ select id into v_domain_b from agent_board.fleet_trust_domains where slug = 'fleet-hijack-domain-b';
58
+
59
+ -- Owner A registers the machine that owner B will try to steal.
60
+ select machine_id
61
+ into v_machine
62
+ from agent_board.register_fleet_machine(
63
+ v_owner_a, v_domain_a, 'owner-a-claw', 'sha256:fleet-hijack-fixture', 'portal', '{"redacted":true}'::jsonb
64
+ );
65
+
66
+ insert into _hijack_fx(owner_a, owner_b, domain_a, domain_b, machine)
67
+ values (v_owner_a, v_owner_b, v_domain_a, v_domain_b, v_machine);
68
+ end
69
+ $$;
70
+
71
+ -- (1) Cross-owner re-register of an existing fingerprint RAISES 42501.
72
+ -- Mutation pin: neutering the `where owner_agent_id = v_actor_agent_id` gate on the
73
+ -- do-update (e.g. `where true`) lets the cross-owner update proceed and this THROWS_OK
74
+ -- flips to `not ok` (the call lives instead of raising).
75
+ select throws_ok(
76
+ $$ select * from agent_board.register_fleet_machine(
77
+ (select owner_b from _hijack_fx),
78
+ (select domain_b from _hijack_fx),
79
+ 'owner-b-steal',
80
+ 'sha256:fleet-hijack-fixture',
81
+ 'portal',
82
+ '{"redacted":true}'::jsonb
83
+ ) $$,
84
+ '42501',
85
+ NULL,
86
+ 'cross-owner re-register of an existing fingerprint raises 42501'
87
+ );
88
+
89
+ -- (2)(3) The victim row is untouched by the failed hijack: owner and lifecycle unchanged.
90
+ -- Mutation pin: additionally restoring `owner_agent_id = excluded.owner_agent_id` to the
91
+ -- do-update (the original stealing upsert) flips assertion (2) -- the owner becomes owner_b.
92
+ select is(
93
+ (select owner_agent_id from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
94
+ (select owner_a from _hijack_fx),
95
+ 'failed hijack leaves owner_agent_id unchanged (still owner A)'
96
+ );
97
+ select is(
98
+ (select lifecycle_state from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
99
+ 'active',
100
+ 'failed hijack leaves lifecycle_state unchanged (still active)'
101
+ );
102
+
103
+ -- (4)(5) No cross-owner WRITE at all: the descriptive fields the attacker supplied
104
+ -- (label, trust domain) are also untouched. My do-update writes machine_label and
105
+ -- trust_domain_id unconditionally once the owner-gate opens, so neutering that gate
106
+ -- (mutation 1) flips these directly -- pinning the no-cross-owner-write property, not
107
+ -- just the raise.
108
+ select is(
109
+ (select machine_label from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
110
+ 'owner-a-claw',
111
+ 'failed hijack leaves machine_label unchanged (attacker label not written)'
112
+ );
113
+ select is(
114
+ (select trust_domain_id from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
115
+ (select domain_a from _hijack_fx),
116
+ 'failed hijack leaves trust_domain_id unchanged (attacker domain not written)'
117
+ );
118
+
119
+ -- (6) Same-owner re-register still succeeds and updates descriptive fields.
120
+ select lives_ok(
121
+ $$ create temp table _hijack_rereg as
122
+ select * from agent_board.register_fleet_machine(
123
+ (select owner_a from _hijack_fx),
124
+ (select domain_a from _hijack_fx),
125
+ 'owner-a-claw-renamed',
126
+ 'sha256:fleet-hijack-fixture',
127
+ 'portal',
128
+ '{"redacted":true}'::jsonb
129
+ ) $$,
130
+ 'same-owner re-register succeeds'
131
+ );
132
+
133
+ -- (7)(8)(9) It is the SAME row, the label updated, and the owner is still owner A.
134
+ select is(
135
+ (select machine_id from _hijack_rereg),
136
+ (select machine from _hijack_fx),
137
+ 'same-owner re-register updates the existing row (machine_id unchanged)'
138
+ );
139
+ select is(
140
+ (select machine_label from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
141
+ 'owner-a-claw-renamed',
142
+ 'same-owner re-register updates the machine label'
143
+ );
144
+ select is(
145
+ (select owner_agent_id from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
146
+ (select owner_a from _hijack_fx),
147
+ 'same-owner re-register keeps owner_agent_id = owner A'
148
+ );
149
+
150
+ -- (10) Owner A revokes their own machine.
151
+ select lives_ok(
152
+ $$ select * from agent_board.revoke_fleet_subject(
153
+ 'machine',
154
+ (select machine from _hijack_fx),
155
+ (select owner_a from _hijack_fx),
156
+ 'test revocation',
157
+ '{"redacted":true}'::jsonb
158
+ ) $$,
159
+ 'owner A revokes their machine'
160
+ );
161
+
162
+ -- (11) A same-owner re-register of the now-revoked machine still succeeds ...
163
+ select lives_ok(
164
+ $$ select * from agent_board.register_fleet_machine(
165
+ (select owner_a from _hijack_fx),
166
+ (select domain_a from _hijack_fx),
167
+ 'owner-a-claw-reregistered',
168
+ 'sha256:fleet-hijack-fixture',
169
+ 'portal',
170
+ '{"redacted":true}'::jsonb
171
+ ) $$,
172
+ 'same-owner re-register of a revoked machine succeeds'
173
+ );
174
+
175
+ -- (13)(14) ... but does NOT resurrect it: lifecycle_state and revoked_at are preserved.
176
+ -- Mutation pin: restoring `lifecycle_state = 'active', revoked_at = null` to the do-update
177
+ -- (the original un-revoke) flips both assertions.
178
+ select is(
179
+ (select lifecycle_state from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
180
+ 'revoked',
181
+ 're-register does not resurrect a revoked machine (lifecycle_state stays revoked)'
182
+ );
183
+ select ok(
184
+ (select revoked_at is not null from agent_board.fleet_machines where machine_fingerprint = 'sha256:fleet-hijack-fixture'),
185
+ 're-register does not clear revoked_at of a revoked machine'
186
+ );
187
+
188
+ select * from finish();
189
+
190
+ rollback;
@@ -0,0 +1,426 @@
1
+ -- #146 operator machine reactivation — supersede-based un-revoke.
2
+ --
3
+ -- reactivate_fleet_machine (service_role only) stamps fleet_machines.reactivated_at and
4
+ -- clears revoked_at/lifecycle so machine-revocation READERS ignore machine events
5
+ -- at-or-before that supersession point. The readers are: evaluate_fleet_sender_policy and
6
+ -- the v_fleet_machine_status read model. revoke_fleet_subject (the WRITER) advances the
7
+ -- event's effective_at to now() on re-revocation so the append-only ledger stays
8
+ -- authoritative (a re-revocation after reactivation supersedes reactivated_at).
9
+ --
10
+ -- These assertions pin:
11
+ -- * revoke → policy denies; reactivate → the SAME sender passes (authority restored).
12
+ -- * a re-revocation via the REAL writer denies again AND advances the ledger's effective_at
13
+ -- past reactivated_at (supersession is prior-only; ledger stays authoritative — #146 panel
14
+ -- MUST-FIX 1). Because now() is transaction-frozen in pgTAP, the re-revoke fixture ages
15
+ -- the original revocation (event -2h) and the reactivation (marker -1h) so the real
16
+ -- writer's now() re-revocation lands strictly after the supersession point.
17
+ -- * reactivation supersedes ONLY the machine subject: a revoked trust_domain or a revoked
18
+ -- sender agent stays denied after machine reactivation.
19
+ -- * reactivation is operator-only: an authenticated caller (even the owner) is refused.
20
+ -- * reactivating a non-revoked machine raises (nothing to reactivate).
21
+ -- * v_fleet_machine_status.machine_revoked honors reactivated_at (#146 panel MUST-FIX 2).
22
+ -- * rule #1 CLASS: every catalog-enumerated machine-subject revocation reader honors
23
+ -- reactivated_at (a new non-conforming reader names itself); the WRITER exemption is
24
+ -- asserted positively (it bumps effective_at=now()).
25
+ --
26
+ -- Tests run as session_user=postgres ⇒ _is_service_role() is true; the authz assertion flips
27
+ -- to role=authenticated (jwt claims) to exercise the body guard.
28
+ --
29
+ -- Mutation pins (rule #2):
30
+ -- * drop the machine-clause reactivated_at guard in the policy → (8) fails.
31
+ -- * revert revoke_fleet_subject's UPDATE branch (remove `effective_at = now()`) → (12) fails
32
+ -- (the aged event stays before reactivated_at → ledger not advanced).
33
+ -- * remove the policy's lifecycle_state='revoked' guard → (11) STILL passes (the advanced
34
+ -- event clause catches the re-revocation): 2-of-2 depth.
35
+ -- * remove reactivate's _is_service_role() body guard → (17) fails.
36
+ -- * add the reactivated_at guard to the trust_domain clause → (14) fails; to the agent
37
+ -- clause → (16) fails.
38
+ -- * revert v_fleet_machine_status.machine_revoked to bare event-existence → (19) fails.
39
+ -- * add any machine-subject reader that omits reactivated_at → (21) fails and names it.
40
+
41
+ create extension if not exists pgtap with schema extensions;
42
+ set search_path = extensions, public, agent_board;
43
+
44
+ begin;
45
+
46
+ select plan(22);
47
+
48
+ -- (1)(2) Shape.
49
+ select has_function(
50
+ 'agent_board', 'reactivate_fleet_machine',
51
+ array['uuid', 'uuid', 'text'],
52
+ 'reactivate_fleet_machine exists'
53
+ );
54
+ select has_column(
55
+ 'agent_board', 'fleet_machines', 'reactivated_at',
56
+ 'fleet_machines.reactivated_at supersession marker exists'
57
+ );
58
+
59
+ create temp table _react_fx (
60
+ recipient uuid,
61
+ owner uuid, -- machine owner (registers machines); also the trust-domain owner
62
+ a1 uuid, a2 uuid, a3 uuid, a4 uuid, a5 uuid, a6 uuid, -- sender agents
63
+ m1 uuid, m2 uuid, m3 uuid, m4 uuid, m5 uuid, m6 uuid, -- machines
64
+ domain_main uuid,
65
+ domain_revoked uuid,
66
+ owner_user_id uuid
67
+ ) on commit drop;
68
+
69
+ grant all on table _react_fx to authenticated, service_role;
70
+
71
+ do $$
72
+ declare
73
+ v_recipient uuid := gen_random_uuid();
74
+ v_owner uuid := gen_random_uuid();
75
+ v_a1 uuid := gen_random_uuid();
76
+ v_a2 uuid := gen_random_uuid();
77
+ v_a3 uuid := gen_random_uuid();
78
+ v_a4 uuid := gen_random_uuid();
79
+ v_a5 uuid := gen_random_uuid();
80
+ v_a6 uuid := gen_random_uuid();
81
+ v_domain_main uuid;
82
+ v_domain_revoked uuid;
83
+ v_m1 uuid; v_m2 uuid; v_m3 uuid; v_m4 uuid; v_m5 uuid; v_m6 uuid;
84
+ begin
85
+ insert into agent_board.agents(id, agent_type, handle, display_name)
86
+ values
87
+ (v_recipient, 'agent', 'react-recipient', 'React Recipient'),
88
+ (v_owner, 'agent', 'react-owner', 'React Owner'),
89
+ (v_a1, 'agent', 'react-sender-1', 'React Sender 1'),
90
+ (v_a2, 'agent', 'react-sender-2', 'React Sender 2'),
91
+ (v_a3, 'agent', 'react-sender-3', 'React Sender 3'),
92
+ (v_a4, 'agent', 'react-sender-4', 'React Sender 4'),
93
+ (v_a5, 'agent', 'react-sender-5', 'React Sender 5'),
94
+ (v_a6, 'agent', 'react-sender-6', 'React Sender 6');
95
+
96
+ insert into agent_board.fleet_trust_domains(slug, display_name, domain_kind, owner_agent_id, lifecycle_state, approved_at)
97
+ values
98
+ ('react-domain-main', 'React Domain Main', 'tailnet', v_owner, 'active', now()),
99
+ ('react-domain-revoked', 'React Domain Revoked', 'tailnet', v_owner, 'active', now());
100
+
101
+ select id into v_domain_main from agent_board.fleet_trust_domains where slug = 'react-domain-main';
102
+ select id into v_domain_revoked from agent_board.fleet_trust_domains where slug = 'react-domain-revoked';
103
+
104
+ select machine_id into v_m1 from agent_board.register_fleet_machine(v_owner, v_domain_main, 'react-m1', 'sha256:react-m1', 'portal', '{"redacted":true}'::jsonb);
105
+ select machine_id into v_m2 from agent_board.register_fleet_machine(v_owner, v_domain_revoked, 'react-m2', 'sha256:react-m2', 'portal', '{"redacted":true}'::jsonb);
106
+ select machine_id into v_m3 from agent_board.register_fleet_machine(v_owner, v_domain_main, 'react-m3', 'sha256:react-m3', 'portal', '{"redacted":true}'::jsonb);
107
+ select machine_id into v_m4 from agent_board.register_fleet_machine(v_owner, v_domain_main, 'react-m4', 'sha256:react-m4', 'portal', '{"redacted":true}'::jsonb);
108
+ select machine_id into v_m5 from agent_board.register_fleet_machine(v_owner, v_domain_main, 'react-m5', 'sha256:react-m5', 'portal', '{"redacted":true}'::jsonb);
109
+ select machine_id into v_m6 from agent_board.register_fleet_machine(v_owner, v_domain_main, 'react-m6', 'sha256:react-m6', 'portal', '{"redacted":true}'::jsonb);
110
+
111
+ insert into agent_board.fleet_machine_agents(machine_id, agent_id, agent_role)
112
+ values
113
+ (v_m1, v_a1, 'phase_worker'),
114
+ (v_m2, v_a2, 'phase_worker'),
115
+ (v_m3, v_a3, 'phase_worker'),
116
+ (v_m4, v_a4, 'phase_worker'),
117
+ (v_m5, v_a5, 'phase_worker'),
118
+ (v_m6, v_a6, 'phase_worker');
119
+
120
+ -- Allow-by-default receive policy for the recipient: a non-revoked bound machine passes,
121
+ -- so the ONLY lever these assertions exercise is the machine-revocation state.
122
+ perform agent_board.set_fleet_receive_policy(v_recipient, 'react allow-by-default', 'allow', false, null, null, '{"senderPolicy":"open"}'::jsonb);
123
+
124
+ insert into _react_fx(recipient, owner, a1, a2, a3, a4, a5, a6, m1, m2, m3, m4, m5, m6, domain_main, domain_revoked)
125
+ values (v_recipient, v_owner, v_a1, v_a2, v_a3, v_a4, v_a5, v_a6, v_m1, v_m2, v_m3, v_m4, v_m5, v_m6, v_domain_main, v_domain_revoked);
126
+ end
127
+ $$;
128
+
129
+ ------------------------------------------------------------------------------------------
130
+ -- CORE (m1): revoke → deny; reactivate → the SAME sender passes.
131
+ ------------------------------------------------------------------------------------------
132
+ do $$
133
+ begin
134
+ perform agent_board.revoke_fleet_subject('machine', (select m1 from _react_fx), (select owner from _react_fx), 'core revoke', '{"redacted":true}'::jsonb);
135
+ end
136
+ $$;
137
+
138
+ -- (3) Revoked machine is denied.
139
+ select is(
140
+ (select denial_class from agent_board.evaluate_fleet_sender_policy(
141
+ (select a1 from _react_fx), (select recipient from _react_fx), (select m1 from _react_fx), null, 'send_direct_message', null, false)),
142
+ 'machine_revoked',
143
+ 'core: revoked machine is denied before reactivation'
144
+ );
145
+
146
+ -- (4) Operator reactivation succeeds (allow-side lives_ok + separate state reads, #113).
147
+ select lives_ok(
148
+ $$ create temp table _react_m1 as
149
+ select * from agent_board.reactivate_fleet_machine(
150
+ (select m1 from _react_fx), (select owner from _react_fx), 'operator brings m1 back') $$,
151
+ 'core: service_role reactivation of m1 succeeds'
152
+ );
153
+
154
+ -- (5)(6)(7) Row is reconciled: reactivated_at stamped, lifecycle active, revoked_at cleared.
155
+ select ok(
156
+ (select reactivated_at is not null from agent_board.fleet_machines where id = (select m1 from _react_fx)),
157
+ 'core: reactivation stamps reactivated_at'
158
+ );
159
+ select is(
160
+ (select lifecycle_state from agent_board.fleet_machines where id = (select m1 from _react_fx)),
161
+ 'active',
162
+ 'core: reactivation sets lifecycle_state=active'
163
+ );
164
+ select ok(
165
+ (select revoked_at is null from agent_board.fleet_machines where id = (select m1 from _react_fx)),
166
+ 'core: reactivation clears revoked_at'
167
+ );
168
+
169
+ -- (8) The SAME sender now passes. MUTATION PIN: dropping the reactivated_at guard on the
170
+ -- machine clause makes the prior machine event match again → this flips to `not ok`.
171
+ select is(
172
+ (select allowed from agent_board.evaluate_fleet_sender_policy(
173
+ (select a1 from _react_fx), (select recipient from _react_fx), (select m1 from _react_fx), null, 'send_direct_message', null, false)),
174
+ true,
175
+ 'core: reactivation restores sender authority (same sender now passes)'
176
+ );
177
+
178
+ ------------------------------------------------------------------------------------------
179
+ -- LEDGER AUTHORITY (m6): a re-revocation via the REAL writer denies again AND advances the
180
+ -- append-only ledger past reactivated_at (#146 panel MUST-FIX 1). now() is transaction-frozen,
181
+ -- so the fixture ages the original revocation and the reactivation to create a real gap.
182
+ ------------------------------------------------------------------------------------------
183
+ do $$
184
+ begin
185
+ perform agent_board.revoke_fleet_subject('machine', (select m6 from _react_fx), (select owner from _react_fx), 'm6 initial revoke', '{"redacted":true}'::jsonb);
186
+ perform * from agent_board.reactivate_fleet_machine((select m6 from _react_fx), (select owner from _react_fx), 'm6 reactivate');
187
+ -- Simulate elapsed time (frozen now()): original revocation 2h ago, reactivation 1h ago.
188
+ -- Only fleet_machines.reactivated_at (a mutable marker) and the existing ledger row's
189
+ -- timestamp are aged; the RE-REVOCATION below uses the real writer, unmodified.
190
+ update agent_board.fleet_revocation_events
191
+ set effective_at = now() - interval '2 hours'
192
+ where subject_kind = 'machine' and subject_id = (select m6 from _react_fx);
193
+ update agent_board.fleet_machines
194
+ set reactivated_at = now() - interval '1 hour'
195
+ where id = (select m6 from _react_fx);
196
+ end
197
+ $$;
198
+
199
+ -- (9) After the aged reactivation, the aged prior revocation is superseded → sender passes.
200
+ select is(
201
+ (select allowed from agent_board.evaluate_fleet_sender_policy(
202
+ (select a6 from _react_fx), (select recipient from _react_fx), (select m6 from _react_fx), null, 'send_direct_message', null, false)),
203
+ true,
204
+ 'ledger: reactivation holds over a real gap (aged prior revocation is superseded)'
205
+ );
206
+
207
+ -- (10) Re-revoke via the REAL writer.
208
+ select lives_ok(
209
+ $$ select * from agent_board.revoke_fleet_subject('machine', (select m6 from _react_fx), (select owner from _react_fx), 'm6 re-revoke', '{"redacted":true}'::jsonb) $$,
210
+ 'ledger: re-revoking a reactivated machine via the real writer succeeds'
211
+ );
212
+
213
+ -- (11) Denied again. MUTATION PIN: with the writer fix the ledger event now post-dates
214
+ -- reactivated_at, so this denial survives removing the policy's lifecycle_state guard
215
+ -- (the event clause catches it — 2-of-2 depth).
216
+ select is(
217
+ (select denial_class from agent_board.evaluate_fleet_sender_policy(
218
+ (select a6 from _react_fx), (select recipient from _react_fx), (select m6 from _react_fx), null, 'send_direct_message', null, false)),
219
+ 'machine_revoked',
220
+ 'ledger: reactivated-then-revoked machine is denied again'
221
+ );
222
+
223
+ -- (12) MUST-FIX 1 PIN: the real writer advanced the append-only ledger past the supersession
224
+ -- point. Reverting revoke_fleet_subject's UPDATE branch (no `effective_at = now()`) leaves the
225
+ -- event at -2h < reactivated_at (-1h) → this flips to `not ok`.
226
+ select ok(
227
+ (
228
+ (select effective_at from agent_board.fleet_revocation_events
229
+ where subject_kind = 'machine' and subject_id = (select m6 from _react_fx))
230
+ > (select reactivated_at from agent_board.fleet_machines where id = (select m6 from _react_fx))
231
+ ),
232
+ 'ledger: re-revocation advances the append-only event effective_at past reactivated_at (ledger stays authoritative)'
233
+ );
234
+
235
+ ------------------------------------------------------------------------------------------
236
+ -- DOMAIN (m2): reactivating the machine does NOT un-revoke its trust domain.
237
+ ------------------------------------------------------------------------------------------
238
+ do $$
239
+ begin
240
+ perform agent_board.revoke_fleet_subject('machine', (select m2 from _react_fx), (select owner from _react_fx), 'domain-case machine revoke', '{"redacted":true}'::jsonb);
241
+ perform agent_board.revoke_fleet_subject('trust_domain', (select domain_revoked from _react_fx), (select owner from _react_fx), 'domain revoke', '{"redacted":true}'::jsonb);
242
+ end
243
+ $$;
244
+
245
+ -- (13) Reactivate the machine only.
246
+ select lives_ok(
247
+ $$ select * from agent_board.reactivate_fleet_machine((select m2 from _react_fx), (select owner from _react_fx), 'reactivate m2 in revoked domain') $$,
248
+ 'domain: reactivating m2 (whose domain is revoked) succeeds'
249
+ );
250
+
251
+ -- (14) Still denied: the trust_domain clause is unchanged by machine reactivation.
252
+ -- MUTATION PIN: adding the reactivated_at guard to the trust_domain clause flips this.
253
+ select is(
254
+ (select denial_class from agent_board.evaluate_fleet_sender_policy(
255
+ (select a2 from _react_fx), (select recipient from _react_fx), (select m2 from _react_fx), null, 'send_direct_message', null, false)),
256
+ 'machine_revoked',
257
+ 'domain: machine reactivation does NOT un-revoke a revoked trust domain'
258
+ );
259
+
260
+ ------------------------------------------------------------------------------------------
261
+ -- AGENT (m3): reactivating the machine does NOT un-revoke the sender agent.
262
+ ------------------------------------------------------------------------------------------
263
+ do $$
264
+ begin
265
+ perform agent_board.revoke_fleet_subject('machine', (select m3 from _react_fx), (select owner from _react_fx), 'agent-case machine revoke', '{"redacted":true}'::jsonb);
266
+ perform agent_board.revoke_fleet_subject('agent', (select a3 from _react_fx), (select a3 from _react_fx), 'agent revoke', '{"redacted":true}'::jsonb);
267
+ end
268
+ $$;
269
+
270
+ -- (15) Reactivate the machine only.
271
+ select lives_ok(
272
+ $$ select * from agent_board.reactivate_fleet_machine((select m3 from _react_fx), (select owner from _react_fx), 'reactivate m3 with revoked agent') $$,
273
+ 'agent: reactivating m3 (whose sender agent is revoked) succeeds'
274
+ );
275
+
276
+ -- (16) Still denied: the agent clause is unchanged by machine reactivation.
277
+ -- MUTATION PIN: adding the reactivated_at guard to the agent clause flips this.
278
+ select is(
279
+ (select denial_class from agent_board.evaluate_fleet_sender_policy(
280
+ (select a3 from _react_fx), (select recipient from _react_fx), (select m3 from _react_fx), null, 'send_direct_message', null, false)),
281
+ 'machine_revoked',
282
+ 'agent: machine reactivation does NOT un-revoke a revoked sender agent'
283
+ );
284
+
285
+ ------------------------------------------------------------------------------------------
286
+ -- AUTHZ (m4): operator-only. An authenticated caller — even the machine owner — is refused.
287
+ ------------------------------------------------------------------------------------------
288
+ do $$
289
+ begin
290
+ perform agent_board.revoke_fleet_subject('machine', (select m4 from _react_fx), (select owner from _react_fx), 'authz-case revoke', '{"redacted":true}'::jsonb);
291
+ end
292
+ $$;
293
+
294
+ -- Map the owner agent to an auth user so the authenticated path resolves a principal.
295
+ do $$
296
+ declare
297
+ v_user_id uuid;
298
+ v_suffix text := replace(substr(gen_random_uuid()::text, 1, 8), '-', '');
299
+ begin
300
+ v_user_id := public.ensure_github_user(
301
+ 'react-owner-' || v_suffix,
302
+ 'react-owner-' || v_suffix || '@example.test',
303
+ 'react-owner-' || v_suffix,
304
+ 'React Owner ' || v_suffix
305
+ );
306
+ update agent_board.agents set auth_user_id = v_user_id where id = (select owner from _react_fx);
307
+ update _react_fx set owner_user_id = v_user_id;
308
+ end
309
+ $$;
310
+
311
+ create temp table _react_authz (denied boolean) on commit drop;
312
+ grant all on table _react_authz to authenticated, service_role;
313
+
314
+ -- Isolate the BODY guard from the grant: temporarily grant EXECUTE to authenticated so a
315
+ -- call reaching the function body is refused by _is_service_role(), not the ACL. (Rolled
316
+ -- back with the test transaction.) This pins the "callable by authenticated" mutation to the
317
+ -- guard itself, independent of the service_role-only grant restated in the migration.
318
+ grant execute on function agent_board.reactivate_fleet_machine(uuid, uuid, text) to authenticated;
319
+
320
+ set local role authenticated;
321
+ select set_config('request.jwt.claim.role', 'authenticated', true);
322
+ select set_config('request.jwt.claim.sub', (select owner_user_id::text from _react_fx), true);
323
+
324
+ do $$
325
+ begin
326
+ begin
327
+ perform * from agent_board.reactivate_fleet_machine(
328
+ (select m4 from _react_fx), (select owner from _react_fx), 'authenticated owner attempt');
329
+ insert into _react_authz values (false); -- reached completion → NOT denied
330
+ exception
331
+ when sqlstate '42501' then insert into _react_authz values (true);
332
+ end;
333
+ end
334
+ $$;
335
+
336
+ reset role;
337
+ select set_config('request.jwt.claim.role', '', true);
338
+ select set_config('request.jwt.claim.sub', '', true);
339
+ revoke execute on function agent_board.reactivate_fleet_machine(uuid, uuid, text) from authenticated;
340
+
341
+ -- (17) MUTATION PIN: removing the _is_service_role() body guard lets the authenticated call
342
+ -- complete → denied=false → this flips to `not ok`.
343
+ select ok(
344
+ (select denied from _react_authz),
345
+ 'authz: authenticated caller (even the owner) cannot reactivate (42501)'
346
+ );
347
+
348
+ ------------------------------------------------------------------------------------------
349
+ -- NON-REVOKED (m5): reactivating a machine that is not revoked raises (nothing to do).
350
+ ------------------------------------------------------------------------------------------
351
+ -- (18)
352
+ select throws_ok(
353
+ $$ select * from agent_board.reactivate_fleet_machine((select m5 from _react_fx), (select owner from _react_fx), 'reactivate a live machine') $$,
354
+ '22023',
355
+ NULL,
356
+ 'non-revoked: reactivating a machine that is not revoked raises'
357
+ );
358
+
359
+ ------------------------------------------------------------------------------------------
360
+ -- READ MODEL (MUST-FIX 2): v_fleet_machine_status.machine_revoked honors reactivated_at.
361
+ ------------------------------------------------------------------------------------------
362
+ -- (19) MUTATION PIN: reverting machine_revoked to bare event-existence flips this — m1 was
363
+ -- reactivated, so its lingering (superseded) event must NOT read as revoked.
364
+ select is(
365
+ (select machine_revoked from agent_board.v_fleet_machine_status where machine_id = (select m1 from _react_fx)),
366
+ false,
367
+ 'view: a reactivated machine reports machine_revoked=false (agrees with the policy)'
368
+ );
369
+
370
+ -- (20) Sanity: a genuinely revoked-and-not-reactivated machine still reads revoked.
371
+ select is(
372
+ (select machine_revoked from agent_board.v_fleet_machine_status where machine_id = (select m4 from _react_fx)),
373
+ true,
374
+ 'view: a revoked (never reactivated) machine still reports machine_revoked=true'
375
+ );
376
+
377
+ ------------------------------------------------------------------------------------------
378
+ -- CLASS (rule #1): catalog-derived — every machine-subject revocation READER honors
379
+ -- reactivated_at; the WRITER exemption is asserted positively.
380
+ ------------------------------------------------------------------------------------------
381
+ -- (21) Enumerate readers (functions + views) whose definition tests a machine-subject
382
+ -- revocation event (`fre.subject_kind = 'machine'`) but does NOT reference reactivated_at.
383
+ -- Expected: none. A new non-conforming reader appears here BY NAME (rule #1: offenders name
384
+ -- themselves). Aggregates are excluded (pg_get_functiondef errors on them; prokind='f').
385
+ select is(
386
+ (
387
+ select string_agg(offender, ', ' order by offender)
388
+ from (
389
+ select p.proname as offender
390
+ from pg_proc p
391
+ join pg_namespace n on n.oid = p.pronamespace
392
+ where n.nspname = 'agent_board'
393
+ and p.prokind = 'f'
394
+ and pg_get_functiondef(p.oid) ~ 'fre\.subject_kind = ''machine'''
395
+ and pg_get_functiondef(p.oid) not ilike '%reactivated_at%'
396
+ union all
397
+ select c.relname
398
+ from pg_class c
399
+ join pg_namespace n on n.oid = c.relnamespace
400
+ where n.nspname = 'agent_board'
401
+ and c.relkind = 'v'
402
+ and pg_get_viewdef(c.oid) ~ 'fre\.subject_kind = ''machine'''
403
+ and pg_get_viewdef(c.oid) not ilike '%reactivated_at%'
404
+ ) offenders
405
+ ),
406
+ null,
407
+ 'class: every machine-subject revocation reader honors reactivated_at (offenders name themselves)'
408
+ );
409
+
410
+ -- (22) Positive exemption (rule #1): revoke_fleet_subject is the machine-revocation WRITER,
411
+ -- not a state reader — its exemption claim is that it advances the ledger's effective_at.
412
+ select ok(
413
+ (
414
+ select pg_get_functiondef(p.oid) ~ 'effective_at = now\(\)'
415
+ from pg_proc p
416
+ join pg_namespace n on n.oid = p.pronamespace
417
+ where n.nspname = 'agent_board'
418
+ and p.prokind = 'f'
419
+ and p.proname = 'revoke_fleet_subject'
420
+ ),
421
+ 'class exemption: revoke_fleet_subject (the writer) advances effective_at=now() on re-revocation'
422
+ );
423
+
424
+ select * from finish();
425
+
426
+ rollback;