@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
package/src/index.js CHANGED
@@ -39,6 +39,26 @@ export function loadOrderedMigrations() {
39
39
 
40
40
  // Parse a supplied migration-history snapshot: an array of { id/version,
41
41
  // sha256? } (applied migrations), or a plain array of version-id strings.
42
+ // Collisions this bundle can HEAL, and the migration that heals each one.
43
+ //
44
+ // A version collision is a PERMANENT fact about a database: Supabase's
45
+ // schema_migrations has `version` as its primary key, so an adopter's row at a
46
+ // bundle migration's version never disappears. Reporting `collision` on the
47
+ // mere presence of that row would therefore fail a fully-remediated database
48
+ // FOREVER, and an adopter forced to ignore exit 14 also stops seeing genuine
49
+ // `behind` and `divergent` states -- strictly worse than the problem it warns
50
+ // about. So a collision whose remediating migration is applied is reported as
51
+ // remediated and does not drive the posture.
52
+ //
53
+ // This map is a DECLARATION, not an enumeration: nothing in the catalog can
54
+ // derive "which migration heals which", so it is stated here and asserted in
55
+ // the tests -- both ids must exist in the bundle, and the healer must actually
56
+ // restate the skipped migration's function body.
57
+ export const COLLISION_REMEDIATIONS = Object.freeze({
58
+ '20260809000100_agent_board_consent_gating':
59
+ '20260905000100_agent_board_consent_gating_reconcile',
60
+ });
61
+
42
62
  export function normalizeHistory(history) {
43
63
  const rows = Array.isArray(history) ? history : history?.applied ?? [];
44
64
  const map = new Map();
package/src/plan.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // history entries the adopter has that the bundle does not (ahead). Never
5
5
  // touches the database.
6
6
 
7
- import { loadOrderedMigrations, normalizeHistory } from './index.js';
7
+ import { COLLISION_REMEDIATIONS, loadOrderedMigrations, normalizeHistory } from './index.js';
8
8
 
9
9
  export function plan(history) {
10
10
  const migrations = loadOrderedMigrations();
@@ -21,6 +21,75 @@ export function plan(history) {
21
21
 
22
22
  const ahead = [...applied.keys()].filter((id) => !bundleIds.has(id));
23
23
 
24
+ // VERSION COLLISIONS (message-board#217). Supabase keys
25
+ // supabase_migrations.schema_migrations on the 14-digit VERSION PREFIX alone,
26
+ // not the name. So an adopter who independently authored a migration at the
27
+ // same version has that version recorded under THEIR name, and the bundle's
28
+ // migration is skipped silently -- no error, and schema_version still reports
29
+ // the bundle's number, so the deploy looks successful.
30
+ //
31
+ // Such a database already shows up here as one `pending` plus one `ahead`, but
32
+ // that pair reads like a malformed history file rather than a skipped
33
+ // migration, and the natural reading ("we applied it, the tool just cannot see
34
+ // the digest") is the wrong one. Naming the collision explicitly is the
35
+ // difference between a puzzle and an instruction.
36
+ const versionOf = (id) => String(id).split('_')[0];
37
+ const aheadByVersion = new Map();
38
+ for (const id of ahead) {
39
+ const v = versionOf(id);
40
+ if (!aheadByVersion.has(v)) aheadByVersion.set(v, []);
41
+ aheadByVersion.get(v).push(id);
42
+ }
43
+ const allCollisions = perId
44
+ .filter((r) => r.action === 'pending')
45
+ .flatMap((r) => (aheadByVersion.get(versionOf(r.id)) ?? [])
46
+ // An ahead id with no NAME carries no evidence: it cannot be distinguished
47
+ // from the bundle's own migration at that version. Two shapes reach here --
48
+ // a bare version (normalizeHistory accepts plain version strings) and
49
+ // `<version>_` from a row whose `name` is NULL, which schema_migrations
50
+ // permits and the documented psql recipe renders exactly that way. A
51
+ // substring test for '_' admits the second and invents a collision from a
52
+ // legitimate history. Require an actual name.
53
+ .filter((recordedAs) => String(recordedAs).slice(versionOf(recordedAs).length + 1).length > 0)
54
+ .map((recordedAs) => ({
55
+ version: versionOf(r.id),
56
+ expected: r.id,
57
+ recordedAs,
58
+ remediatedBy: COLLISION_REMEDIATIONS[r.id] ?? null,
59
+ })));
60
+
61
+ // A collision is LIVE only while its healer is unapplied. Once healed the row
62
+ // remains in schema_migrations forever, but the DDL is present, so the
63
+ // database is correct and must be allowed to report a clean posture.
64
+ // The healer must be applied AND not divergent. A divergent digest means the
65
+ // database applied SOMETHING under that id which is not what this bundle ships,
66
+ // so nothing here can claim the skipped DDL was restored -- and claiming it
67
+ // would be the same false-success the collision detector exists to prevent.
68
+ // An UNKNOWN digest is not divergence: histories without digests are a
69
+ // supported shape, and demanding one would strand those adopters in the same
70
+ // permanent red this design just removed.
71
+ const healerDivergent = new Map(
72
+ perId.filter((r) => r.action === 'applied-divergent').map((r) => [r.id, true]),
73
+ );
74
+ const remediates = (c) =>
75
+ c.remediatedBy != null && applied.has(c.remediatedBy) && !healerDivergent.has(c.remediatedBy);
76
+ const collisions = allCollisions.filter((c) => !remediates(c));
77
+ const collisionsRemediated = allCollisions.filter(remediates);
78
+
79
+ // A remediated collision must stop affecting the posture ENTIRELY, not merely
80
+ // stop being called a collision. Both halves of the pair are permanent: the
81
+ // bundle's migration can never be applied on that database (its version is
82
+ // taken) so it would count `pending` forever, and the adopter's row can never
83
+ // be removed so it would count `ahead` forever. Left in, they gate the healed
84
+ // database at `behind`/10 in perpetuity -- a different permanent red than the
85
+ // one just fixed, which is why "not collision" was too weak a claim to make.
86
+ const remediatedExpected = new Set(collisionsRemediated.map((c) => c.expected));
87
+ const remediatedRecordedAs = new Set(collisionsRemediated.map((c) => c.recordedAs));
88
+ for (const r of perId) {
89
+ if (remediatedExpected.has(r.id) && r.action === 'pending') r.action = 'collision-remediated';
90
+ }
91
+ const effectiveAhead = ahead.filter((id) => !remediatedRecordedAs.has(id));
92
+
24
93
  const counts = perId.reduce((acc, r) => {
25
94
  acc[r.action] = (acc[r.action] || 0) + 1;
26
95
  return acc;
@@ -29,16 +98,21 @@ export function plan(history) {
29
98
  const divergent = perId.filter((r) => r.action === 'applied-divergent').length;
30
99
  const pending = perId.filter((r) => r.action === 'pending').length;
31
100
  let posture;
32
- if (divergent > 0) posture = 'divergent';
33
- else if (ahead.length > 0 && pending === 0) posture = 'ahead';
101
+ // Collision outranks every other posture: it is the only one where the
102
+ // database is silently missing bundle DDL while reporting success.
103
+ if (collisions.length > 0) posture = 'collision';
104
+ else if (divergent > 0) posture = 'divergent';
105
+ else if (effectiveAhead.length > 0 && pending === 0) posture = 'ahead';
34
106
  else if (pending > 0) posture = 'behind';
35
107
  else posture = 'exact';
36
108
 
37
109
  return {
38
110
  schema: 'agent_board_plan.v1',
39
111
  posture,
40
- counts: { ...counts, ahead: ahead.length },
41
- ahead,
112
+ counts: { ...counts, ahead: effectiveAhead.length, collision: collisions.length, collisionRemediated: collisionsRemediated.length },
113
+ ahead: effectiveAhead,
114
+ collisions,
115
+ collisionsRemediated,
42
116
  perId,
43
117
  };
44
118
  }
@@ -5,7 +5,8 @@ import { join } from 'node:path';
5
5
  import { loadManifest, loadOrderedMigrations, sha256 } from './index.js';
6
6
  import { materialize } from './materialize.js';
7
7
  import { plan } from './plan.js';
8
- import { check, EXIT } from './check.js';
8
+ import { check, EXIT, POSTURE_ACTIONS } from './check.js';
9
+ import { COLLISION_REMEDIATIONS } from './index.js';
9
10
 
10
11
  const manifest = loadManifest();
11
12
  const ordered = loadOrderedMigrations();
@@ -37,9 +38,9 @@ describe('manifest integrity', () => {
37
38
  expect(manifest.schemaVersion).toBe(8);
38
39
  expect(manifest.minClientVersion).toMatch(/^\d+\.\d+\.\d+$/);
39
40
  });
40
- it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM forward migrations (migrationCount = prior 112 + 1 = 113)', () => {
41
+ it('registers the four MBAUTHZ + four MBSENDAUTHZ + four MBSESSION + four MBDELEG + three MBFED + three MBHARDEN + two MBPORTALGATE + two MBPORTAL-PR32 + two ROOMOWN + two ROOMLINK + one ROOMDELEG + one LOCALAUTH + one ROOMUIAPI + one FEDSESSION + one FEDSESSION-AUTOJOIN + one GATENULL + one MEMBSTATE + one AGENTSDIR + one LEAVEIDEM + one ROLERESET + one MBROWID + one MBHYGIENE + one MBCONSENT-A + one MBCONSENT-B(a) + one MBCONSENT-B(i) + one MBCONSENT-B(ii) + one MBCONSENT-B(iii) + one MBHARDEN-#97 + one MBHARDEN-#118 + one INVITEPOLICY + one OBSGATE + one OBSGATE-2 + one OBSGATE-3 + one OBSGATE-4 + one FLEETMACHINERLS + one RESOLVENOOP + one ROOMDELEG-LANEB + one CLAIMKEY + two MINTHARDEN + one FLEETMACHINEHIJACK + one CLAIMGATE-SHADOW + one CLAIMGATE-ENFORCE + one FLEETMACHINEREACTIVATION + one DELEGCAPCEILING + one FLEETMACHINERECLAIM + one DELEGSOLEWRITER + one FLEETDOMAINRECLAIM + five ATOMICFLEETONBOARDING (onboarding + ceiling-lock + revocation-evidence + fedsession-gate + fedsession-lock-order) + one OUTBOXWAKE + one ENROLLLOCKORDER + one FEDPROVIDERABSENCE + one WAKERECOVERY + one CONSENTRECONCILE forward migrations (migrationCount = prior 113 + 5 + 1 + 1 + 1 + 1 + 1 = 123)', () => {
41
42
  expect(manifest.migrationCount).toBe(manifest.migrations.length);
42
- expect(manifest.migrationCount).toBe(113);
43
+ expect(manifest.migrationCount).toBe(123);
43
44
  // MBSESSION switched enforcement on (scoped-send re-grant): the client-visible
44
45
  // schema version bumped to 8. MBDELEG tightens delegation and MBFED adds only
45
46
  // enrollment-only additive surface (identity_providers registry, enrollment audit,
@@ -199,7 +200,239 @@ describe('plan / check postures + exit codes (SemVer contract)', () => {
199
200
  expect(check({ applied }).exitCode).toBe(11);
200
201
  });
201
202
  it('exit-code map is a frozen SemVer surface', () => {
202
- expect(EXIT).toEqual({ exact: 0, behind: 10, ahead: 11, divergent: 12, error: 13 });
203
+ expect(EXIT).toEqual({ exact: 0, behind: 10, ahead: 11, divergent: 12, error: 13, collision: 14 });
204
+ });
205
+
206
+ // VERSION COLLISION (message-board#217). Supabase keys schema_migrations on the
207
+ // 14-digit version prefix alone, so an adopter migration authored at a bundle
208
+ // migration's version makes the bundle's file skip SILENTLY -- no error, and
209
+ // schema_version still reports the bundle's number. The reported case is real:
210
+ // consiliency-portal recorded 20260809000100 as reconcile_panel_schema_drift.
211
+ // The adopter's own migration occupies the bundle's version, so the bundle's
212
+ // file was skipped. Two DISTINCT states matter, and conflating them is the
213
+ // defect a review seat caught: the collision ROW is permanent (Supabase keys
214
+ // schema_migrations on version, which is its primary key), so reporting on the
215
+ // row alone would fail a fully-healed database forever -- and an adopter forced
216
+ // to ignore exit 14 stops seeing genuine behind/divergent states too.
217
+ const portalHistory = (opts = {}) => ({
218
+ applied: [
219
+ ...ordered
220
+ .filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
221
+ .filter((m) => opts.healed || m.id !== '20260905000100_agent_board_consent_gating_reconcile')
222
+ .map((m) => ({ id: m.id, sha256: m.sha256 })),
223
+ { id: '20260809000100_reconcile_panel_schema_drift', sha256: 'aa' },
224
+ ],
225
+ });
226
+
227
+ it('collision when an adopter migration occupies a bundle version and is unhealed', () => {
228
+ const c = check(portalHistory());
229
+ expect(c.posture).toBe('collision');
230
+ expect(c.exitCode).toBe(14);
231
+ });
232
+
233
+ it('collision names BOTH sides, so the report is an instruction not a puzzle', () => {
234
+ expect(plan(portalHistory()).collisions).toEqual([
235
+ {
236
+ version: '20260809000100',
237
+ expected: '20260809000100_agent_board_consent_gating',
238
+ recordedAs: '20260809000100_reconcile_panel_schema_drift',
239
+ remediatedBy: '20260905000100_agent_board_consent_gating_reconcile',
240
+ },
241
+ ]);
242
+ });
243
+
244
+ it('collision outranks behind — "behind" invites re-applying, which is the no-op that leaves the DB wrong', () => {
245
+ const p = plan(portalHistory());
246
+ expect(p.perId.filter((r) => r.action === 'pending').map((r) => r.id)).toContain(
247
+ '20260809000100_agent_board_consent_gating',
248
+ );
249
+ expect(p.posture).toBe('collision');
250
+ });
251
+
252
+ it('a HEALED database reports exact/0 — not merely "not collision"', () => {
253
+ // Asserting "not collision / not 14" was too weak and a review seat broke it:
254
+ // both halves of a remediated pair are PERMANENT (the bundle migration can
255
+ // never be applied on that database because its version is taken, and the
256
+ // adopter's row can never be removed), so leaving them in the counts gated
257
+ // the healed database at behind/10 forever — a different permanent red than
258
+ // the one being fixed. The claim is exact/0, so that is what is asserted.
259
+ const p = plan(portalHistory({ healed: true }));
260
+ expect(p.collisions).toEqual([]);
261
+ expect(p.collisionsRemediated.map((c) => c.expected)).toEqual([
262
+ '20260809000100_agent_board_consent_gating',
263
+ ]);
264
+ expect(p.counts.pending ?? 0).toBe(0);
265
+ expect(p.ahead).toEqual([]);
266
+ expect(p.posture).toBe('exact');
267
+ expect(check(portalHistory({ healed: true })).exitCode).toBe(0);
268
+ });
269
+
270
+ it('a NULL migration name is indeterminate, not a collision', () => {
271
+ // schema_migrations.name is nullable, and the documented psql recipe renders
272
+ // such a row as `<version>_`. A substring test for '_' called that a named
273
+ // row and invented a collision from a legitimate history.
274
+ const applied = [
275
+ ...ordered
276
+ .filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
277
+ .map((m) => ({ id: m.id, sha256: m.sha256 })),
278
+ { id: '20260809000100_', sha256: 'aa' },
279
+ ];
280
+ const p = plan({ applied });
281
+ expect(p.collisions).toEqual([]);
282
+ expect(check({ applied }).exitCode).toBe(10); // behind, which is true: the DDL is absent
283
+ });
284
+
285
+ it('a DIVERGENT healer does not remediate — it is not the migration we shipped', () => {
286
+ // The database applied something under the healer's id that is not this
287
+ // bundle's bytes, so nothing here can claim the skipped DDL was restored.
288
+ // Posture already goes divergent, but reporting the collision as REMEDIATED
289
+ // would be the same false-success the detector exists to prevent.
290
+ const healer = '20260905000100_agent_board_consent_gating_reconcile';
291
+ const applied = [
292
+ ...ordered
293
+ .filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
294
+ .map((m) => ({ id: m.id, sha256: m.id === healer ? 'deadbeef' : m.sha256 })),
295
+ { id: '20260809000100_reconcile_panel_schema_drift', sha256: 'aa' },
296
+ ];
297
+ const p = plan({ applied });
298
+ expect(p.collisionsRemediated).toEqual([]);
299
+ expect(p.collisions).toHaveLength(1);
300
+ expect(p.posture).toBe('collision');
301
+ });
302
+
303
+ it('an UNKNOWN healer digest still remediates — digest-less histories are supported', () => {
304
+ // normalizeHistory accepts entries without a digest. Requiring one would
305
+ // strand those adopters in the permanent red this design removes.
306
+ const applied = [
307
+ ...ordered.filter((m) => m.id !== '20260809000100_agent_board_consent_gating').map((m) => m.id),
308
+ '20260809000100_reconcile_panel_schema_drift',
309
+ ];
310
+ const p = plan({ applied });
311
+ expect(p.collisionsRemediated).toHaveLength(1);
312
+ expect(p.collisions).toEqual([]);
313
+ });
314
+
315
+ it('the healer being present is what clears it — not merely being declared', () => {
316
+ // Same declaration, healer unapplied: still live. Without this the previous
317
+ // test would pass on a map lookup rather than on the database's actual state.
318
+ expect(plan(portalHistory()).collisionsRemediated).toEqual([]);
319
+ expect(plan(portalHistory()).collisions).toHaveLength(1);
320
+ });
321
+
322
+ it('a bare-version history is indeterminate, not 123 collisions', () => {
323
+ // normalizeHistory accepts plain version strings. Such an entry carries no
324
+ // name, so it cannot be told apart from the bundle's own migration at that
325
+ // version — reporting it as a collision turns a supported input shape into a
326
+ // wall of false positives.
327
+ const p = plan({ applied: ordered.map((m) => m.id.split('_')[0]) });
328
+ expect(p.collisions).toEqual([]);
329
+ });
330
+
331
+ it('an unrelated adopter migration is NOT a collision, even with a bundle migration pending', () => {
332
+ // The history MUST have something pending as well as something ahead. A first
333
+ // draft of this test used an exact history plus one extra entry, and a mutation
334
+ // that reported every ahead entry as a collision SURVIVED it: with nothing
335
+ // pending there is no cross-product to over-report, so the assertion passed
336
+ // while checking nothing. A negative assertion driven by a clean input proves
337
+ // nothing (CLAUDE.md rule 2).
338
+ const applied = [
339
+ ...ordered
340
+ .filter((m) => m.id !== '20260809000100_agent_board_consent_gating')
341
+ .map((m) => ({ id: m.id, sha256: m.sha256 })),
342
+ { id: '99999999999999_extra', sha256: 'ff' },
343
+ ];
344
+ const p = plan({ applied });
345
+ expect(p.perId.some((r) => r.action === 'pending')).toBe(true); // the input is not clean
346
+ expect(p.ahead).toContain('99999999999999_extra');
347
+ expect(p.collisions).toEqual([]);
348
+ expect(p.posture).toBe('behind');
349
+ });
350
+ });
351
+
352
+ // OPERATOR GUIDANCE. Checks EXIT and POSTURE_ACTIONS agree with each other.
353
+ //
354
+ // This is NOT the completeness guard, and an earlier version of this comment
355
+ // claimed it was. EXIT is a SECOND hand list, not the catalog: a posture added to
356
+ // plan() without an EXIT entry passes everything here, falls through check()'s
357
+ // `EXIT[p.posture] ?? EXIT.error` to exit 13, and prints no action. The real
358
+ // catalog is the set of values plan() assigns, and scripts/check-posture-consumers.mjs
359
+ // reads it from there -- and also holds the live-drift consumer to using
360
+ // POSTURE_ACTIONS, which no test in this package can see. That script runs in
361
+ // `schema:verify`. What follows is the in-package half: the two tables stay
362
+ // consistent, and the collision action never tells an operator to re-apply.
363
+ describe('every posture has operator guidance', () => {
364
+ const postures = Object.keys(EXIT).filter((k) => k !== 'error');
365
+
366
+ it('EXIT and POSTURE_ACTIONS cover the same postures', () => {
367
+ expect(Object.keys(POSTURE_ACTIONS).sort()).toEqual(postures.sort());
368
+ });
369
+
370
+ it('every non-exact posture has a non-empty action', () => {
371
+ for (const posture of postures) {
372
+ if (posture === 'exact') {
373
+ expect(POSTURE_ACTIONS.exact).toBeNull(); // nothing to do is not a missing action
374
+ continue;
375
+ }
376
+ expect(typeof POSTURE_ACTIONS[posture]).toBe('string');
377
+ expect(POSTURE_ACTIONS[posture].length).toBeGreaterThan(20);
378
+ }
379
+ });
380
+
381
+ it("the collision action does NOT tell an operator to re-apply", () => {
382
+ // Re-applying is the no-op that leaves the database wrong: the version is taken.
383
+ expect(POSTURE_ACTIONS.collision).toMatch(/no-op|not simply re-apply|Do NOT/i);
384
+ });
385
+ });
386
+
387
+ // COLLISION REMEDIATION (message-board#217). The reconcile migration's whole
388
+ // no-op-on-a-healthy-database property rests on its join_board body being
389
+ // byte-identical to the one in the migration an affected database skipped. The
390
+ // PR asserted that in prose and nothing checked it -- the exact shape this repo
391
+ // keeps getting caught by. The digest tests freeze each file's bytes, which is
392
+ // not the same claim: two files can both be frozen and still differ.
393
+ describe('collision remediation is real, not merely declared', () => {
394
+ const read = (id) =>
395
+ readFileSync(new URL(`../migrations/${id}.sql`, import.meta.url), 'utf8');
396
+ const joinBoardBlock = (sql) => {
397
+ const start = sql.search(/create or replace function agent_board\.join_board\b/i);
398
+ const rest = sql.slice(start + 10);
399
+ const next = rest.search(/create or replace function agent_board\./i);
400
+ return (next === -1 ? rest : rest.slice(0, next)).trimEnd();
401
+ };
402
+
403
+ it('every declared remediation names migrations that exist in the bundle', () => {
404
+ const ids = new Set(ordered.map((m) => m.id));
405
+ for (const [skipped, healer] of Object.entries(COLLISION_REMEDIATIONS)) {
406
+ expect(ids.has(skipped)).toBe(true);
407
+ expect(ids.has(healer)).toBe(true);
408
+ // The healer must come LATER, or it cannot run after the skip.
409
+ expect(healer > skipped).toBe(true);
410
+ }
411
+ });
412
+
413
+ it('the healer restates the skipped function BYTE-IDENTICALLY, so a healthy DB is a true no-op', () => {
414
+ const skipped = joinBoardBlock(read('20260809000100_agent_board_consent_gating'));
415
+ const healer = joinBoardBlock(read('20260905000100_agent_board_consent_gating_reconcile'));
416
+ expect(healer.length).toBeGreaterThan(1000);
417
+ expect(healer).toBe(skipped);
418
+ });
419
+
420
+ it('the healer touches ONLY the stale function, so it cannot clobber a later definition', () => {
421
+ // resolve_board_request and update_board_membership are superseded by
422
+ // 20260819000100 and 20260813000100. Restating them here would re-apply the
423
+ // older bodies over the newer ones on every healthy database.
424
+ const sql = read('20260905000100_agent_board_consent_gating_reconcile');
425
+ const defined = [...sql.matchAll(/create or replace function agent_board\.(\w+)/gi)].map((m) => m[1]);
426
+ expect(defined).toEqual(['join_board']);
427
+ });
428
+
429
+ it('the healer adds no grant, revoke or comment — create-or-replace preserves the ACL', () => {
430
+ const sql = read('20260905000100_agent_board_consent_gating_reconcile')
431
+ .split('\n').filter((l) => !l.trimStart().startsWith('--')).join('\n');
432
+ expect(sql).not.toMatch(/\bgrant\b/i);
433
+ expect(sql).not.toMatch(/\brevoke\b/i);
434
+ expect(sql).not.toMatch(/comment\s+on/i);
435
+ expect(sql).not.toMatch(/drop\s+function/i);
203
436
  });
204
437
  });
205
438