@consiliency/agent-board-schema 1.28.0 → 1.38.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 (43) hide show
  1. package/fixtures/already-applied.json +40 -0
  2. package/migrations/20260910000100_agent_board_ceiling_lock_comment_truth.sql +89 -0
  3. package/migrations/20260911000100_agent_board_ledger_revoked_board_creation.sql +330 -0
  4. package/migrations/20260912000100_agent_board_receipt_subject_gate.sql +348 -0
  5. package/migrations/20260913000100_agent_board_task_thread_lock_order.sql +292 -0
  6. package/migrations/20260914000100_agent_board_claim_subject_gate.sql +500 -0
  7. package/migrations/20260915000100_agent_board_fedsession_stored_identity_gate.sql +353 -0
  8. package/migrations/20260916000100_agent_board_federated_identity_injective_slug.sql +1646 -0
  9. package/migrations/20260917000100_agent_board_invite_no_downgrade.sql +398 -0
  10. package/migrations/20260918000100_agent_board_signed_write_enforced_repoint.sql +139 -0
  11. package/migrations/20260919000100_agent_board_signed_write_model_key.sql +107 -0
  12. package/package.json +1 -1
  13. package/schema-manifest.json +93 -3
  14. package/src/deployment-drift-script.test.mjs +424 -0
  15. package/src/deployment-drift.test.mjs +301 -0
  16. package/src/tooling.test.mjs +83 -2
  17. package/tests/agent_board_atomic_fleet_onboarding.test.sql +144 -29
  18. package/tests/agent_board_bundle_endpoint_compat.test.sql +73 -18
  19. package/tests/agent_board_claim_subject_gate.test.sql +1025 -0
  20. package/tests/agent_board_consent_substrate.test.sql +8 -1
  21. package/tests/agent_board_enroll_resumption.test.sql +7 -1
  22. package/tests/agent_board_federated_identity_injective_slug.test.sql +388 -0
  23. package/tests/agent_board_fedsession.test.sql +17 -2
  24. package/tests/agent_board_fedsession_stored_identity_gate.test.sql +606 -0
  25. package/tests/agent_board_fk_lock_edges.test.sql +422 -0
  26. package/tests/agent_board_invite_no_downgrade.test.sql +689 -0
  27. package/tests/agent_board_ledger_authority.test.sql +142 -71
  28. package/tests/agent_board_receipt_subject_gate.test.sql +624 -0
  29. package/tests/agent_board_signed_write_enforced.test.sql +366 -0
  30. package/tests/agent_board_signer_lifecycle.test.sql +23 -6
  31. package/tests/agent_board_source_strip_helper.test.sql +256 -1
  32. package/tests/agent_board_task_thread_lock_order.test.sql +337 -0
  33. package/tests/agent_board_write_enforcement_class.test.sql +1016 -0
  34. package/tests/catalog/p_metadata_parameters.json +12 -0
  35. package/tests/fixtures/isolation-substrate.sql +1 -1
  36. package/tests/fixtures/test-prereqs.sql +442 -11
  37. package/tests/isolation/expected/ledger_authority_revoke.out +219 -1
  38. package/tests/isolation/expected/task_thread_lock_order.out +203 -0
  39. package/tests/isolation/specs/enroll_identity_board_owner_lock_order.spec +5 -4
  40. package/tests/isolation/specs/fedsession_provider_absent_owner_lock.spec +4 -4
  41. package/tests/isolation/specs/ledger_authority_revoke.spec +115 -0
  42. package/tests/isolation/specs/task_thread_lock_order.spec +197 -0
  43. package/tests/seeds/20260916000100_agent_board_federated_identity_injective_slug.sql.pre.sql +196 -0
