@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,43 @@
1
+ -- #132 — fleet_machines authenticated SELECT policy: fix the dead binding arm.
2
+ --
3
+ -- The policy `agent_board_authenticated_select_fleet_machines` gated its EXISTS subquery on
4
+ -- `fma.machine_id = fma.id` — a fleet_machine_agents SELF-comparison (machine_id FKs
5
+ -- fleet_machines.id; fma.id is the junction PK), so it is NEVER true and a bound agent could
6
+ -- never SELECT its own machine via the binding arm (only the owner arm worked).
7
+ --
8
+ -- The naive fix (`fma.machine_id = fleet_machines.id`) INFINITELY RECURSES: fleet_machine_agents'
9
+ -- own SELECT policy references fleet_machines back, so a correlated subquery over the binding
10
+ -- table re-enters this policy. (The original never recursed only because its dead comparison
11
+ -- meant the subquery never evaluated against real rows.) Break the cycle with a SECURITY DEFINER
12
+ -- helper that reads the binding table with RLS bypassed — the same technique the other
13
+ -- policy-helper predicates use.
14
+ --
15
+ -- Additive, forward-only; policy + internal helper. schema_version stays 8.
16
+
17
+ create or replace function agent_board._current_agent_bound_to_machine(p_machine_id uuid)
18
+ returns boolean
19
+ language sql
20
+ stable
21
+ security definer
22
+ set search_path = ''
23
+ as $$
24
+ select exists (
25
+ select 1
26
+ from agent_board.fleet_machine_agents fma
27
+ where fma.machine_id = p_machine_id
28
+ and fma.agent_id = agent_board.current_agent_id()
29
+ and fma.lifecycle_state = 'active'
30
+ )
31
+ $$;
32
+ comment on function agent_board._current_agent_bound_to_machine(uuid) is
33
+ '#132: does the current agent hold an ACTIVE fleet_machine_agents binding on this machine? SECURITY DEFINER so the fleet_machines SELECT policy can consult the binding table without recursing into its RLS (which references fleet_machines back). Session-derived; PUBLIC EXECUTE revoked.';
34
+ revoke all on function agent_board._current_agent_bound_to_machine(uuid) from public, anon;
35
+ grant execute on function agent_board._current_agent_bound_to_machine(uuid) to authenticated, service_role;
36
+
37
+ drop policy if exists agent_board_authenticated_select_fleet_machines on agent_board.fleet_machines;
38
+ create policy agent_board_authenticated_select_fleet_machines on agent_board.fleet_machines
39
+ as permissive for select to authenticated
40
+ using (
41
+ owner_agent_id = agent_board.current_agent_id()
42
+ or agent_board._current_agent_bound_to_machine(id)
43
+ );
@@ -0,0 +1,166 @@
1
+ -- MBCONSENT resolve/request no-op polish (#116, Fable #114 Part B).
2
+ --
3
+ -- B1 — approve-no-op left the pending row dangling. When an owner "approves" a subject who is
4
+ -- ALREADY active, resolve_board_request returns the active row untouched (the Option-1 no-op) but
5
+ -- did not retire the redundant requested/invited row, so pending listings kept showing a request
6
+ -- the owner believes resolved (only a reject cleared it). Fix: retire the redundant pending row to
7
+ -- 'left' (recoverable) in the no-op path, routed through the emitting sink so the write hits the
8
+ -- ledger (the emission-completeness invariant). The emitted event is board-authored (actor !=
9
+ -- subject) and therefore NEUTRAL for consent — it records the pending request's retirement without
10
+ -- touching the subject's consent-present status, which the untouched active row carries. B4
11
+ -- (target state / p_metadata ignored on the no-op path)
12
+ -- and B2 (with two pending rows only the newest resolves) are documented in-place — behaviour
13
+ -- unchanged, deliberately.
14
+ --
15
+ -- CREATE OR REPLACE with the same body plus the B1 retire; grants re-asserted. schema_version
16
+ -- stays 8. Additive, forward-only.
17
+
18
+ create or replace function agent_board.resolve_board_request(
19
+ p_board_id uuid,
20
+ p_agent_id uuid,
21
+ p_membership_state text,
22
+ p_created_by_agent_id uuid,
23
+ p_metadata jsonb default '{}'::jsonb
24
+ )
25
+ returns table(membership_id uuid, board_id uuid, agent_id uuid, membership_state text, left_at timestamp with time zone, updated_at timestamp with time zone)
26
+ language plpgsql
27
+ security definer
28
+ set search_path to 'agent_board', 'public'
29
+ as $function$
30
+ declare
31
+ v_effective_state text;
32
+ v_board agent_board.boards%rowtype;
33
+ v_membership_id uuid;
34
+ v_active_id uuid;
35
+ v_membership agent_board.board_memberships%rowtype;
36
+ v_actor uuid := agent_board.current_agent_id();
37
+ begin
38
+ if v_actor is null and not agent_board._is_service_role() then
39
+ raise exception 'current agent principal not resolved' using errcode = '42501';
40
+ end if;
41
+
42
+ if p_membership_state not in ('joined', 'subscribed', 'removed') then
43
+ raise exception 'resolve_board_request only accepts joined, subscribed, or removed' using errcode = '22023';
44
+ end if;
45
+ select * into v_board from agent_board.boards where id = p_board_id and status = 'active';
46
+ if v_board.id is null then
47
+ raise exception 'board not found' using errcode = 'P0002';
48
+ end if;
49
+ if v_actor is not null then
50
+ if v_board.role_gate_enforced then
51
+ if not exists (
52
+ select 1
53
+ from agent_board.board_memberships bm
54
+ where bm.board_id = p_board_id
55
+ and bm.agent_id = v_actor
56
+ and bm.membership_state in ('joined', 'subscribed')
57
+ and bm.role in ('owner', 'admin')
58
+ ) then
59
+ raise exception 'board request resolution requires owner or admin role' using errcode = '42501';
60
+ end if;
61
+ elsif v_actor <> v_board.created_by_agent_id then
62
+ if not exists (
63
+ select 1
64
+ from agent_board.board_memberships bm
65
+ where bm.board_id = p_board_id
66
+ and bm.agent_id = v_actor
67
+ and bm.membership_state in ('joined', 'subscribed')
68
+ ) then
69
+ raise exception 'board request resolution requires active membership' using errcode = '42501';
70
+ end if;
71
+ end if;
72
+ end if;
73
+
74
+ -- Lock the WHOLE (board, agent) set first so a concurrent self-withdrawal on ANY physical row
75
+ -- serialises behind this resolution (#109's race is per-(board,agent), not per-row).
76
+ perform 1
77
+ from agent_board.board_memberships bm
78
+ where bm.board_id = p_board_id
79
+ and bm.agent_id = p_agent_id
80
+ order by bm.id
81
+ for update;
82
+
83
+ -- Select the PENDING row explicitly (not the total-order winner). B2 (#116): with TWO pending
84
+ -- rows (a requested AND an invited) for one subject this resolves the NEWEST; the older dangles.
85
+ -- Inherent to the multi-row model — resolution is one act on one row.
86
+ select bm.id
87
+ into v_membership_id
88
+ from agent_board.board_memberships bm
89
+ where bm.board_id = p_board_id
90
+ and bm.agent_id = p_agent_id
91
+ and bm.membership_state in ('requested', 'invited')
92
+ order by bm.created_at desc, bm.id desc
93
+ limit 1;
94
+
95
+ select bm.id
96
+ into v_active_id
97
+ from agent_board.board_memberships bm
98
+ where bm.board_id = p_board_id
99
+ and bm.agent_id = p_agent_id
100
+ and bm.membership_state in ('joined', 'subscribed')
101
+ order by bm.created_at desc, bm.id desc
102
+ limit 1;
103
+
104
+ if v_membership_id is null then
105
+ raise exception 'pending board membership not found' using errcode = 'P0002';
106
+ end if;
107
+
108
+ -- OPTION 1 FALLBACK (#109 A3): approving a subject who is ALREADY active is a no-op success,
109
+ -- not a promotion of the pending row into collision with the active one. Return the active row
110
+ -- untouched. Rejection still falls through to act on the pending row.
111
+ --
112
+ -- B4 (#116): the no-op path intentionally IGNORES the requested target state and p_metadata —
113
+ -- approving with target 'subscribed' a 'joined' active row returns 'joined' (a self-toggle
114
+ -- belongs to update_board_membership, which is where the old path coupled the metadata write to
115
+ -- a demotion). Nothing legitimate is lost.
116
+ if p_membership_state in ('joined', 'subscribed') and v_active_id is not null then
117
+ -- B1 (#116): retire the redundant pending row so it stops showing in pending listings. The
118
+ -- subject is already active via v_active_id (untouched); route the retire through the sink so
119
+ -- the write EMITS (the emission-completeness invariant requires every membership write to hit
120
+ -- the ledger) — a board-authored 'left' event, which is NEUTRAL for consent (not the subject's
121
+ -- own act) and does not affect the subject's consent-present status carried by the active row.
122
+ -- 'left' is recoverable. Previously only a reject cleared this, leaving an owner-visible
123
+ -- dangling request after a no-op approve.
124
+ perform agent_board._upsert_board_membership_state(
125
+ p_board_id, p_agent_id, 'left', p_created_by_agent_id, p_metadata, v_membership_id, 'noop_superseded'
126
+ );
127
+
128
+ return query
129
+ select bm.id, bm.board_id, bm.agent_id, bm.membership_state, bm.left_at, bm.updated_at
130
+ from agent_board.board_memberships bm
131
+ where bm.id = v_active_id;
132
+ return;
133
+ end if;
134
+
135
+ -- REJECTSEM (#89): a rejection is NOT an eviction — resolve only acts on a PENDING row, so a
136
+ -- refusal lands on 'left' (recoverable), never 'removed' (which bars self-re-entry). Applied to
137
+ -- both requested and invited origins.
138
+ v_effective_state := case when p_membership_state = 'removed' then 'left'
139
+ else p_membership_state end;
140
+
141
+ -- #82: promotion into an active state requires the subject's own consent act (the sole
142
+ -- consent-predicate read site). Authenticated only; service_role keeps the operator carve-out.
143
+ if v_actor is not null
144
+ and v_effective_state in ('joined', 'subscribed')
145
+ and not agent_board._membership_consent_present(p_board_id, p_agent_id) then
146
+ raise exception 'admitting % requires an act by that agent', p_agent_id
147
+ using errcode = '42501';
148
+ end if;
149
+
150
+ v_membership := agent_board._upsert_board_membership_state(
151
+ p_board_id,
152
+ p_agent_id,
153
+ v_effective_state,
154
+ p_created_by_agent_id,
155
+ p_metadata,
156
+ v_membership_id,
157
+ case when p_membership_state = 'removed' then 'reject_withdrawn' else null end
158
+ );
159
+
160
+ return query
161
+ select v_membership.id, v_membership.board_id, v_membership.agent_id, v_membership.membership_state, v_membership.left_at, v_membership.updated_at;
162
+ end;
163
+ $function$;
164
+
165
+ revoke all on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) from public, anon;
166
+ grant execute on function agent_board.resolve_board_request(uuid, uuid, text, uuid, jsonb) to authenticated, service_role;
@@ -0,0 +1,206 @@
1
+ -- =============================================================================
2
+ -- ROOMDELEG-2 / Lane B (#59) — self-service delegation policy writer for an
3
+ -- authenticated human-root (partial: policy writer only; minter deferred).
4
+ -- =============================================================================
5
+ -- Lane A (#57) shipped owner-gated set_board_role_gate. Lane B opens self-service
6
+ -- delegation to an authenticated human-agent root. This migration ships the SAFE half —
7
+ -- the policy writer — and DEFERS opening the minter (section 3 explains why):
8
+ --
9
+ -- upsert_delegation_policy (NEW) — write the root's own policy. SHIPS HERE.
10
+ -- mint_delegated_child (gate widened) — DEFERRED to #138 (needs child-key PoP +
11
+ -- raw-byte fingerprint first; see section 3).
12
+ --
13
+ -- BLOCKING DECISION (4-seat panel, ratified): there is NO `_grant_within_ceiling`-analog
14
+ -- for delegation SCOPES, and building one would be worse than not having it. A catalog
15
+ -- enumeration of every reader of delegation scope found that NO resource gate consumes
16
+ -- `allowed_scopes`: mint reads only grant liveness; the obsgate authoring/task/claims
17
+ -- gates and the send-authz layer gate on board membership/role and fleet origin, never
18
+ -- on granted scope. So `allowed_scopes` is ADVISORY — a scope-ceiling would cap a value
19
+ -- with zero enforcement effect, making mint's `requested ⊆ allowed_scopes` guard LOOK
20
+ -- non-vacuous while pinning nothing (the "succeeds where it should not" failure class).
21
+ -- Real capability is gated downstream: a child with self-declared scopes `["*"]` can do
22
+ -- nothing until an authority ADMITS it to a board (bounded by _grant_within_ceiling and
23
+ -- the board's ceiling), its session stays live, and its signing key is unrevoked.
24
+ --
25
+ -- Keeping "advisory" HONEST rather than a latent landmine: the frozen scope model
26
+ -- (20260718001000) declares a root's scope IS `allowed_scopes`, so a future phase may
27
+ -- start consuming it — at which point rows self-declared under this regime would silently
28
+ -- become load-bearing. Defuse it STRUCTURALLY with a provenance column: the authenticated
29
+ -- self-service path always stamps `scope_attestation='self_declared'` (session-derived,
30
+ -- never caller-supplied — the same doctrine as event provenance). Any future consumer
31
+ -- MUST require `operator_vetted` or apply a real ceiling at consumption time.
32
+ --
33
+ -- The fields that DO have teeth are hard-clamped on the self-service path (a root could
34
+ -- otherwise self-set keyless children / unbounded fan-out / an inflated TTL):
35
+ -- require_child_signing_key : pinned true (keyless children erode signed-write evidence)
36
+ -- max_active_children : non-null, 1..25 (each mint provisions an auth.users row)
37
+ -- child_ttl_ceiling : positive, <= 24h
38
+ -- max_depth : pinned 1 (no transitive minting; mint also enforces it)
39
+ -- Actor binding: root == current_agent_id() via _resolved_actor_agent_id (strict for v1;
40
+ -- owner-parity widening is compatible later, a contraction would not be). service_role
41
+ -- keeps the operator carve-out (trusted; scopes operator_vetted; caps as supplied).
42
+ --
43
+ -- schema_version stays 8: additive (one column + one new function + one gate widening).
44
+ -- =============================================================================
45
+
46
+ -- ---------------------------------------------------------------------------
47
+ -- 1. Scope provenance column. Existing rows were all service_role-written, so the
48
+ -- `default 'operator_vetted'` backfills them correctly on ALTER.
49
+ -- ---------------------------------------------------------------------------
50
+ alter table agent_board.delegation_policies
51
+ add column if not exists scope_attestation text not null default 'operator_vetted'
52
+ check (scope_attestation in ('operator_vetted', 'self_declared'));
53
+
54
+ comment on column agent_board.delegation_policies.scope_attestation is
55
+ 'Provenance of allowed_scopes. self_declared = written by the root itself via the '
56
+ 'authenticated upsert_delegation_policy self-service path — scopes are ADVISORY, NOT '
57
+ 'operator-vetted authority. operator_vetted = written by service_role. A future gate '
58
+ 'that consumes allowed_scopes as capability MUST require operator_vetted (or apply a '
59
+ 'real ceiling at consumption); it must NEVER trust self_declared scopes as authority. '
60
+ 'Stamped from the session, never from a caller argument. Existing rows backfilled '
61
+ 'operator_vetted (all were service_role-written).';
62
+
63
+ -- ---------------------------------------------------------------------------
64
+ -- 2. upsert_delegation_policy — the authenticated self-service policy writer.
65
+ -- ---------------------------------------------------------------------------
66
+ create or replace function agent_board.upsert_delegation_policy(
67
+ p_root_agent_id uuid,
68
+ p_mode text default 'root_minted',
69
+ p_allowed_scopes jsonb default null,
70
+ p_max_active_children int default null,
71
+ p_child_ttl_ceiling interval default null,
72
+ p_require_child_signing_key boolean default null
73
+ )
74
+ returns agent_board.delegation_policies
75
+ language plpgsql
76
+ security definer
77
+ set search_path to 'agent_board', 'public'
78
+ as $$
79
+ declare
80
+ v_actor uuid;
81
+ v_is_service boolean := agent_board._is_service_role();
82
+ v_attestation text;
83
+ v_mode text;
84
+ v_max_children int;
85
+ v_ttl interval;
86
+ v_require_key boolean;
87
+ v_existing agent_board.delegation_policies%rowtype;
88
+ v_policy agent_board.delegation_policies%rowtype;
89
+ begin
90
+ -- Actor bound to the SESSION: authenticated -> current_agent_id() and p_root must
91
+ -- match it; service_role -> the supplied p_root (create-on-behalf-of). The binding is
92
+ -- derived from the session, never trusted from the parameter (Lane A's coalesce lesson).
93
+ v_actor := agent_board._resolved_actor_agent_id(p_root_agent_id, 'p_root_agent_id');
94
+
95
+ if not exists (select 1 from agent_board.agents where id = v_actor) then
96
+ raise exception 'root agent not found' using errcode = 'P0002';
97
+ end if;
98
+
99
+ -- Serialize concurrent self-service writes for the SAME root. The find-then-branch
100
+ -- below takes `for update`, but a `for update` over ZERO rows locks nothing, so two
101
+ -- concurrent FIRST upserts could each insert an active policy row (same-actor, not a
102
+ -- cross-root escalation, but it makes latest-policy selection ambiguous). A per-root
103
+ -- transaction advisory lock is the mutex — dedicated namespace, no coupling to the
104
+ -- agents row other flows may lock.
105
+ perform pg_advisory_xact_lock(hashtext('agent_board.delegation_policy:' || v_actor::text));
106
+
107
+ if p_mode is not null and p_mode not in ('independent_auth', 'root_minted') then
108
+ raise exception 'invalid delegation policy mode' using errcode = '22023';
109
+ end if;
110
+ v_mode := coalesce(p_mode, 'root_minted');
111
+
112
+ if v_is_service then
113
+ -- OPERATOR path: trusted. Scopes are operator-vetted; caps as supplied (table
114
+ -- defaults apply for nulls).
115
+ v_attestation := 'operator_vetted';
116
+ v_max_children := p_max_active_children;
117
+ v_ttl := coalesce(p_child_ttl_ceiling, interval '8 hours');
118
+ v_require_key := coalesce(p_require_child_signing_key, true);
119
+ else
120
+ -- SELF-SERVICE path: dangerous fields are CLAMPED, not trusted, and the scopes are
121
+ -- accepted but STAMPED self_declared.
122
+ v_attestation := 'self_declared';
123
+ -- require_child_signing_key: PINNED true regardless of input.
124
+ v_require_key := true;
125
+ -- max_active_children: NON-NULL, bounded 1..25.
126
+ v_max_children := coalesce(p_max_active_children, 5);
127
+ if v_max_children < 1 or v_max_children > 25 then
128
+ raise exception 'max_active_children must be between 1 and 25 for self-service delegation policies' using errcode = '22023';
129
+ end if;
130
+ -- child_ttl_ceiling: positive, capped at 24h (default 8h).
131
+ v_ttl := coalesce(p_child_ttl_ceiling, interval '8 hours');
132
+ if v_ttl <= interval '0' then
133
+ raise exception 'child_ttl_ceiling must be positive' using errcode = '22023';
134
+ end if;
135
+ if v_ttl > interval '24 hours' then
136
+ raise exception 'child_ttl_ceiling may not exceed 24 hours for self-service delegation policies' using errcode = '22023';
137
+ end if;
138
+ end if;
139
+
140
+ -- Upsert the root's CURRENT active policy. There is no unique on root_agent_id and the
141
+ -- mint reads the latest unrevoked row, so find-then-branch under a row lock rather than
142
+ -- ON CONFLICT: a concurrent self-service write serializes here. max_depth is PINNED 1 on
143
+ -- both branches — the mint enforces it structurally, but pinning the stored field stops a
144
+ -- future depth-aware mint inheriting an inflated value.
145
+ select * into v_existing
146
+ from agent_board.delegation_policies dp
147
+ where dp.root_agent_id = v_actor
148
+ and dp.revoked_at is null
149
+ order by dp.created_at desc
150
+ limit 1
151
+ for update;
152
+
153
+ if found then
154
+ update agent_board.delegation_policies dp
155
+ set mode = v_mode,
156
+ max_depth = 1,
157
+ max_active_children = v_max_children,
158
+ allowed_scopes = coalesce(p_allowed_scopes, '[]'::jsonb),
159
+ child_ttl_ceiling = v_ttl,
160
+ require_child_signing_key = v_require_key,
161
+ scope_attestation = v_attestation
162
+ where dp.id = v_existing.id
163
+ returning * into v_policy;
164
+ else
165
+ insert into agent_board.delegation_policies(
166
+ root_agent_id, mode, max_depth, max_active_children, allowed_scopes,
167
+ child_ttl_ceiling, require_child_signing_key, scope_attestation
168
+ )
169
+ values (
170
+ v_actor, v_mode, 1, v_max_children, coalesce(p_allowed_scopes, '[]'::jsonb),
171
+ v_ttl, v_require_key, v_attestation
172
+ )
173
+ returning * into v_policy;
174
+ end if;
175
+
176
+ return v_policy;
177
+ end;
178
+ $$;
179
+
180
+ comment on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) is
181
+ 'Self-service writer for a root''s delegation policy (ROOMDELEG-2 / #59). authenticated: '
182
+ 'actor bound root == current_agent_id(); scopes accepted but stamped scope_attestation='
183
+ '''self_declared'' (ADVISORY — see the column comment); require_child_signing_key pinned '
184
+ 'true, max_active_children clamped 1..25, child_ttl_ceiling clamped <=24h, max_depth pinned '
185
+ '1. service_role: create-on-behalf-of, operator_vetted, caps as supplied. Upserts the root''s '
186
+ 'latest unrevoked policy row.';
187
+
188
+ revoke all on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) from public, anon, authenticated;
189
+ grant execute on function agent_board.upsert_delegation_policy(uuid, text, jsonb, int, interval, boolean) to authenticated, service_role;
190
+
191
+ -- ---------------------------------------------------------------------------
192
+ -- 3. mint_delegated_child — DEFERRED.
193
+ -- ---------------------------------------------------------------------------
194
+ -- Opening mint to authenticated is INTENTIONALLY NOT done here. mint stamps a
195
+ -- caller-supplied child public key as proof_state='proven' with NO proof-of-possession,
196
+ -- and derives its fingerprint over the base64url STRING rather than the raw key bytes
197
+ -- (20260718001000:203). Under the current service_role-only gate that is operator-trusted;
198
+ -- but opening it to authenticated would let any root register a VICTIM's public key as its
199
+ -- child's "proven" key — and because the string-fingerprint differs from enroll's raw-byte
200
+ -- fingerprint (fixed for enroll in 20260719001200), the agent_signing_keys.fingerprint
201
+ -- UNIQUE backstop does not dedup across the two paths, so the squat sails past it.
202
+ --
203
+ -- Mint therefore stays service_role-only until that class is closed. The mint-opening +
204
+ -- child-key PoP + raw-byte fingerprint (catalog-enumerated across every fingerprint writer)
205
+ -- + the non-positive-TTL floor land together in the hardening tracked by #138. This PR ships
206
+ -- only the self-service POLICY writer, which handles no keys and adds no signing surface.