@consiliency/agent-board-schema 1.12.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/fixtures/already-applied.json +160 -0
  2. package/migrations/20260731000100_agent_board_agentsdir_column_scope.sql +88 -0
  3. package/migrations/20260801000100_agent_board_leaveidem_terminal_noop.sql +317 -0
  4. package/migrations/20260802000100_agent_board_rolereset_reentry_clamp.sql +237 -0
  5. package/migrations/20260803000100_agent_board_rowid_total_order.sql +1201 -0
  6. package/migrations/20260804000100_agent_board_hygiene_actor_binding_rls.sql +229 -0
  7. package/migrations/20260806000100_agent_board_consent_substrate.sql +339 -0
  8. package/migrations/20260807000100_agent_board_consent_emission.sql +619 -0
  9. package/migrations/20260808000100_agent_board_rejectsem.sql +145 -0
  10. package/migrations/20260809000100_agent_board_consent_gating.sql +492 -0
  11. package/migrations/20260810000100_agent_board_consent_locking.sql +485 -0
  12. package/migrations/20260811000100_agent_board_retention_metadata_revoke.sql +23 -0
  13. package/migrations/20260812000100_agent_board_pubexec_class_revoke.sql +54 -0
  14. package/migrations/20260813000100_agent_board_invitepolicy_block.sql +420 -0
  15. package/migrations/20260814000100_agent_board_obsgate_thread_post_policy.sql +239 -0
  16. package/migrations/20260815000100_agent_board_obsgate2_authoring_gates.sql +287 -0
  17. package/migrations/20260816000100_agent_board_obsgate3_task_lifecycle.sql +118 -0
  18. package/migrations/20260817000100_agent_board_obsgate4_claims_lifecycle.sql +250 -0
  19. package/migrations/20260818000100_agent_board_fleet_machines_rls_correlation.sql +43 -0
  20. package/migrations/20260819000100_agent_board_resolve_noop_retire.sql +166 -0
  21. package/migrations/20260820000100_agent_board_roomdeleg_lane_b.sql +206 -0
  22. package/migrations/20260821000100_agent_board_claimgate_claimkey.sql +372 -0
  23. package/migrations/20260822000100_agent_board_mint_harden.sql +710 -0
  24. package/migrations/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql +207 -0
  25. package/migrations/20260823000100_agent_board_fleet_machine_hijack.sql +106 -0
  26. package/migrations/20260824000100_agent_board_claimgate_shadow.sql +443 -0
  27. package/migrations/20260825000100_agent_board_claimgate_enforce.sql +660 -0
  28. package/migrations/20260826000100_agent_board_fleet_machine_reactivation.sql +514 -0
  29. package/migrations/20260828000100_agent_board_delegation_cap_ceiling.sql +474 -0
  30. package/migrations/20260829000100_agent_board_fleet_machine_reclaim.sql +432 -0
  31. package/migrations/20260830000100_agent_board_delegation_sole_writer.sql +41 -0
  32. package/migrations/20260831000100_agent_board_fleet_domain_reclaim.sql +389 -0
  33. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  34. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  35. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  36. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  37. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  38. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  39. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  40. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  41. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  42. package/package.json +1 -1
  43. package/schema-manifest.json +363 -3
  44. package/src/tooling.test.mjs +113 -2
  45. package/tests/agent_board_actorspoof.test.sql +349 -0
  46. package/tests/agent_board_agentsdir.test.sql +154 -0
  47. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  48. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  49. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  50. package/tests/agent_board_authbound_boundary.test.sql +9 -1
  51. package/tests/agent_board_claimgate_enforce.test.sql +464 -0
  52. package/tests/agent_board_claimgate_predicate.test.sql +360 -0
  53. package/tests/agent_board_claimkey.test.sql +229 -0
  54. package/tests/agent_board_consent_backfill.test.sql +48 -0
  55. package/tests/agent_board_consent_emission.test.sql +601 -0
  56. package/tests/agent_board_consent_gating.test.sql +489 -0
  57. package/tests/agent_board_consent_substrate.test.sql +720 -0
  58. package/tests/agent_board_delegation_cap_ceiling.test.sql +303 -0
  59. package/tests/agent_board_delegation_enforcement.test.sql +55 -6
  60. package/tests/agent_board_delegation_policies.test.sql +107 -1
  61. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  62. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  63. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  64. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  65. package/tests/agent_board_fedsession.test.sql +51 -5
  66. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  67. package/tests/agent_board_fleet_domain_reclaim.test.sql +419 -0
  68. package/tests/agent_board_fleet_machine_hijack.test.sql +190 -0
  69. package/tests/agent_board_fleet_machine_reactivation.test.sql +426 -0
  70. package/tests/agent_board_fleet_machine_reclaim.test.sql +509 -0
  71. package/tests/agent_board_fleet_machines_rls.test.sql +79 -0
  72. package/tests/agent_board_fleet_policy.test.sql +2 -2
  73. package/tests/agent_board_grants_hygiene.test.sql +408 -0
  74. package/tests/agent_board_identity_providers.test.sql +9 -0
  75. package/tests/agent_board_inbox_reads.test.sql +45 -31
  76. package/tests/agent_board_inbox_rpcs.test.sql +44 -34
  77. package/tests/agent_board_invitepolicy.test.sql +205 -0
  78. package/tests/agent_board_leaveidem.test.sql +194 -0
  79. package/tests/agent_board_mint_fleet_agent_session.test.sql +10 -10
  80. package/tests/agent_board_mint_harden.test.sql +303 -0
  81. package/tests/agent_board_mint_harden_backfill.test.sql +95 -0
  82. package/tests/agent_board_obsgate.test.sql +178 -0
  83. package/tests/agent_board_obsgate2.test.sql +153 -0
  84. package/tests/agent_board_obsgate3.test.sql +170 -0
  85. package/tests/agent_board_obsgate4.test.sql +196 -0
  86. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  87. package/tests/agent_board_portal_offline_invariant.test.sql +14 -3
  88. package/tests/agent_board_pubexec_class.test.sql +53 -0
  89. package/tests/agent_board_rejectsem.test.sql +271 -0
  90. package/tests/agent_board_resigning_refresh.test.sql +1 -1
  91. package/tests/agent_board_resolve_noop_retire.test.sql +71 -0
  92. package/tests/agent_board_retention_metadata_acl.test.sql +53 -0
  93. package/tests/agent_board_revoke_agent_subtree.test.sql +3 -3
  94. package/tests/agent_board_rolereset.test.sql +351 -0
  95. package/tests/agent_board_roomdeleg.test.sql +5 -3
  96. package/tests/agent_board_roomdeleg_lane_b.test.sql +144 -0
  97. package/tests/agent_board_roomlink.test.sql +27 -11
  98. package/tests/agent_board_rowid.test.sql +1274 -0
  99. package/tests/agent_board_signature_rls_reads.test.sql +3 -3
  100. package/tests/agent_board_signed_write_gateway.test.sql +3 -3
  101. package/tests/agent_board_signing_challenge_pending_cap.test.sql +2 -2
  102. package/tests/agent_board_signing_challenge_reaper.test.sql +2 -2
  103. package/tests/agent_board_signing_proof_rpcs.test.sql +4 -4
  104. package/tests/agent_board_signing_store.test.sql +6 -6
  105. package/tests/agent_board_wake_recovery.test.sql +756 -0
  106. package/tests/fixtures/isolation-substrate.sql +93 -0
  107. package/tests/fixtures/test-prereqs.sql +33 -0
  108. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  109. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  110. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  111. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  112. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  113. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  114. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  115. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  116. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  117. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  118. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  119. package/tests/isolation/expected/claimgate_ownerless_quota.out +36 -0
  120. package/tests/isolation/expected/claimkey_renew_race.out +38 -0
  121. package/tests/isolation/expected/consent_approval_lock.out +43 -0
  122. package/tests/isolation/expected/consent_emit_actual_state.out +37 -0
  123. package/tests/isolation/expected/domain_reclaim_revoke_race.out +40 -0
  124. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  125. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  126. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  127. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  128. package/tests/isolation/expected/reactivate_revoke_race.out +80 -0
  129. package/tests/isolation/expected/reclaim_revoke_race.out +40 -0
  130. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  131. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  132. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  133. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  134. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  135. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  136. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  137. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  138. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  139. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  140. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  141. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  142. package/tests/isolation/specs/claimgate_ownerless_quota.spec +77 -0
  143. package/tests/isolation/specs/claimkey_renew_race.spec +67 -0
  144. package/tests/isolation/specs/consent_approval_lock.spec +98 -0
  145. package/tests/isolation/specs/consent_emit_actual_state.spec +79 -0
  146. package/tests/isolation/specs/domain_reclaim_revoke_race.spec +88 -0
  147. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  148. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  149. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  150. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  151. package/tests/isolation/specs/reactivate_revoke_race.spec +115 -0
  152. package/tests/isolation/specs/reclaim_revoke_race.spec +94 -0
  153. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  154. package/tests/seeds/20260806000100_agent_board_consent_substrate.sql.pre.sql +19 -0
  155. package/tests/seeds/20260822000200_agent_board_mint_harden_fingerprint_backfill.sql.pre.sql +39 -0
  156. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,604 @@
