@consiliency/agent-board-schema 1.22.0 → 1.24.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 (65) hide show
  1. package/README.md +75 -1
  2. package/fixtures/already-applied.json +40 -0
  3. package/migrations/20260901000100_agent_board_atomic_fleet_onboarding.sql +1496 -0
  4. package/migrations/20260901000110_agent_board_fleet_onboarding_ceiling_lock.sql +604 -0
  5. package/migrations/20260901000120_agent_board_human_revocation_evidence_completeness.sql +78 -0
  6. package/migrations/20260901000130_agent_board_fedsession_human_revocation_gate.sql +74 -0
  7. package/migrations/20260901000140_agent_board_fedsession_board_owner_lock_order.sql +102 -0
  8. package/migrations/20260902000100_agent_board_outbox_wake_ordering.sql +184 -0
  9. package/migrations/20260903000100_agent_board_enroll_identity_board_owner_lock_order.sql +604 -0
  10. package/migrations/20260903000200_agent_board_fedsession_provider_absence_gate.sql +177 -0
  11. package/migrations/20260904000100_agent_board_wake_recovery_surface.sql +155 -0
  12. package/migrations/20260905000100_agent_board_consent_gating_reconcile.sql +151 -0
  13. package/package.json +1 -1
  14. package/schema-manifest.json +93 -3
  15. package/src/check.js +23 -0
  16. package/src/index.js +20 -0
  17. package/src/plan.js +79 -5
  18. package/src/tooling.test.mjs +237 -4
  19. package/tests/agent_board_atomic_fleet_onboarding.test.sql +623 -0
  20. package/tests/agent_board_atomic_fleet_onboarding_backfill.test.sql +142 -0
  21. package/tests/agent_board_atomic_fleet_onboarding_runtime.test.sql +1250 -0
  22. package/tests/agent_board_enroll_federated_identity.test.sql +3 -3
  23. package/tests/agent_board_enroll_pop_binding.test.sql +13 -3
  24. package/tests/agent_board_enrollment_audit_content.test.sql +11 -7
  25. package/tests/agent_board_federated_lock_order_class.test.sql +390 -0
  26. package/tests/agent_board_fedsession.test.sql +46 -2
  27. package/tests/agent_board_fedsession_provider_absence_gate.test.sql +170 -0
  28. package/tests/agent_board_inbox_reads.test.sql +16 -19
  29. package/tests/agent_board_outbox_wake_ordering.test.sql +452 -0
  30. package/tests/agent_board_portal_offline_invariant.test.sql +1 -1
  31. package/tests/agent_board_roomlink.test.sql +2 -2
  32. package/tests/agent_board_wake_recovery.test.sql +756 -0
  33. package/tests/isolation/expected/atomic_fedsession_fleet_lock_order.out +79 -0
  34. package/tests/isolation/expected/atomic_fedsession_human_revoke.out +87 -0
  35. package/tests/isolation/expected/atomic_fleet_authority_races.out +469 -0
  36. package/tests/isolation/expected/atomic_fleet_binding_revoke.out +103 -0
  37. package/tests/isolation/expected/atomic_fleet_board_archive.out +117 -0
  38. package/tests/isolation/expected/atomic_fleet_human_fresh_revoke.out +136 -0
  39. package/tests/isolation/expected/atomic_fleet_identity_disposition.out +61 -0
  40. package/tests/isolation/expected/atomic_fleet_lifecycle_races.out +1479 -0
  41. package/tests/isolation/expected/atomic_fleet_onboarding.out +75 -0
  42. package/tests/isolation/expected/atomic_fleet_subtree_lock_order.out +60 -0
  43. package/tests/isolation/expected/ceiling_setter_lock_before_upsert.out +95 -0
  44. package/tests/isolation/expected/enroll_identity_board_owner_lock_order.out +55 -0
  45. package/tests/isolation/expected/fedsession_provider_absent_owner_lock.out +37 -0
  46. package/tests/isolation/expected/onboarding_ceiling_lock_order.out +123 -0
  47. package/tests/isolation/expected/outbox_wake_ordering.out +86 -0
  48. package/tests/isolation/expected/wake_recovery_late_commit.out +89 -0
  49. package/tests/isolation/specs/atomic_fedsession_fleet_lock_order.spec +117 -0
  50. package/tests/isolation/specs/atomic_fedsession_human_revoke.spec +66 -0
  51. package/tests/isolation/specs/atomic_fleet_authority_races.spec +235 -0
  52. package/tests/isolation/specs/atomic_fleet_binding_revoke.spec +127 -0
  53. package/tests/isolation/specs/atomic_fleet_board_archive.spec +134 -0
  54. package/tests/isolation/specs/atomic_fleet_human_fresh_revoke.spec +142 -0
  55. package/tests/isolation/specs/atomic_fleet_identity_disposition.spec +118 -0
  56. package/tests/isolation/specs/atomic_fleet_lifecycle_races.spec +244 -0
  57. package/tests/isolation/specs/atomic_fleet_onboarding.spec +106 -0
  58. package/tests/isolation/specs/atomic_fleet_subtree_lock_order.spec +147 -0
  59. package/tests/isolation/specs/ceiling_setter_lock_before_upsert.spec +117 -0
  60. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +117 -0
  61. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +63 -0
  62. package/tests/isolation/specs/onboarding_ceiling_lock_order.spec +183 -0
  63. package/tests/isolation/specs/outbox_wake_ordering.spec +202 -0
  64. package/tests/isolation/specs/wake_recovery_late_commit.spec +207 -0
  65. package/tests/seeds/20260901000100_agent_board_atomic_fleet_onboarding.sql.pre.sql +110 -0