@@ -0,0 +1,197 @@
1
+ # Consiliency/message-board#281 (R1) — append_task_event must take THREADS BEFORE TASKS,
2
+ # and must still serialise its event_sequence computation.
3
+ #
4
+ # THE DEFECT. #290's FK-aware detector found 40 unserialised ABBA rows on
5
+ # `tasks <-> threads`, the largest block of the 186 and the only one whose operand
6
+ # coincidence is an ordinary workload:
7
+ # append_task_event: tasks (explicit FOR UPDATE) -> threads (KEY SHARE, task_events.thread_id FK)
8
+ # post_message: threads (UPDATE, in _next_thread_message_sequence) -> tasks (KEY SHARE, messages.task_id FK)
9
+ # The colliding operand is `postMessage({ threadId: task.threadId, taskId: task.id })`
10
+ # concurrent with any agent claiming/delegating/resolving that task.
11
+ #
12
+ # READ THIS BEFORE READING THE GOLDEN: THE REAL PAIR DOES NOT DEADLOCK, AND PERMUTATIONS 2
13
+ # AND 3 ARE THE EVIDENCE FOR THAT, NOT A FAILED REPRODUCTION.
14
+ # `_next_thread_message_sequence` updates threads.last_message_sequence / updated_at.
15
+ # `threads` has exactly one unique index (threads_pkey on id), so neither column is a key
16
+ # column and that UPDATE takes FOR NO KEY UPDATE — which does NOT conflict with the FOR
17
+ # KEY SHARE append_task_event takes on threads through task_events_thread_id_fkey. (The
18
+ # matrix is pinned verbatim as FKL-2 in agent_board_fk_lock_edges.test.sql:
19
+ # `no key update/key share=false`.) So post_message blocks on append_task_event's
20
+ # `tasks FOR UPDATE`, and append_task_event never blocks on post_message's threads lock:
21
+ # ONE-WAY BLOCKING, NO CYCLE. The detector reports 40 rows because `_acq` aggregates any
22
+ # write-update to mode `update` — it over-reports by design.
23
+ # Permutations 2 and 3 therefore show block-then-complete in both directions, and they
24
+ # showed the same thing BEFORE the fix. No pre-fix reproduction of the real pair is
25
+ # claimed here, because none exists.
26
+ #
27
+ # THE "probe" SESSION IS AN ORDER INSTRUMENT, NOT A post_message STAND-IN. State it that
28
+ # way or a reader will draw the wrong conclusion from permutation 1.
29
+ # * It takes `threads FOR UPDATE` then `tasks FOR UPDATE`. No DUAL-RELATION shipped path
30
+ # takes threads FOR UPDATE — and the scope of that claim is load-bearing, so read it
31
+ # against its evidence rather than past it. agent_board_task_thread_lock_order.test.sql
32
+ # TT-1 enumerates the functions that touch BOTH relations, and across that class the
33
+ # only threads modes are `key share` and `update`(write-update, i.e. FOR NO KEY UPDATE).
34
+ # TT-1's class is dual-relation BY CONSTRUCTION, so a threads-only caller is outside it
35
+ # — out of scope, not a counterexample.
36
+ # * AND THERE IS SUCH A CALLER. `create_or_find_thread` takes a real
37
+ # `select … from agent_board.threads … for update`
38
+ # (20260517000000_agent_board_addressing.sql:265-273), and it is the ONLY function in
39
+ # the bundle holding an EXPLICIT lock on threads — catalog-derived: exactly one row with
40
+ # `rel_name = agent_board.threads and via = 'textual'`, everything else that conflicts
41
+ # with KEY SHARE on threads does so through `write-update`. It cannot close a cycle here
42
+ # because it takes NO lock on `tasks` at all (its only other edges are FK KEY SHARE on
43
+ # `agents` and `boards`), so it is one-way blocking against append_task_event in either
44
+ # direction. An earlier draft of this header claimed "NO shipped caller takes threads
45
+ # FOR UPDATE" and cited TT-1 for it: a universal claim resting on a class-scoped
46
+ # assertion that never covered it. The citation made it look verified. It was false.
47
+ # * It is deliberately STRONGER than the real pair's modes. That is its purpose: those
48
+ # modes do not conflict, so post_message vs append_task_event cannot make the ORDER
49
+ # observable. FOR UPDATE conflicts with FOR KEY SHARE, which turns the order into
50
+ # something isolationtester can see and a golden can pin.
51
+ # * BUT DO NOT READ "INSTRUMENT" AS "HYPOTHETICAL" — it is closer to a shipped caller than
52
+ # that word suggests, and this is the reason not to prune this permutation as synthetic.
53
+ # `create_or_find_thread` ALREADY performs the probe's first step for real, on its reuse
54
+ # path. The probe is that function PLUS a `tasks` lock, and nothing else. Measured with a
55
+ # three-session run (real create_or_find_thread, real append_task_event, and a
56
+ # `tasks … for key share` observer): PRE-FIX the observer WAITS, because append_task_event
57
+ # blocked on the thread lock WHILE HOLDING `tasks FOR UPDATE` and convoyed every other
58
+ # writer of that task; POST-FIX the observer completes immediately, because the appender
59
+ # now waits holding nothing. So the ordering this permutation pins already pays off
60
+ # today — the synthetic part is only the second step.
61
+ # * What it proves is exactly this: with append_task_event tasks-first, a threads-first
62
+ # adversary deadlocks; with append_task_event threads-first, the same adversary merely
63
+ # serialises. That is the property the fix claims, and it is the property that becomes
64
+ # load-bearing the day any real function strengthens its threads lock.
65
+ # * VERIFIED PRE-FIX, and quoted at the width it was actually observed. Against the
66
+ # 20260816000100 body, permutation 1 emits `step probe_tasks: <waiting ...>` and then
67
+ # `step append_run: <... completed>` / `ERROR: deadlock detected` -- isolationtester
68
+ # reports the primary line only, not the `while locking tuple in relation ...` DETAIL,
69
+ # so no DETAIL is quoted here. Against the 20260913000100 body the same permutation
70
+ # produces the serialisation in this golden. Permutations 2, 3 and 4 are BYTE-IDENTICAL
71
+ # under both bodies, which is the reachability finding above stated as an observation.
72
+ #
73
+ # PERMUTATION 4 IS THE GUARD THE REORDER COULD HAVE BROKEN. The `tasks FOR UPDATE` is not
74
+ # decoration: it is the mutual exclusion behind `select coalesce(max(event_sequence),0)+1`
75
+ # against task_events_task_id_event_sequence_key. Two concurrent appends on one task must
76
+ # serialise and produce 1 then 2. Mutation-verified: delete the FOR UPDATE and this
77
+ # permutation stops blocking and ends in a duplicate-key error instead.
78
+
79
+ setup
80
+ {
81
+ insert into agent_board.agents(id, agent_type, kind, handle, display_name, status)
82
+ values ('7a810000-0000-4000-8000-000000000001','agent','agent','r1iso-actor','R1 ISO Actor','active');
83
+
84
+ insert into agent_board.threads(id, thread_type, created_by_agent_id, subject)
85
+ values ('7a820000-0000-4000-8000-000000000001','coordination',
86
+ '7a810000-0000-4000-8000-000000000001','R1 ISO thread');
87
+
88
+ insert into agent_board.tasks(id, thread_id, created_by_agent_id, title, status)
89
+ values ('7a830000-0000-4000-8000-000000000001','7a820000-0000-4000-8000-000000000001',
90
+ '7a810000-0000-4000-8000-000000000001','R1 ISO task','pending');
91
+ }
92
+
93
+ teardown
94
+ {
95
+ delete from agent_board.messages where thread_id = '7a820000-0000-4000-8000-000000000001';
96
+ delete from agent_board.task_events where task_id = '7a830000-0000-4000-8000-000000000001';
97
+ delete from agent_board.tasks where id = '7a830000-0000-4000-8000-000000000001';
98
+ delete from agent_board.threads where id = '7a820000-0000-4000-8000-000000000001';
99
+ delete from agent_board.agents where id = '7a810000-0000-4000-8000-000000000001';
100
+ }
101
+
102
+ # The real append, unmodified. Only deterministic columns reach the golden — the event id is
103
+ # a fresh uuid and the timestamps are wall-clock.
104
+ session "append"
105
+ step "append_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
106
+ step "append_run" {
107
+ select e.event_sequence,
108
+ (e.thread_id = '7a820000-0000-4000-8000-000000000001') as thread_is_task_thread
109
+ from agent_board.append_task_event(
110
+ '7a830000-0000-4000-8000-000000000001',
111
+ '7a810000-0000-4000-8000-000000000001',
112
+ 'commented') e;
113
+ }
114
+ step "append_commit" { COMMIT; }
115
+
116
+ # A second real append, for the event_sequence guard in permutation 4.
117
+ session "append2"
118
+ step "append2_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
119
+ step "append2_run" {
120
+ select e.event_sequence
121
+ from agent_board.append_task_event(
122
+ '7a830000-0000-4000-8000-000000000001',
123
+ '7a810000-0000-4000-8000-000000000001',
124
+ 'status_changed') e;
125
+ }
126
+ step "append2_commit" { COMMIT; }
127
+
128
+ # The real post_message, task-attached into the task's own thread — the exact operand
129
+ # coincidence #281 R1 is about.
130
+ session "post"
131
+ step "post_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
132
+ step "post_run" {
133
+ select m.message_sequence,
134
+ (m.task_id = '7a830000-0000-4000-8000-000000000001') as task_attached
135
+ from agent_board.post_message(
136
+ '7a820000-0000-4000-8000-000000000001',
137
+ '7a810000-0000-4000-8000-000000000001',
138
+ 'r1 iso body',
139
+ 'comment',
140
+ '7a830000-0000-4000-8000-000000000001') m;
141
+ }
142
+ step "post_commit" { COMMIT; }
143
+
144
+ # THE ORDER INSTRUMENT. See the header: threads FOR UPDATE is stronger than any shipped
145
+ # caller's threads lock, on purpose, because the shipped modes do not conflict.
146
+ session "probe"
147
+ step "probe_begin" { BEGIN; SET LOCAL "request.jwt.claim.role" = 'service_role'; }
148
+ step "probe_threads" {
149
+ select count(*)::int as locked_threads from (
150
+ select 1 from agent_board.threads t
151
+ where t.id = '7a820000-0000-4000-8000-000000000001' for update) s;
152
+ }
153
+ step "probe_tasks" {
154
+ select count(*)::int as locked_tasks from (
155
+ select 1 from agent_board.tasks tk
156
+ where tk.id = '7a830000-0000-4000-8000-000000000001' for update) s;
157
+ }
158
+ step "probe_commit" { COMMIT; }
159
+
160
+ session "check"
161
+ step "check" {
162
+ select (select array_agg(te.event_sequence order by te.event_sequence)
163
+ from agent_board.task_events te
164
+ where te.task_id = '7a830000-0000-4000-8000-000000000001') as event_sequences,
165
+ (select count(*)::int from agent_board.messages
166
+ where thread_id = '7a820000-0000-4000-8000-000000000001') as messages,
167
+ (select count(*)::int from agent_board.task_events te
168
+ where te.task_id = '7a830000-0000-4000-8000-000000000001'
169
+ and te.thread_id <> '7a820000-0000-4000-8000-000000000001') as events_on_wrong_thread;
170
+ }
171
+
172
+ # 1. THE ORDER, made observable. probe holds threads(H) FOR UPDATE; append then runs.
173
+ # TASKS-FIRST (pre-fix), append takes tasks(T) FOR UPDATE and blocks wanting threads(H)
174
+ # KEY SHARE; probe_tasks then wants that same task row and the server aborts one with
175
+ # `deadlock detected`. THREADS-FIRST (fixed), append blocks at its FIRST lock statement
176
+ # holding nothing, probe completes, and the append drains afterwards. The difference
177
+ # between those two worlds is the whole golden.
178
+ permutation "probe_begin" "probe_threads" "append_begin" "append_run" "probe_tasks" "probe_commit" "append_commit" "check"
179
+
180
+ # 2. THE REAL PAIR, append first. append holds tasks FOR UPDATE; post_message needs
181
+ # tasks KEY SHARE for the messages.task_id FK and blocks. It is released by the commit,
182
+ # never by a deadlock. This is one-way blocking and it is the same before and after the
183
+ # fix — see the header.
184
+ permutation "append_begin" "append_run" "post_begin" "post_run" "append_commit" "post_commit" "check"
185
+
186
+ # 3. THE REAL PAIR, post first. post holds threads(H) FOR NO KEY UPDATE and tasks(T) KEY
187
+ # SHARE. append's threads KEY SHARE is GRANTED — the two modes do not conflict, which is
188
+ # the reachability finding stated as an observation rather than as prose — and it then
189
+ # blocks on tasks FOR UPDATE until post commits. Again no cycle in either direction.
190
+ permutation "post_begin" "post_run" "append_begin" "append_run" "post_commit" "append_commit" "check"
191
+
192
+ # 4. THE event_sequence GUARD, positively. Two concurrent appends on ONE task: the second
193
+ # blocks on the first's `tasks FOR UPDATE` and only then computes its sequence, so the
194
+ # two events are 1 and 2 and the unique constraint is never tested. Remove that FOR
195
+ # UPDATE and this permutation stops blocking and ends in a duplicate-key error — which is
196
+ # the mutation that pins this assertion.
197
+ permutation "append_begin" "append_run" "append2_begin" "append2_run" "append_commit" "append2_commit" "check"
@@ -0,0 +1,196 @@
1
+ -- Pre-migration seed for 20260916000100 (message-board#246, change 2 of 3).
2
+ --
3
+ -- WHY A SEED AND NOT A FIXTURE INSIDE THE TEST. The thing under test is the SHIPPED
4
+ -- backfill, which runs once, inside the migration. A fixture created inside the pgTAP
5
+ -- transaction would be created AFTER the migration had already run and would prove
6
+ -- nothing about it. The message-board#103 pre-migration seed hook exists for exactly
7
+ -- this: these rows are inserted immediately BEFORE 20260916000100 applies, in the
8
+ -- migration-N-1 schema, so the backfill meets real pre-existing rows and
9
+ -- agent_board_federated_identity_injective_slug.test.sql asserts what it actually
10
+ -- wrote.
11
+ --
12
+ -- These rows model the production shape measured on mqccjfqngptkemnchlko (2026-08-16):
13
+ -- one federated owner uid, its own human agent row whose handle IS the slug, and
14
+ -- several agents that reference it only by owner_user_id. The real pair is a live
15
+ -- person's identity and is deliberately not reproduced here; these are synthetic pairs
16
+ -- whose slugs were computed with the same two expressions the migration uses.
17
+ --
18
+ -- Three named identities, one per recovery arm the backfill has to get right:
19
+ --
20
+ -- F1 d2460001-… HUMAN ARM pair P1, recoverable via agents.auth_user_id.
21
+ -- Carries a SECOND agent stamped with the SAME pair, so
22
+ -- a backfill counting rows instead of DISTINCT pairs
23
+ -- refuses it -- which is what production's six-agent
24
+ -- owner would do.
25
+ -- F2 d2460002-… OWNER-ONLY ARM pair P2, NO agent on that auth_user_id at all. The
26
+ -- only route to the pair is owner_user_id. Drop that
27
+ -- arm and this row becomes unrecoverable.
28
+ -- F3 d2460003-… DECOY ARM pair P3, plus an owned agent stamped with an unrelated
29
+ -- pair that does NOT reproduce F3's slug. Drop the
30
+ -- "reproduces the current slug" filter and F3 suddenly
31
+ -- has two candidate pairs and is refused.
32
+ -- F4 d2460004-… DIVERGENT ARM pair P4, but its raw_user_meta_data.slug and its
33
+ -- identity_data sub/slug hold values that are NOT its old
34
+ -- slug. The backfill rewrites those two only when they
35
+ -- EQUAL the old slug (a `case` arm each), and F1-F3 all
36
+ -- have them equal -- so before F4 existed, making both arms
37
+ -- UNCONDITIONAL survived every assertion. F4 is the
38
+ -- complement of BF-2d (which pins the same narrowness for
39
+ -- agents.handle) and it is the assertion that was missing.
40
+ --
41
+ -- Slugs, computed from the pairs below (old = the ambiguous `issuer || '|' || subject`,
42
+ -- new = the netstring form the migration introduces):
43
+ -- P1 https://fixture-246.example/idp-a fixture-246-subject-a
44
+ -- old fed-human-d50f81b6913e066dba4c19f5cb6b202a
45
+ -- new fed-human-6c056fe12e1fb58fb2c072ff8bd9a613
46
+ -- P2 https://fixture-246.example/idp-b fixture-246-subject-b
47
+ -- old fed-human-7ab9c8b527e1cce671305defc65fe7c0
48
+ -- new fed-human-ec861cc1407f8f585e94cb9e8ece6039
49
+ -- P3 https://fixture-246.example/idp-c fixture-246-subject-c
50
+ -- old fed-human-def7953eb096d786392090ce33952d9d
51
+ -- new fed-human-d4211005ca6d96ac0f8a90d2065dceda
52
+ -- P3-decoy https://fixture-246.example/idp-decoy fixture-246-subject-decoy
53
+ -- old fed-human-0897be463845203cbc1c6099e8befcb0 (reproduces nothing here)
54
+ -- P4 https://fixture-246.example/idp-d fixture-246-subject-d
55
+ -- old fed-human-38b45a2207cfb4847a4c62c9973cd676
56
+ -- new fed-human-25bf058f71a25339995a9a2012ced65b
57
+ --
58
+ -- The literals are spelled out rather than computed so that the seed and the test agree
59
+ -- on a NAMED value: a seed that derived the slug with the same expression as the code
60
+ -- under test would move silently whenever that expression moved.
61
+
62
+ -- The owner auth.users rows, in the pre-migration (old-slug) form _ensure_agent_auth_user
63
+ -- would have written them.
64
+ insert into auth.users (
65
+ id, instance_id, email, email_confirmed_at, encrypted_password,
66
+ created_at, updated_at, raw_app_meta_data, raw_user_meta_data, aud, role
67
+ )
68
+ values
69
+ ('d2460001-0000-4000-8000-000000000001', '00000000-0000-0000-0000-000000000000',
70
+ 'agent+fed-human-d50f81b6913e066dba4c19f5cb6b202a@agent-board.local', now(), '',
71
+ now(), now(),
72
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
73
+ jsonb_build_object('slug', 'fed-human-d50f81b6913e066dba4c19f5cb6b202a',
74
+ 'issuer', 'agent_board.provision_agent'),
75
+ 'authenticated', 'authenticated'),
76
+ ('d2460002-0000-4000-8000-000000000002', '00000000-0000-0000-0000-000000000000',
77
+ 'agent+fed-human-7ab9c8b527e1cce671305defc65fe7c0@agent-board.local', now(), '',
78
+ now(), now(),
79
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
80
+ jsonb_build_object('slug', 'fed-human-7ab9c8b527e1cce671305defc65fe7c0',
81
+ 'issuer', 'agent_board.provision_agent'),
82
+ 'authenticated', 'authenticated'),
83
+ ('d2460003-0000-4000-8000-000000000003', '00000000-0000-0000-0000-000000000000',
84
+ 'agent+fed-human-def7953eb096d786392090ce33952d9d@agent-board.local', now(), '',
85
+ now(), now(),
86
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
87
+ jsonb_build_object('slug', 'fed-human-def7953eb096d786392090ce33952d9d',
88
+ 'issuer', 'agent_board.provision_agent'),
89
+ 'authenticated', 'authenticated'),
90
+ -- F2's owned agent needs an auth_user_id of its own, so that F2's owner uid is
91
+ -- reachable ONLY through owner_user_id. This row is not a fed-human identity.
92
+ ('d2460002-0000-4000-8000-0000000000a2', '00000000-0000-0000-0000-000000000000',
93
+ 'agent+mb246-fixture-a2@agent-board.local', now(), '',
94
+ now(), now(),
95
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
96
+ jsonb_build_object('slug', 'mb246-fixture-a2', 'issuer', 'agent_board.provision_agent'),
97
+ 'authenticated', 'authenticated'),
98
+ -- F4: the email carries the old slug, but the meta slug deliberately does NOT. An
99
+ -- unconditional rewrite of raw_user_meta_data.slug would clobber this value; the
100
+ -- shipped `case` arm leaves it alone.
101
+ ('d2460004-0000-4000-8000-000000000004', '00000000-0000-0000-0000-000000000000',
102
+ 'agent+fed-human-38b45a2207cfb4847a4c62c9973cd676@agent-board.local', now(), '',
103
+ now(), now(),
104
+ jsonb_build_object('provider', 'agent_board', 'providers', jsonb_build_array('agent_board')),
105
+ jsonb_build_object('slug', 'mb246-f4-legacy-meta-slug', 'issuer', 'agent_board.provision_agent'),
106
+ 'authenticated', 'authenticated')
107
+ -- RESET, not insert-if-absent. The replay database is reused between runs and the
108
+ -- previous run's backfill already moved these rows to the new slug; an insert that
109
+ -- skipped on conflict would leave the second run asserting a move that had already
110
+ -- happened. Restoring the OLD form here makes every run exercise the backfill.
111
+ on conflict (id) do update
112
+ set email = excluded.email,
113
+ raw_user_meta_data = excluded.raw_user_meta_data;
114
+
115
+ insert into auth.identities (
116
+ id, user_id, provider_id, provider, identity_data, last_sign_in_at, created_at, updated_at
117
+ )
118
+ values
119
+ ('d2460011-0000-4000-8000-000000000001', 'd2460001-0000-4000-8000-000000000001',
120
+ 'fed-human-d50f81b6913e066dba4c19f5cb6b202a', 'agent_board',
121
+ jsonb_build_object('sub', 'fed-human-d50f81b6913e066dba4c19f5cb6b202a',
122
+ 'slug', 'fed-human-d50f81b6913e066dba4c19f5cb6b202a'),
123
+ now(), now(), now()),
124
+ ('d2460012-0000-4000-8000-000000000002', 'd2460002-0000-4000-8000-000000000002',
125
+ 'fed-human-7ab9c8b527e1cce671305defc65fe7c0', 'agent_board',
126
+ jsonb_build_object('sub', 'fed-human-7ab9c8b527e1cce671305defc65fe7c0',
127
+ 'slug', 'fed-human-7ab9c8b527e1cce671305defc65fe7c0'),
128
+ now(), now(), now()),
129
+ ('d2460013-0000-4000-8000-000000000003', 'd2460003-0000-4000-8000-000000000003',
130
+ 'fed-human-def7953eb096d786392090ce33952d9d', 'agent_board',
131
+ jsonb_build_object('sub', 'fed-human-def7953eb096d786392090ce33952d9d',
132
+ 'slug', 'fed-human-def7953eb096d786392090ce33952d9d'),
133
+ now(), now(), now()),
134
+ -- F4: provider_id DOES carry the old slug (so the update's WHERE matches and the row is
135
+ -- genuinely visited), while identity_data.sub/slug deliberately do not. The two `case`
136
+ -- arms must leave those alone; provider_id, which is rewritten unconditionally, moves.
137
+ ('d2460014-0000-4000-8000-000000000004', 'd2460004-0000-4000-8000-000000000004',
138
+ 'fed-human-38b45a2207cfb4847a4c62c9973cd676', 'agent_board',
139
+ jsonb_build_object('sub', 'mb246-f4-legacy-sub', 'slug', 'mb246-f4-legacy-slug'),
140
+ now(), now(), now())
141
+ on conflict (id) do update
142
+ set provider_id = excluded.provider_id,
143
+ identity_data = excluded.identity_data;
144
+
145
+ -- F1: the human agent whose HANDLE is the slug (the surface a relabel would move), plus
146
+ -- a second agent carrying the SAME stamp so DISTINCT is load-bearing.
147
+ insert into agent_board.agents (
148
+ id, agent_type, kind, handle, display_name, status, auth_user_id, owner_user_id, metadata
149
+ )
150
+ values
151
+ ('d2460101-0000-4000-8000-000000000001', 'human', 'human',
152
+ 'fed-human-d50f81b6913e066dba4c19f5cb6b202a', 'MB246 fixture F1 human', 'active',
153
+ 'd2460001-0000-4000-8000-000000000001', 'd2460001-0000-4000-8000-000000000001',
154
+ jsonb_build_object('federation', jsonb_build_object(
155
+ 'issuer', 'https://fixture-246.example/idp-a',
156
+ 'subject', 'fixture-246-subject-a'))),
157
+ ('d2460102-0000-4000-8000-000000000001', 'agent', 'agent',
158
+ 'mb246-fixture-f1-owned', 'MB246 fixture F1 owned agent', 'active',
159
+ null, 'd2460001-0000-4000-8000-000000000001',
160
+ jsonb_build_object('federation', jsonb_build_object(
161
+ 'issuer', 'https://fixture-246.example/idp-a',
162
+ 'subject', 'fixture-246-subject-a'))),
163
+
164
+ -- F2: owner reachable ONLY via owner_user_id.
165
+ ('d2460201-0000-4000-8000-000000000002', 'agent', 'agent',
166
+ 'mb246-fixture-a2', 'MB246 fixture F2 owned agent', 'active',
167
+ 'd2460002-0000-4000-8000-0000000000a2', 'd2460002-0000-4000-8000-000000000002',
168
+ jsonb_build_object('federation', jsonb_build_object(
169
+ 'issuer', 'https://fixture-246.example/idp-b',
170
+ 'subject', 'fixture-246-subject-b'))),
171
+
172
+ -- F3: the true pair, plus a decoy stamp that does not reproduce F3's slug.
173
+ ('d2460301-0000-4000-8000-000000000003', 'human', 'human',
174
+ 'fed-human-def7953eb096d786392090ce33952d9d', 'MB246 fixture F3 human', 'active',
175
+ 'd2460003-0000-4000-8000-000000000003', 'd2460003-0000-4000-8000-000000000003',
176
+ jsonb_build_object('federation', jsonb_build_object(
177
+ 'issuer', 'https://fixture-246.example/idp-c',
178
+ 'subject', 'fixture-246-subject-c'))),
179
+ ('d2460302-0000-4000-8000-000000000003', 'agent', 'agent',
180
+ 'mb246-fixture-f3-decoy', 'MB246 fixture F3 decoy-stamped agent', 'active',
181
+ null, 'd2460003-0000-4000-8000-000000000003',
182
+ jsonb_build_object('federation', jsonb_build_object(
183
+ 'issuer', 'https://fixture-246.example/idp-decoy',
184
+ 'subject', 'fixture-246-subject-decoy'))),
185
+
186
+ -- F4: an ordinary human row whose handle IS the slug, so the row is recovered and
187
+ -- re-keyed normally. Its value is entirely in the divergent meta/identity_data above.
188
+ ('d2460401-0000-4000-8000-000000000004', 'human', 'human',
189
+ 'fed-human-38b45a2207cfb4847a4c62c9973cd676', 'MB246 fixture F4 human', 'active',
190
+ 'd2460004-0000-4000-8000-000000000004', 'd2460004-0000-4000-8000-000000000004',
191
+ jsonb_build_object('federation', jsonb_build_object(
192
+ 'issuer', 'https://fixture-246.example/idp-d',
193
+ 'subject', 'fixture-246-subject-d')))
194
+ on conflict (id) do update
195
+ set handle = excluded.handle,
196
+ metadata = excluded.metadata;