1
+ -- Consiliency/message-board#194 -- enroll_federated_identity took the human owner
2
+ -- BEFORE the board, inverting the canonical order the other two federated write
3
+ -- routes already use, and then SWALLOWED the resulting serialization failure.
4
+ --
5
+ -- THE CYCLE (reproduced on a private database, real `deadlock detected` output):
6
+ -- enroll_federated_identity auth.users FOR UPDATE -> boards FOR KEY SHARE
7
+ -- (:504) (via board_memberships_board_id_fkey,
8
+ -- inside the invite redemption)
9
+ -- enroll_federated_human boards FOR UPDATE -> auth.users FOR UPDATE
10
+ -- onboard_federated_fleet_agent
11
+ -- boards FOR UPDATE -> auth.users FOR UPDATE
12
+ -- Two of three routes already took board -> owner, so board -> owner is canonical
13
+ -- and this migration moves the third onto it rather than moving the other two.
14
+ --
15
+ -- WHY NO TEXTUAL CHECK FOUND IT: enroll_federated_identity never names `boards`.
16
+ -- Its boards lock arrives through an FK on a table it inserts into via a callee.
17
+ -- The class assertion added with this migration
18
+ -- (agent_board_federated_lock_order_class.test.sql) is therefore derived from
19
+ -- pg_constraint FK edges plus the pg_proc call graph, not from prosrc text: it
20
+ -- enumerates every agent_board function that acquires BOTH a boards row lock and
21
+ -- an auth.users row lock -- through an explicit lock OR through an INSERT into a
22
+ -- direct FK child of either table -- and requires the board acquisition first.
23
+ -- Against the pre-fix schema it flags exactly the two functions this migration
24
+ -- changes, and names them.
25
+ --
26
+ -- THE MASKING, which is the more serious half. The redemption sub-block caught
27
+ -- `others` and set invite_redeemed := false. That catches 40P01/40001, so under
28
+ -- contention the RPC returned SUCCESS with no board membership and no error to
29
+ -- the client (measured: uncontended invite_redeemed=t + membership row; under the
30
+ -- cycle invite_redeemed=f + none). Class 40 is now re-raised at BOTH sites -- the
31
+ -- wrapper's replay branch and _enroll_federated_identity_once step (13) -- while
32
+ -- every deterministic failure (bad/expired/exhausted/unauthorized code) keeps the
33
+ -- released soft-honor behaviour.
34
+ --
35
+ -- Both functions are recreated with CREATE OR REPLACE (ACL and COMMENT survive);
36
+ -- the grants and comments are restated below regardless, per the repo rule.
37
+ -- The bodies are otherwise byte-identical to their released sources
38
+ -- (20260901000100 for the wrapper, 20260822000100 for the renamed core).
39
+ --
40
+ -- WHY THE HOISTED LOCK IS *FOR KEY SHARE*. It stands in for an acquisition the
41
+ -- redemption makes through board_memberships_board_id_fkey, and that acquisition IS
42
+ -- FOR KEY SHARE. Taking the same mode early establishes the identical ordering
43
+ -- point -- KEY SHARE conflicts with the FOR UPDATE that enroll_federated_human and
44
+ -- onboard_federated_fleet_agent take on a boards row, which is the conflict the
45
+ -- ordering needs -- without serializing two concurrent enrollments that merely share
46
+ -- a board. A FOR UPDATE here (the first cut) would have been strictly stronger than
47
+ -- the thing it replaces and would have blocked every concurrent membership write on
48
+ -- that board for the length of an enrollment: throughput, not correctness, but not
49
+ -- a cost this migration needs to impose.
50
+ --
51
+ -- TWO ADVISORY-LOCK EDGES THIS ROUTE NOW HAS, recorded so the next person to move a
52
+ -- lock does not have to rediscover them:
53
+ -- * pg_advisory_xact_lock(hashtextextended(<key fingerprint>, 0)) is taken by BOTH
54
+ -- enroll_federated_identity (below) and onboard_federated_fleet_agent
55
+ -- (20260901000110:332). After this migration enroll runs boards -> fp-advisory,
56
+ -- and onboard runs boards -> ... -> fp-advisory: SAME direction, so the pair
57
+ -- holds. It holds because onboard takes its boards lock first for its own
58
+ -- reasons, not because anything enforced it -- moving either acquisition across
59
+ -- the other reopens a cycle.
60
+ -- * This route does NOT take _lock_board_authority_ceiling. It never acquires that
61
+ -- advisory lock at all, so taking boards without it cannot form the
62
+ -- advisory<->boards cycle 20260901000110's header describes; only
63
+ -- onboard_federated_fleet_agent and set_board_authority_ceiling participate in
64
+ -- that lock (enumerated from pg_proc).
65
+
66
+ -- 1. the internal one-time creator (renamed core).
67
+ create or replace function agent_board._enroll_federated_identity_once(
68
+ p_issuer text,
69
+ p_audience text,
70
+ p_subject text,
71
+ p_human_identity_claim jsonb,
72
+ p_slug text,
73
+ p_agent_public_key text,
74
+ p_agent_public_key_format text,
75
+ p_agent_public_key_fingerprint text,
76
+ p_pop_verified boolean,
77
+ p_actor_sub text default null,
78
+ p_agent_kind text default 'agent',
79
+ p_requested_scopes text[] default '{}',
80
+ p_requested_board_scopes text[] default '{}',
81
+ p_invite_code text default null
82
+ )
83
+ returns table(
84
+ agent_id uuid,
85
+ auth_user_id uuid,
86
+ provider_id text,
87
+ signing_key_id uuid,
88
+ granted_scopes text[],
89
+ key_id text,
90
+ key_version int,
91
+ agent_handle text,
92
+ invite_redeemed boolean
93
+ )
94
+ language plpgsql
95
+ security definer
96
+ set search_path = agent_board, public
97
+ as $$
98
+ declare
99
+ v_slug text;
100
+ v_kind text;
101
+ v_provider agent_board.identity_providers%rowtype;
102
+ v_owner_user_id uuid;
103
+ v_auth_user_id uuid;
104
+ v_agent_handle text;
105
+ v_agent_id uuid;
106
+ v_signing_key_id uuid;
107
+ v_fingerprint text;
108
+ v_key_hex text;
109
+ v_raw_key bytea;
110
+ v_reencoded text;
111
+ v_key_id text := 'portal-enroll';
112
+ v_key_version int := 1;
113
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
114
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
115
+ v_invite_code text := nullif(trim(coalesce(p_invite_code, '')), '');
116
+ v_invite_redeemed boolean := false;
117
+ v_invite_board_id uuid;
118
+ begin
119
+ -- service_role-only (defense in depth on top of the execute-grant revocation).
120
+ if not agent_board._is_service_role() then
121
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
122
+ end if;
123
+
124
+ -- A verified subject + human identity claim are always present from the EF.
125
+ if p_subject is null then
126
+ raise exception 'verified subject is required' using errcode = '22023';
127
+ end if;
128
+ if p_human_identity_claim is null then
129
+ raise exception 'human identity claim is required' using errcode = '22023';
130
+ end if;
131
+
132
+ -- (0) PoP gate — the EF Ed25519-verified the proof-of-possession over the token
133
+ -- bytes and passes true. Refuse false BEFORE any write (no agent/key/audit row).
134
+ if p_pop_verified is not true then
135
+ raise exception 'proof-of-possession was not verified; enrollment refused'
136
+ using errcode = '42501';
137
+ end if;
138
+
139
+ -- (1) Public-key shape gate. The enroll contract is raw Ed25519 (32 bytes -> 43
140
+ -- base64url chars).
141
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url' then
142
+ raise exception 'agent public key format must be raw_ed25519_base64url' using errcode = '22023';
143
+ end if;
144
+ if p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
145
+ raise exception 'malformed agent public key: expected a 43-char base64url raw Ed25519 key'
146
+ using errcode = '22023';
147
+ end if;
148
+
149
+ -- (1a) B2 — decode base64url -> raw 32 bytes and REJECT any non-canonical encoding.
150
+ v_raw_key := decode(translate(p_agent_public_key, '-_', '+/') || '=', 'base64');
151
+ if octet_length(v_raw_key) <> 32 then
152
+ raise exception 'agent public key must decode to exactly 32 raw Ed25519 bytes'
153
+ using errcode = '22023';
154
+ end if;
155
+ v_reencoded := rtrim(translate(encode(v_raw_key, 'base64'), '+/', '-_'), '=');
156
+ if v_reencoded is distinct from p_agent_public_key then
157
+ raise exception 'agent public key is not canonical base64url (aliased encoding rejected)'
158
+ using errcode = '22023';
159
+ end if;
160
+
161
+ -- (1b) Reject the all-zero small-order placeholder over the RAW bytes.
162
+ if v_raw_key = decode(repeat('00', 32), 'hex') then
163
+ raise exception 'agent public key is the all-zero small-order placeholder and is rejected'
164
+ using errcode = '22023';
165
+ end if;
166
+
167
+ -- (2) Recompute-and-assert the fingerprint in-DB over the RAW 32 bytes. Derived through
168
+ -- the shared _raw_key_fingerprint helper (single source of truth; catalog-sweep-pinned).
169
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
170
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
171
+ raise exception 'agent public key fingerprint mismatch (recompute != supplied)'
172
+ using errcode = '22023';
173
+ end if;
174
+
175
+ -- (3) act.sub is audit-only, but when present it must be a real value.
176
+ if p_actor_sub is not null and length(trim(p_actor_sub)) = 0 then
177
+ raise exception 'actor sub, when present, must be non-empty' using errcode = '22023';
178
+ end if;
179
+
180
+ v_slug := agent_board._normalize_provision_slug(p_slug);
181
+ v_kind := lower(coalesce(trim(p_agent_kind), ''));
182
+
183
+ -- (4) allowlist lookup by (issuer, audience); must exist + supported + policy-approved.
184
+ select *
185
+ into v_provider
186
+ from agent_board.identity_providers
187
+ where issuer = p_issuer
188
+ and audience = p_audience
189
+ limit 1;
190
+ if v_provider.id is null then
191
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
192
+ end if;
193
+ if not v_provider.supported then
194
+ raise exception 'identity provider is not supported' using errcode = '42501';
195
+ end if;
196
+ if not v_provider.policy_approved then
197
+ raise exception 'identity provider is not policy-approved' using errcode = '42501';
198
+ end if;
199
+
200
+ -- (5) scope-ceiling + kind gate. 'system' is never federatable.
201
+ if v_kind = 'system' then
202
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
203
+ end if;
204
+ if v_kind not in ('human', 'agent') then
205
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
206
+ end if;
207
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
208
+ and not (v_kind = any (v_provider.allowed_agent_kinds)) then
209
+ raise exception 'agent kind % is outside the provider ceiling', v_kind using errcode = '42501';
210
+ end if;
211
+ if not (v_requested_scopes <@ v_provider.allowed_scopes) then
212
+ raise exception 'requested scopes exceed the provider allowed_scopes ceiling' using errcode = '42501';
213
+ end if;
214
+ if not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
215
+ raise exception 'requested board scopes exceed the provider allowed_board_scopes ceiling' using errcode = '42501';
216
+ end if;
217
+
218
+ -- (6) dedup on the enrolled key: a fingerprint already enrolled cannot be re-bound.
219
+ if exists (select 1 from agent_board.agent_signing_keys k where k.fingerprint = v_fingerprint) then
220
+ raise exception 'this signing key is already enrolled (duplicate fingerprint)'
221
+ using errcode = '23505';
222
+ end if;
223
+
224
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
225
+ -- never names. The redemption below inserts board_memberships, which takes FOR
226
+ -- KEY SHARE on the invite's agent_board.boards row through
227
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
228
+ -- auth.users owner lock, inverting against enroll_federated_human and
229
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
230
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
231
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
232
+ -- relative order _redeem_unbounded_invite_for_agent and
233
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
234
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
235
+ -- revoked, expired or exhausted code still falls through to redemption, which
236
+ -- stays the sole authority on invite usability (soft-honor contract).
237
+ if v_invite_code is not null then
238
+ select bit.board_id into v_invite_board_id
239
+ from agent_board.board_invite_tokens bit
240
+ where bit.code = v_invite_code
241
+ for update;
242
+ if v_invite_board_id is not null then
243
+ perform 1 from agent_board.boards b
244
+ where b.id = v_invite_board_id
245
+ for key share;
246
+ end if;
247
+ end if;
248
+
249
+ -- (7) HUMAN OWNER — established idempotently per (issuer, sub).
250
+ v_owner_user_id := agent_board._ensure_agent_auth_user(
251
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32));
252
+
253
+ -- (8) AGENT handle — unique per enrolled key.
254
+ v_key_hex := encode(extensions.digest(v_raw_key, 'sha256'), 'hex');
255
+ v_agent_handle := v_slug;
256
+ if exists (select 1 from agent_board.agents a where a.handle = v_agent_handle) then
257
+ v_agent_handle := v_slug || '-' || substr(v_key_hex, 1, 12);
258
+ end if;
259
+
260
+ -- (9) the agent's OWN board principal (session-usable via its enrolled key).
261
+ v_auth_user_id := agent_board._ensure_agent_auth_user(v_agent_handle);
262
+
263
+ -- (10) create the SEPARATE agent row (never keyed on sub; owner = the human).
264
+ insert into agent_board.agents(
265
+ agent_type,
266
+ kind,
267
+ handle,
268
+ display_name,
269
+ status,
270
+ auth_user_id,
271
+ owner_user_id,
272
+ metadata
273
+ )
274
+ values (
275
+ v_kind,
276
+ v_kind::agent_board.agent_kind,
277
+ v_agent_handle,
278
+ agent_board._default_provision_display_name(v_agent_handle),
279
+ 'active',
280
+ v_auth_user_id,
281
+ v_owner_user_id,
282
+ jsonb_build_object(
283
+ 'federation', jsonb_build_object(
284
+ 'issuer', p_issuer,
285
+ 'audience', p_audience,
286
+ 'subject', p_subject,
287
+ 'actorSub', p_actor_sub,
288
+ 'providerId', v_provider.id,
289
+ 'signingKeyFingerprint', v_fingerprint,
290
+ 'requestedScopes', to_jsonb(v_requested_scopes),
291
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes),
292
+ 'popVerified', true,
293
+ 'enrolledAt', now()
294
+ )
295
+ )
296
+ )
297
+ returning id into v_agent_id;
298
+
299
+ -- (11) enroll the public key as active + proven (satisfies active_requires_proof).
300
+ insert into agent_board.agent_signing_keys(
301
+ id, agent_id, key_id, key_version, public_key, public_key_format, fingerprint,
302
+ status, proof_state, metadata
303
+ )
304
+ values (
305
+ gen_random_uuid(), v_agent_id, v_key_id, v_key_version, p_agent_public_key,
306
+ p_agent_public_key_format, v_fingerprint,
307
+ 'active', 'proven',
308
+ jsonb_build_object('enrolledVia', 'federated_identity', 'popVerified', true)
309
+ )
310
+ returning id into v_signing_key_id;
311
+
312
+ -- (12) one static claim-snapshot audit row (the verified claim contains act.sub).
313
+ insert into agent_board.enrollment_federation_audit(
314
+ issuer, audience, subject, human_identity_claim, provider_id,
315
+ agent_id, auth_user_id, requested_scopes, granted_scopes
316
+ )
317
+ values (
318
+ p_issuer, p_audience, p_subject, p_human_identity_claim, v_provider.id,
319
+ v_agent_id, v_auth_user_id, v_requested_scopes, v_requested_scopes
320
+ );
321
+
322
+ -- (13) ROOMLINK soft-honor: if a code is present, redeem it into a bounded board
323
+ -- membership for the freshly-created agent. Wrapped in a subtransaction so a
324
+ -- bad/expired/exhausted/unauthorized code rolls back ONLY this sub-block — the
325
+ -- agent-create above persists and enrollment still succeeds (invite_redeemed=false).
326
+ if v_invite_code is not null then
327
+ begin
328
+ perform agent_board._redeem_invite_for_agent(v_invite_code, v_agent_id);
329
+ v_invite_redeemed := true;
330
+ exception when others then
331
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
332
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
333
+ -- invite_redeemed=false and no membership row, while the deadlock counter
334
+ -- ticked and no error reached the client. Re-raise the whole class
335
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
336
+ -- and keep the soft-honor contract for every deterministic failure.
337
+ if left(sqlstate, 2) = '40' then
338
+ raise;
339
+ end if;
340
+ v_invite_redeemed := false;
341
+ end;
342
+ end if;
343
+
344
+ -- Server-authoritative identity: the client mints /session with the RETURNED
345
+ -- key_id/key_version/agent_handle (its private key stays in the keystore).
346
+ return query
347
+ select v_agent_id, v_auth_user_id, v_provider.id, v_signing_key_id, v_requested_scopes,
348
+ v_key_id, v_key_version, v_agent_handle, v_invite_redeemed;
349
+ end;
350
+ $$;
351
+
352
+ comment on function agent_board._enroll_federated_identity_once(
353
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
354
+ 'Internal one-time federated-identity creator (the released 20260724000200/20260822000100 implementation, renamed by 20260901000100). Call only through enroll_federated_identity, which holds the invite/board and owner locks. Takes the invite row and its board BEFORE the owner is provisioned so the board -> owner order holds even on a direct service_role call (#194); the optional trailing p_invite_code is still soft-honored via _redeem_invite_for_agent in a BEGIN/EXCEPTION subtransaction (bad code -> enrollment still succeeds, invite_redeemed=false), EXCEPT for SQLSTATE class 40, which propagates rather than being reported as a non-redemption.';
355
+ revoke all on function agent_board._enroll_federated_identity_once(
356
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
357
+ from public, anon, authenticated;
358
+ grant execute on function agent_board._enroll_federated_identity_once(
359
+ text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text)
360
+ to service_role;
361
+
362
+ -- 2. the replay wrapper -- the route the Portal /enroll endpoint calls.
363
+ create or replace function agent_board.enroll_federated_identity(
364
+ p_issuer text,
365
+ p_audience text,
366
+ p_subject text,
367
+ p_human_identity_claim jsonb,
368
+ p_slug text,
369
+ p_agent_public_key text,
370
+ p_agent_public_key_format text,
371
+ p_agent_public_key_fingerprint text,
372
+ p_pop_verified boolean,
373
+ p_actor_sub text default null,
374
+ p_agent_kind text default 'agent',
375
+ p_requested_scopes text[] default '{}',
376
+ p_requested_board_scopes text[] default '{}',
377
+ p_invite_code text default null
378
+ )
379
+ returns table(
380
+ agent_id uuid,
381
+ auth_user_id uuid,
382
+ provider_id text,
383
+ signing_key_id uuid,
384
+ granted_scopes text[],
385
+ key_id text,
386
+ key_version int,
387
+ agent_handle text,
388
+ invite_redeemed boolean
389
+ )
390
+ language plpgsql
391
+ security definer
392
+ set search_path = agent_board, public
393
+ as $$
394
+ #variable_conflict use_column
395
+ declare
396
+ v_fingerprint text;
397
+ v_existing record;
398
+ v_created record;
399
+ v_replay jsonb;
400
+ v_invite_redeemed boolean := false;
401
+ v_slug text;
402
+ v_kind text := lower(btrim(coalesce(p_agent_kind, 'agent')));
403
+ v_requested_scopes text[] := coalesce(p_requested_scopes, '{}');
404
+ v_requested_board_scopes text[] := coalesce(p_requested_board_scopes, '{}');
405
+ v_provider agent_board.identity_providers%rowtype;
406
+ v_invite_board_id uuid;
407
+ v_claim_audience jsonb;
408
+ v_owner_user_id uuid;
409
+ begin
410
+ if not agent_board._is_service_role() then
411
+ raise exception 'service_role required for enroll_federated_identity' using errcode = '42501';
412
+ end if;
413
+ if p_pop_verified is not true then
414
+ raise exception 'proof-of-possession was not verified' using errcode = '42501';
415
+ end if;
416
+ if p_subject is null then
417
+ raise exception 'verified subject is required' using errcode = '22023';
418
+ end if;
419
+ if p_human_identity_claim is null then
420
+ raise exception 'human identity claim is required' using errcode = '22023';
421
+ end if;
422
+
423
+ select * into v_provider from agent_board.identity_providers ip
424
+ where ip.issuer = p_issuer and ip.audience = p_audience limit 1;
425
+ if v_provider.id is null then
426
+ raise exception 'issuer/audience not in the identity-provider allowlist' using errcode = '42501';
427
+ end if;
428
+
429
+ v_claim_audience := agent_board._normalized_federation_audience(p_human_identity_claim);
430
+ if p_human_identity_claim ->> 'iss' is distinct from p_issuer
431
+ or p_human_identity_claim ->> v_provider.subject_claim is distinct from p_subject
432
+ or v_claim_audience is null
433
+ or not (v_claim_audience ? p_audience) then
434
+ raise exception 'verified stable claim projection does not match enrollment inputs' using errcode = '22023';
435
+ end if;
436
+ if p_agent_public_key_format is distinct from 'raw_ed25519_base64url'
437
+ or p_agent_public_key is null or p_agent_public_key !~ '^[A-Za-z0-9_-]{43}$' then
438
+ raise exception 'malformed agent public key' using errcode = '22023';
439
+ end if;
440
+ v_fingerprint := agent_board._raw_key_fingerprint(p_agent_public_key);
441
+ if p_agent_public_key_fingerprint is distinct from v_fingerprint then
442
+ raise exception 'agent public key fingerprint mismatch' using errcode = '22023';
443
+ end if;
444
+ v_slug := agent_board._normalize_provision_slug(p_slug);
445
+
446
+ if not v_provider.supported or not v_provider.policy_approved then
447
+ raise exception 'identity provider is not approved' using errcode = '42501';
448
+ end if;
449
+ if v_kind = 'system' then
450
+ raise exception 'the system kind cannot be federated' using errcode = '42501';
451
+ end if;
452
+ if v_kind not in ('human', 'agent') then
453
+ raise exception 'agent kind must be human or agent' using errcode = '22023';
454
+ end if;
455
+ if array_length(v_provider.allowed_agent_kinds, 1) is not null
456
+ and not (v_kind = any(v_provider.allowed_agent_kinds)) then
457
+ raise exception 'agent kind is outside the provider ceiling' using errcode = '42501';
458
+ end if;
459
+ if not (v_requested_scopes <@ v_provider.allowed_scopes)
460
+ or not (v_requested_board_scopes <@ v_provider.allowed_board_scopes) then
461
+ raise exception 'requested scopes exceed the provider ceiling' using errcode = '42501';
462
+ end if;
463
+
464
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null and exists (
465
+ select 1 from agent_board.board_invite_tokens bit
466
+ join agent_board.fleet_onboarding_invite_bounds b on b.invite_id = bit.id
467
+ where bit.code = btrim(p_invite_code)
468
+ ) then
469
+ raise exception 'fleet invite requires atomic fleet onboarding' using errcode = '42501';
470
+ end if;
471
+
472
+
473
+ -- #194 LOCK ORDER -- BOARD BEFORE OWNER, and the reason is an FK this function
474
+ -- never names. The redemption below inserts board_memberships, which takes FOR
475
+ -- KEY SHARE on the invite's agent_board.boards row through
476
+ -- board_memberships_board_id_fkey. That acquisition used to land AFTER the
477
+ -- auth.users owner lock, inverting against enroll_federated_human and
478
+ -- onboard_federated_fleet_agent -- both of which take board -> owner -- and the
479
+ -- resulting cycle was reproduced as `deadlock detected ... while locking tuple
480
+ -- in relation "boards"`. Acquire the invite row and its board here, in the same
481
+ -- relative order _redeem_unbounded_invite_for_agent and
482
+ -- onboard_federated_fleet_agent take them (invite -> board), and in the SAME mode
483
+ -- the redemption itself uses, so the redemption below acquires nothing new. Absence is deliberately NOT fatal: a missing,
484
+ -- revoked, expired or exhausted code still falls through to redemption, which
485
+ -- stays the sole authority on invite usability (soft-honor contract).
486
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
487
+ select bit.board_id into v_invite_board_id
488
+ from agent_board.board_invite_tokens bit
489
+ where bit.code = nullif(btrim(coalesce(p_invite_code, '')), '')
490
+ for update;
491
+ if v_invite_board_id is not null then
492
+ perform 1 from agent_board.boards b
493
+ where b.id = v_invite_board_id
494
+ for key share;
495
+ end if;
496
+ end if;
497
+
498
+ perform pg_advisory_xact_lock(hashtextextended(v_fingerprint, 0));
499
+
500
+ select a.owner_user_id into v_owner_user_id
501
+ from agent_board.agent_signing_keys k
502
+ join agent_board.agents a on a.id = k.agent_id
503
+ where k.fingerprint = v_fingerprint;
504
+ v_owner_user_id := coalesce(v_owner_user_id, agent_board._ensure_agent_auth_user(
505
+ 'fed-human-' || substr(encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'), 1, 32)
506
+ ));
507
+ perform 1 from auth.users u where u.id = v_owner_user_id for update;
508
+ if exists (
509
+ select 1 from agent_board.fleet_human_revocations r
510
+ where r.issuer = p_issuer and r.subject = p_subject
511
+ ) then
512
+ raise exception 'human owner has been revoked' using errcode = '42501';
513
+ end if;
514
+
515
+ select a.id as agent_id, a.auth_user_id, a.handle, a.agent_type, a.status,
516
+ a.metadata, k.id as signing_key_id, k.key_id, k.key_version,
517
+ k.public_key, k.public_key_format, k.status as key_status, k.proof_state,
518
+ (select e.granted_scopes
519
+ from agent_board.enrollment_federation_audit e
520
+ where e.agent_id = a.id
521
+ and e.issuer = p_issuer
522
+ and e.subject = p_subject
523
+ and e.provider_id = v_provider.id) as granted_scopes
524
+ into v_existing
525
+ from agent_board.agent_signing_keys k
526
+ join agent_board.agents a on a.id = k.agent_id
527
+ where k.fingerprint = v_fingerprint
528
+ for update of a, k;
529
+
530
+ if found then
531
+ v_replay := v_existing.metadata #> '{federation,replay}';
532
+ if v_existing.public_key is distinct from p_agent_public_key
533
+ or v_existing.public_key_format is distinct from p_agent_public_key_format
534
+ or v_existing.key_status <> 'active'
535
+ or v_existing.proof_state <> 'proven'
536
+ or v_existing.status <> 'active'
537
+ or v_existing.granted_scopes is null
538
+ or v_replay is null
539
+ or v_replay ->> 'issuer' is distinct from p_issuer
540
+ or v_replay -> 'audience' is distinct from v_claim_audience
541
+ or v_replay ->> 'subject' is distinct from p_subject
542
+ or v_replay ->> 'requestedSlug' is distinct from v_slug
543
+ or v_replay ->> 'agentKind' is distinct from v_kind
544
+ or v_replay -> 'actorSub' is distinct from coalesce(to_jsonb(p_actor_sub), 'null'::jsonb)
545
+ or v_replay -> 'requestedScopes' is distinct from to_jsonb(v_requested_scopes)
546
+ or v_replay -> 'requestedBoardScopes' is distinct from to_jsonb(v_requested_board_scopes)
547
+ or v_replay ->> 'providerId' is distinct from v_provider.id then
548
+ raise exception 'enrollment replay conflicts with the frozen identity contract' using errcode = '23505';
549
+ end if;
550
+
551
+ if nullif(btrim(coalesce(p_invite_code, '')), '') is not null then
552
+ begin
553
+ perform agent_board._redeem_invite_for_agent(btrim(p_invite_code), v_existing.agent_id);
554
+ v_invite_redeemed := true;
555
+ exception when others then
556
+ -- #194: a serialization failure is NOT "the invite was not redeemed".
557
+ -- Swallowing class 40 here reported enrollment SUCCESS to the caller with
558
+ -- invite_redeemed=false and no membership row, while the deadlock counter
559
+ -- ticked and no error reached the client. Re-raise the whole class
560
+ -- (40001 serialization_failure, 40P01 deadlock_detected, 40003, 40002)
561
+ -- and keep the soft-honor contract for every deterministic failure.
562
+ if left(sqlstate, 2) = '40' then
563
+ raise;
564
+ end if;
565
+ v_invite_redeemed := false;
566
+ end;
567
+ end if;
568
+
569
+ return query select v_existing.agent_id, v_existing.auth_user_id,
570
+ v_replay ->> 'providerId', v_existing.signing_key_id,
571
+ v_existing.granted_scopes, v_existing.key_id, v_existing.key_version,
572
+ v_existing.handle, v_invite_redeemed;
573
+ return;
574
+ end if;
575
+
576
+ select * into v_created from agent_board._enroll_federated_identity_once(
577
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_slug,
578
+ p_agent_public_key, p_agent_public_key_format, p_agent_public_key_fingerprint,
579
+ p_pop_verified, p_actor_sub, p_agent_kind, v_requested_scopes,
580
+ v_requested_board_scopes, p_invite_code
581
+ );
582
+
583
+ update agent_board.agents a
584
+ set metadata = jsonb_set(a.metadata, '{federation,replay}', jsonb_build_object(
585
+ 'issuer', p_issuer, 'audience', v_claim_audience, 'subject', p_subject,
586
+ 'providerId', v_created.provider_id,
587
+ 'actorSub', coalesce(to_jsonb(p_actor_sub), 'null'::jsonb),
588
+ 'requestedSlug', v_slug, 'agentKind', v_kind,
589
+ 'requestedScopes', to_jsonb(v_requested_scopes),
590
+ 'requestedBoardScopes', to_jsonb(v_requested_board_scopes)
591
+ ), true)
592
+ where a.id = v_created.agent_id;
593
+
594
+ return query select v_created.agent_id, v_created.auth_user_id,
595
+ v_created.provider_id, v_created.signing_key_id, v_created.granted_scopes,
596
+ v_created.key_id, v_created.key_version, v_created.agent_handle,
597
+ v_created.invite_redeemed;
598
+ end;
599
+ $$;
600
+
601
+ comment on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) is
602
+ 'Service-role federated enrollment with exact fingerprint replay. Stable owner, actor, slug, kind, and scope inputs are frozen; rotating presentation claims are revalidated upstream but are not equality-gated. Fleet-bounded codes are rejected before writes. Takes the invite row and its board (invite -> board) BEFORE the auth.users owner lock so every federated write route agrees on board -> owner (#194); a serialization failure or deadlock during redemption propagates instead of being reported as invite_redeemed=false.';
603
+ revoke all on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) from public, anon, authenticated;
604
+ grant execute on function agent_board.enroll_federated_identity(text, text, text, jsonb, text, text, text, text, boolean, text, text, text[], text[], text) to service_role;