@@ -0,0 +1,74 @@
1
+ -- Consiliency/message-board#162 follow-up: /portal-session provisions through
2
+ -- enroll_federated_human, so that route must share the durable human-revocation
3
+ -- owner lock and marker gate with fleet enrollment.
4
+
5
+ alter function agent_board.enroll_federated_human(text, text, text, jsonb, text)
6
+ rename to _enroll_federated_human_after_revocation_gate;
7
+
8
+ revoke all on function agent_board._enroll_federated_human_after_revocation_gate(text, text, text, jsonb, text)
9
+ from public, anon, authenticated, service_role;
10
+ comment on function agent_board._enroll_federated_human_after_revocation_gate(text, text, text, jsonb, text) is
11
+ 'Internal federated-human provisioner. Call only through enroll_federated_human, which holds the deterministic auth.users owner lock and rejects durable issuer/subject revocation markers.';
12
+
13
+ create or replace function agent_board.enroll_federated_human(
14
+ p_issuer text,
15
+ p_audience text,
16
+ p_subject text,
17
+ p_human_identity_claim jsonb,
18
+ p_display_name text default null
19
+ )
20
+ returns table(
21
+ agent_id uuid,
22
+ auth_user_id uuid,
23
+ handle text,
24
+ kind text,
25
+ status text,
26
+ provider_id text,
27
+ created_at timestamptz
28
+ )
29
+ language plpgsql
30
+ security definer
31
+ set search_path = agent_board, public
32
+ as $$
33
+ declare
34
+ v_handle text;
35
+ v_uid uuid;
36
+ begin
37
+ if not agent_board._is_service_role() then
38
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
39
+ end if;
40
+ if p_subject is null then
41
+ raise exception 'verified subject is required' using errcode = '22023';
42
+ end if;
43
+
44
+ v_handle := 'fed-human-' || substr(
45
+ encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
46
+ 1, 32);
47
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
48
+
49
+ perform 1 from auth.users u where u.id = v_uid for update;
50
+ if not found then
51
+ raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
52
+ end if;
53
+ if exists (
54
+ select 1
55
+ from agent_board.fleet_human_revocations r
56
+ where r.issuer = p_issuer
57
+ and r.subject = p_subject
58
+ ) then
59
+ raise exception 'human identity is durably revoked' using errcode = '42501';
60
+ end if;
61
+
62
+ return query
63
+ select * from agent_board._enroll_federated_human_after_revocation_gate(
64
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
65
+ );
66
+ end;
67
+ $$;
68
+
69
+ comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
70
+ 'Service-role federated-human enrollment used by /portal-session. Derives the deterministic owner, holds its auth.users row lock through commit, rejects durable issuer/subject revocation, then delegates to the internal provisioner.';
71
+ revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
72
+ from public, anon, authenticated;
73
+ grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
74
+ to service_role;
@@ -0,0 +1,102 @@
1
+ -- Consiliency/message-board#162 follow-up: a federated-human provisioner may
2
+ -- auto-join its provider's default board. Fleet onboarding takes board -> owner,
3
+ -- so /portal-session must take the same order rather than owner -> board.
4
+
5
+ create or replace function agent_board.enroll_federated_human(
6
+ p_issuer text,
7
+ p_audience text,
8
+ p_subject text,
9
+ p_human_identity_claim jsonb,
10
+ p_display_name text default null
11
+ )
12
+ returns table(
13
+ agent_id uuid,
14
+ auth_user_id uuid,
15
+ handle text,
16
+ kind text,
17
+ status text,
18
+ provider_id text,
19
+ created_at timestamptz
20
+ )
21
+ language plpgsql
22
+ security definer
23
+ set search_path = agent_board, public
24
+ as $$
25
+ declare
26
+ v_provider_id agent_board.identity_providers.id%type;
27
+ v_default_board_id uuid;
28
+ v_locked_default_board_id uuid;
29
+ v_handle text;
30
+ v_uid uuid;
31
+ begin
32
+ if not agent_board._is_service_role() then
33
+ raise exception 'service_role required for enroll_federated_human' using errcode = '42501';
34
+ end if;
35
+ if p_subject is null then
36
+ raise exception 'verified subject is required' using errcode = '22023';
37
+ end if;
38
+
39
+ -- Snapshot the provider's auto-join target without a row lock so a non-null
40
+ -- target can be locked in canonical board -> provider order. Then lock the
41
+ -- provider row and require that the target did not change across the gap. A
42
+ -- concurrent NULL -> board designation is rejected for retry rather than
43
+ -- falling back to provider -> board and recreating the cross-route cycle.
44
+ select ip.id, ip.default_board_id
45
+ into v_provider_id, v_default_board_id
46
+ from agent_board.identity_providers ip
47
+ where ip.issuer = p_issuer
48
+ and ip.audience = p_audience
49
+ limit 1;
50
+ if v_provider_id is not null then
51
+ if v_default_board_id is not null then
52
+ perform 1 from agent_board.boards b
53
+ where b.id = v_default_board_id
54
+ for update;
55
+ if not found then
56
+ raise exception 'federated human default board changed during enrollment'
57
+ using errcode = '40001';
58
+ end if;
59
+ end if;
60
+
61
+ select ip.default_board_id
62
+ into v_locked_default_board_id
63
+ from agent_board.identity_providers ip
64
+ where ip.id = v_provider_id
65
+ for update;
66
+ if not found or v_locked_default_board_id is distinct from v_default_board_id then
67
+ raise exception 'federated human default board changed during enrollment'
68
+ using errcode = '40001';
69
+ end if;
70
+ end if;
71
+
72
+ v_handle := 'fed-human-' || substr(
73
+ encode(extensions.digest(convert_to(p_issuer || '|' || p_subject, 'UTF8'), 'sha256'), 'hex'),
74
+ 1, 32);
75
+ v_uid := agent_board._ensure_agent_auth_user(v_handle);
76
+
77
+ perform 1 from auth.users u where u.id = v_uid for update;
78
+ if not found then
79
+ raise exception 'federated human owner not found after provisioning' using errcode = 'P0002';
80
+ end if;
81
+ if exists (
82
+ select 1
83
+ from agent_board.fleet_human_revocations r
84
+ where r.issuer = p_issuer
85
+ and r.subject = p_subject
86
+ ) then
87
+ raise exception 'human identity is durably revoked' using errcode = '42501';
88
+ end if;
89
+
90
+ return query
91
+ select * from agent_board._enroll_federated_human_after_revocation_gate(
92
+ p_issuer, p_audience, p_subject, p_human_identity_claim, p_display_name
93
+ );
94
+ end;
95
+ $$;
96
+
97
+ comment on function agent_board.enroll_federated_human(text, text, text, jsonb, text) is
98
+ 'Service-role federated-human enrollment used by /portal-session. Locks a non-null default board before its provider row, freezes that designation, then holds the deterministic auth.users owner lock, rejects durable issuer/subject revocation, and delegates to the internal provisioner.';
99
+ revoke all on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
100
+ from public, anon, authenticated;
101
+ grant execute on function agent_board.enroll_federated_human(text, text, text, jsonb, text)
102
+ to service_role;
@@ -0,0 +1,184 @@
1
+ -- OUTBOXWAKE / #187 — monotonic wake ordering for the notification outbox.
2
+ --
3
+ -- THE DEFECT IS ALREADY LIVE, not hypothetical. `agent_board.callback_outbox_events`
4
+ -- has no monotonic anchor: its only ordering columns are `id uuid default
5
+ -- gen_random_uuid()` and `created_at`/`available_at timestamptz default now()`.
6
+ -- `claim_callback_outbox_events` orders its candidate set by
7
+ -- order by coe.available_at asc, coe.created_at asc, coe.id asc
8
+ -- whose FINAL tiebreak is a random v4 uuid. And `now()` is `transaction_timestamp()`,
9
+ -- constant within a transaction, so a multicast fan-out writes N rows with
10
+ -- byte-identical `created_at`. Timestamp collision is the NORMAL path here, not the
11
+ -- edge case, so which rows a bounded batch claims — and in what order a worker sees
12
+ -- them — is decided by uuid randomness today.
13
+ --
14
+ -- This repo already met and solved this hazard once, on the consent substrate
15
+ -- (`20260806000100_agent_board_consent_substrate.sql`), with `board_membership_events.seq`.
16
+ -- That was, until now, the only identity column in the whole schema. The notification
17
+ -- path never got the same treatment. This migration gives it one.
18
+ --
19
+ -- WHY `generated always` AND NOT `by default`:
20
+ -- `seq` is EVIDENCE of write order, in the same sense that provenance is derived from
21
+ -- the session and never from a caller argument. `generated always` makes the value
22
+ -- un-suppliable: an INSERT that names `seq` is rejected by the server (SQLSTATE 428C9),
23
+ -- so no writer — present or future — can assert an ordering it did not earn. Only an
24
+ -- explicit `overriding system value` can bypass that, and the class assertion in
25
+ -- `agent_board_outbox_wake_ordering.test.sql` fails, by name, on any function that uses it.
26
+ --
27
+ -- BACKFILL: none required. `callback_outbox_events` holds 0 rows in production
28
+ -- (verified before writing this migration), so adding the identity column needs no
29
+ -- decision about what order pre-existing rows "should" have had.
30
+ --
31
+ -- VISIBILITY IS NOT WIDENED. `seq` is exposed only on surfaces a consumer can already
32
+ -- read: the table itself (RLS unchanged) and the service_role-only claim RPC, which
33
+ -- already returns the row's contents. No view is created or altered.
34
+ --
35
+ -- WHAT THIS MIGRATION DOES NOT GIVE YOU — read this before building a consumer on `seq`:
36
+ -- `seq` is monotonic in ASSIGNMENT order, NOT in COMMIT order. The sequence is
37
+ -- non-transactional, so a transaction that inserts early and commits late holds a LOWER
38
+ -- seq than rows a consumer has already seen and acknowledged. `seq > my persisted cursor`
39
+ -- is therefore NOT a safe recovery predicate: it silently skips the late committer.
40
+ -- Recovery must gate on transaction visibility or on row STATUS — never on `seq` alone.
41
+ -- `claim_callback_outbox_events` is safe because it selects on `outbox_status`, not on a
42
+ -- cursor. `agent-board-runtime/src/notifications.ts` (`shouldReplayMissedEvent`) does gate
43
+ -- on `seq` today and is exposed to this; fixing it needs the wake contract and is tracked
44
+ -- separately, NOT in this migration.
45
+ --
46
+ -- What this migration delivers, stated exactly: DETERMINISTIC CLAIM ORDERING over rows
47
+ -- that share a timestamp, and a total order to build a gap-safe protocol on later.
48
+
49
+ alter table agent_board.callback_outbox_events
50
+ add column if not exists seq bigint generated always as identity;
51
+
52
+ comment on column agent_board.callback_outbox_events.seq is
53
+ 'Monotonic write-order evidence for wake ordering (#187). GENERATED ALWAYS: not caller-suppliable. Gives same-timestamp rows a total order, which `created_at` cannot because now() is transaction_timestamp() and a fan-out shares it. CAUTION: monotonic in ASSIGNMENT order, NOT commit order -- a late-committing transaction holds a lower seq than rows already consumed, so `seq > cursor` is NOT a safe recovery predicate. Gate recovery on transaction visibility or on outbox_status, never on seq alone.';
54
+
55
+ create unique index if not exists uq_agent_board_callback_outbox_events_seq
56
+ on agent_board.callback_outbox_events(seq);
57
+
58
+ -- The consumer's access path is "rows for me newer than the cursor I persisted".
59
+ create index if not exists idx_agent_board_callback_outbox_events_recipient_seq
60
+ on agent_board.callback_outbox_events(recipient_agent_id, seq);
61
+
62
+ -- Claim ordering: `available_at` stays FIRST because it is scheduling (a row is not
63
+ -- eligible before its time), and `seq` replaces the (created_at, id) pair because it is
64
+ -- ordering. The uuid tiebreak is dropped entirely.
65
+ --
66
+ -- The returns table gains `outbox_seq` so a worker can observe and record the order it was
67
+ -- handed rows in. It is NOT a lossless recovery cursor — see the caution above. That is a
68
+ -- RETURN TYPE change, so the function must be dropped and recreated rather than replaced.
69
+ --
70
+ -- DROP FUNCTION DISCARDS THE ACL AND THE COMMENT. Recreating a SECURITY DEFINER function
71
+ -- grants EXECUTE to PUBLIC by default. This repo shipped a real gate bypass by omitting
72
+ -- exactly this restatement, so the revoke, the grant, and the comment are restated below
73
+ -- in this SAME migration, and asserted by the test file.
74
+ drop function if exists agent_board.claim_callback_outbox_events(integer);
75
+
76
+ create function agent_board.claim_callback_outbox_events(
77
+ p_batch_size integer default 25
78
+ )
79
+ returns table(
80
+ outbox_event_id uuid,
81
+ endpoint_id uuid,
82
+ subscription_id uuid,
83
+ recipient_agent_id uuid,
84
+ inbox_item_id uuid,
85
+ board_id uuid,
86
+ endpoint_url text,
87
+ signing_key_version integer,
88
+ event_category text,
89
+ delivery_mode text,
90
+ source_kind text,
91
+ source_message_id uuid,
92
+ source_task_id uuid,
93
+ source_delegation_id uuid,
94
+ idempotency_key text,
95
+ subject text,
96
+ preview text,
97
+ priority text,
98
+ fetch_descriptor jsonb,
99
+ outbox_created_at timestamptz,
100
+ queued_at timestamptz,
101
+ claimed_at timestamptz,
102
+ attempt_number integer,
103
+ endpoint_status text,
104
+ subscription_status text,
105
+ metadata jsonb,
106
+ outbox_seq bigint
107
+ )
108
+ language plpgsql
109
+ security definer
110
+ set search_path = agent_board, public
111
+ as $$
112
+ begin
113
+ if p_batch_size is null or p_batch_size < 1 or p_batch_size > 100 then
114
+ raise exception 'callback batch size must be between 1 and 100' using errcode = '22023';
115
+ end if;
116
+
117
+ return query
118
+ with candidates as (
119
+ select coe.id
120
+ from agent_board.callback_outbox_events coe
121
+ join agent_board.callback_endpoints ce on ce.id = coe.endpoint_id
122
+ join agent_board.callback_subscriptions cs on cs.id = coe.subscription_id
123
+ where coe.outbox_status in ('pending', 'retryable')
124
+ and coe.available_at <= now()
125
+ and ce.endpoint_status = 'verified'
126
+ and cs.subscription_status = 'active'
127
+ order by coe.available_at asc, coe.seq asc
128
+ for update of coe skip locked
129
+ limit p_batch_size
130
+ ),
131
+ updated as (
132
+ update agent_board.callback_outbox_events coe
133
+ set outbox_status = 'delivering',
134
+ locked_at = now(),
135
+ updated_at = now()
136
+ from candidates
137
+ where coe.id = candidates.id
138
+ returning coe.*
139
+ )
140
+ select
141
+ u.id,
142
+ u.endpoint_id,
143
+ u.subscription_id,
144
+ u.recipient_agent_id,
145
+ u.inbox_item_id,
146
+ u.board_id,
147
+ ce.endpoint_url,
148
+ ce.signing_key_version,
149
+ u.event_category,
150
+ u.delivery_mode,
151
+ u.source_kind,
152
+ u.source_message_id,
153
+ u.source_task_id,
154
+ u.source_delegation_id,
155
+ u.idempotency_key,
156
+ u.subject,
157
+ u.preview,
158
+ u.priority,
159
+ u.fetch_descriptor,
160
+ u.created_at,
161
+ u.available_at,
162
+ u.locked_at,
163
+ agent_board._next_callback_attempt_number(u.id),
164
+ ce.endpoint_status,
165
+ cs.subscription_status,
166
+ coalesce(u.metadata, '{}'::jsonb),
167
+ u.seq
168
+ from updated u
169
+ join agent_board.callback_endpoints ce on ce.id = u.endpoint_id
170
+ join agent_board.callback_subscriptions cs on cs.id = u.subscription_id
171
+ -- The batch is also RETURNED in wake order. Without this the claimed SET would be
172
+ -- deterministic while the order the worker observes it in stayed arbitrary, which is
173
+ -- the same defect one join away.
174
+ order by u.seq asc;
175
+ end;
176
+ $$;
177
+
178
+ revoke all on function agent_board.claim_callback_outbox_events(integer)
179
+ from public, anon, authenticated;
180
+ grant execute on function agent_board.claim_callback_outbox_events(integer)
181
+ to service_role;
182
+
183
+ comment on function agent_board.claim_callback_outbox_events(integer) is
184
+ 'Service-role notification worker claim: locks the ready outbox batch with SKIP LOCKED and marks it delivering. Ordered by (available_at, seq) — scheduling first, then monotonic write order (#187), so rows sharing a timestamp are claimed deterministically. Losslessness comes from selecting on outbox_status, NOT from seq: seq is monotonic in assignment order, not commit order, so `seq > cursor` is not a safe recovery predicate. Returns outbox_seq for observability and ordering, not as a recovery cursor.';