@substrat-run/contract-tests 0.147.0 → 0.149.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 (89) hide show
  1. package/dist/bind-module.d.ts +4 -0
  2. package/dist/bind-module.d.ts.map +1 -1
  3. package/dist/capability-expiry-suite.d.ts.map +1 -1
  4. package/dist/capability-expiry-suite.js +6 -4
  5. package/dist/capability-expiry-suite.js.map +1 -1
  6. package/dist/counter-runner.d.ts +22 -0
  7. package/dist/counter-runner.d.ts.map +1 -0
  8. package/dist/counter-runner.js +96 -0
  9. package/dist/counter-runner.js.map +1 -0
  10. package/dist/derived-module.d.ts +4 -0
  11. package/dist/derived-module.d.ts.map +1 -1
  12. package/dist/directory-restore-suite.d.ts.map +1 -1
  13. package/dist/directory-restore-suite.js +66 -2
  14. package/dist/directory-restore-suite.js.map +1 -1
  15. package/dist/directory-shapes.d.ts +6 -0
  16. package/dist/directory-shapes.d.ts.map +1 -1
  17. package/dist/directory-shapes.js +42 -0
  18. package/dist/directory-shapes.js.map +1 -1
  19. package/dist/entity-state-module.d.ts +8 -0
  20. package/dist/entity-state-module.d.ts.map +1 -1
  21. package/dist/entity-state-module.js +3 -0
  22. package/dist/entity-state-module.js.map +1 -1
  23. package/dist/entity-trash-module.d.ts +4 -0
  24. package/dist/entity-trash-module.d.ts.map +1 -1
  25. package/dist/entity-trash-suite.d.ts.map +1 -1
  26. package/dist/entity-trash-suite.js +20 -0
  27. package/dist/entity-trash-suite.js.map +1 -1
  28. package/dist/erasure-module.d.ts +4 -0
  29. package/dist/erasure-module.d.ts.map +1 -1
  30. package/dist/held-migrations.d.ts +27 -0
  31. package/dist/held-migrations.d.ts.map +1 -0
  32. package/dist/held-migrations.js +39 -0
  33. package/dist/held-migrations.js.map +1 -0
  34. package/dist/index.d.ts +12 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +13 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/migration-fence-suite.d.ts +3 -0
  39. package/dist/migration-fence-suite.d.ts.map +1 -0
  40. package/dist/migration-fence-suite.js +173 -0
  41. package/dist/migration-fence-suite.js.map +1 -0
  42. package/dist/migration-fence.d.ts +42 -0
  43. package/dist/migration-fence.d.ts.map +1 -0
  44. package/dist/migration-fence.js +66 -0
  45. package/dist/migration-fence.js.map +1 -0
  46. package/dist/modules.d.ts +126 -0
  47. package/dist/modules.d.ts.map +1 -1
  48. package/dist/modules.js +23 -0
  49. package/dist/modules.js.map +1 -1
  50. package/dist/orphan-sweep-suite.d.ts +23 -0
  51. package/dist/orphan-sweep-suite.d.ts.map +1 -0
  52. package/dist/orphan-sweep-suite.js +411 -0
  53. package/dist/orphan-sweep-suite.js.map +1 -0
  54. package/dist/permission-suite.d.ts.map +1 -1
  55. package/dist/permission-suite.js +19 -0
  56. package/dist/permission-suite.js.map +1 -1
  57. package/dist/scope-host-suite.d.ts.map +1 -1
  58. package/dist/scope-host-suite.js +465 -10
  59. package/dist/scope-host-suite.js.map +1 -1
  60. package/dist/scope-store-runner-suite.d.ts +46 -0
  61. package/dist/scope-store-runner-suite.d.ts.map +1 -0
  62. package/dist/scope-store-runner-suite.js +187 -0
  63. package/dist/scope-store-runner-suite.js.map +1 -0
  64. package/dist/scope-store-suite.d.ts.map +1 -1
  65. package/dist/scope-store-suite.js +55 -103
  66. package/dist/scope-store-suite.js.map +1 -1
  67. package/dist/stamps-module.d.ts +4 -0
  68. package/dist/stamps-module.d.ts.map +1 -1
  69. package/dist/store-driver-release-suite.d.ts +12 -0
  70. package/dist/store-driver-release-suite.d.ts.map +1 -0
  71. package/dist/store-driver-release-suite.js +561 -0
  72. package/dist/store-driver-release-suite.js.map +1 -0
  73. package/dist/upload-link-module.d.ts +172 -0
  74. package/dist/upload-link-module.d.ts.map +1 -0
  75. package/dist/upload-link-module.js +157 -0
  76. package/dist/upload-link-module.js.map +1 -0
  77. package/dist/upload-link-replay-suite.d.ts +3 -0
  78. package/dist/upload-link-replay-suite.d.ts.map +1 -0
  79. package/dist/upload-link-replay-suite.js +291 -0
  80. package/dist/upload-link-replay-suite.js.map +1 -0
  81. package/dist/upload-link-suite.d.ts +3 -0
  82. package/dist/upload-link-suite.d.ts.map +1 -0
  83. package/dist/upload-link-suite.js +485 -0
  84. package/dist/upload-link-suite.js.map +1 -0
  85. package/dist/vertical-events-suite.d.ts +8 -0
  86. package/dist/vertical-events-suite.d.ts.map +1 -1
  87. package/dist/vertical-events-suite.js +46 -4
  88. package/dist/vertical-events-suite.js.map +1 -1
  89. package/package.json +3 -3
@@ -1 +1 @@
1
- {"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAqBL,KAAK,SAAS,EAqBf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAeL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAa9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAuCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,MACrB,OAAO,CAAC,OAAO,CAAC,QACb,SAAS,YACL,MAAM,KACf,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,IAAI,GAAE,qBAA0B,GAC/B,IAAI,CAytUN"}
1
+ {"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAqBL,KAAK,SAAS,EAsBf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAkBL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAc9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAuCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,MACrB,OAAO,CAAC,OAAO,CAAC,QACb,SAAS,YACL,MAAM,KACf,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,IAAI,GAAE,qBAA0B,GAC/B,IAAI,CA8rVN"}
@@ -1,9 +1,10 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
- import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
- import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
3
+ import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, unheldMigrationDigest, } from '@substrat-run/contracts';
4
+ import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, promoteReviewDigest, promoteReviewRefusalOf, rebindReviewRefusalOf, createUlid, ulid, } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
6
6
  import { testOperations } from './test-operations.js';
7
+ import { derivedDigestOf, heldMigrations } from './held-migrations.js';
7
8
  /**
8
9
  * A journal read whose every row is whole. A test that planted nothing has no business meeting
9
10
  * an `UndecodablePlatformRequest` (#1637), so one fails here, naming why, rather than being
@@ -3853,7 +3854,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3853
3854
  version,
3854
3855
  manifestDigest: `man-${version}`,
3855
3856
  permissionDigest: 'p',
3856
- migrationDigest: mig,
3857
+ ...(await heldMigrations(mig)),
3857
3858
  deploymentRef: null,
3858
3859
  });
3859
3860
  await host.admin.admitVersion(staff, id);
@@ -5236,6 +5237,113 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5236
5237
  expect(await host.admin.openSubjectPayloads(staff, t1, s, [{ subjectId: subject, sealed: sealed }]))
5237
5238
  .toEqual([null]);
5238
5239
  });
5240
+ /** A vertical with one admitted version, served on `ref`, and a scope pinned to `scopeRef`. */
5241
+ const pinnedErasureFixture = async (scopeRef) => {
5242
+ const slug = `pin-${ulid().toLowerCase().slice(-8)}`;
5243
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
5244
+ const versionId = ulid();
5245
+ const ref = `${slug}-serving`;
5246
+ await host.admin.publishVersion(staff, {
5247
+ id: versionId,
5248
+ verticalSlug: slug,
5249
+ version: `1.0.${versionId.slice(-4).toLowerCase()}`,
5250
+ manifestDigest: `m-${versionId}`,
5251
+ permissionDigest: 'p',
5252
+ migrationDigest: 'g',
5253
+ deploymentRef: ref,
5254
+ manifestJson: JSON.stringify({
5255
+ version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
5256
+ registry: { permissions: [], roles: [], entityGrants: [] },
5257
+ digests: { manifest: 'm', permission: 'p', migration: 'g' },
5258
+ }),
5259
+ });
5260
+ await host.admin.admitVersion(staff, versionId);
5261
+ const s = scopeId.parse(ulid());
5262
+ const subject = ulid();
5263
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: slug });
5264
+ const [sealed] = await host.admin.sealSubjectPayloads(staff, t1, s, [{ subjectId: subject, plaintext: 'private' }]);
5265
+ await host.admin.setVerticalServing(staff, slug, { ref, versionId, doClasses: ['ScopeDO'], migrationTag: 'v1' });
5266
+ await host.admin.setScopeServingRef(staff, t1, s, scopeRef ?? ref);
5267
+ const opened = () => host.admin.openSubjectPayloads(staff, t1, s, [{ subjectId: subject, sealed: sealed }]);
5268
+ return { slug, versionId, ref, s, subject, opened };
5269
+ };
5270
+ const redacted = [{ events: 0, intents: 0, jobRuns: 0, idempotencyResults: 0, intentIds: [],
5271
+ vertical: { verticalRows: [], hookRows: [], unreachedEntities: [] } }];
5272
+ it('an erasure claim fails when the serving pointer is not the pair it read, and the key stays (#2285)', async () => {
5273
+ const { versionId, ref, s, subject, opened } = await pinnedErasureFixture();
5274
+ const expected = { versionId: null, servingRef: ref, epoch: 0, copyCount: 0, servingPointer: { ref, versionId: ulid() } };
5275
+ await expectRefusal(host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, expected), 'precondition_failed');
5276
+ expect(await opened()).toEqual(['private']);
5277
+ expect((await host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, { ...expected, servingPointer: { ref, versionId } })).keyDestroyed).toBe(true);
5278
+ expect(await opened()).toEqual([null]);
5279
+ });
5280
+ it('a scope off its pin is fenced too: a pointer moved onto its script refuses the claim (#2285)', async () => {
5281
+ // The scope's own script is not the pointer's when the erasure reads it, so it runs no
5282
+ // pointer version. A rollback onto that script before the claim would make it run one.
5283
+ const { slug, versionId, ref, s, subject, opened } = await pinnedErasureFixture('old-script');
5284
+ const expected = { versionId: null, servingRef: 'old-script', epoch: 0, copyCount: 0, servingPointer: { ref, versionId } };
5285
+ await host.admin.setVerticalServing(staff, slug, { ref: 'old-script', versionId, doClasses: ['ScopeDO'], migrationTag: 'v1' });
5286
+ await expectRefusal(host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, expected), 'precondition_failed');
5287
+ expect(await opened()).toEqual(['private']);
5288
+ expect((await host.admin.finalizeSubjectShred(staff, t1, s, subject, redacted, { ...expected, servingPointer: { ref: 'old-script', versionId } })).keyDestroyed).toBe(true);
5289
+ });
5290
+ it('a direct shred with a fence claims it: a moved scope keeps the key, and the pair it read destroys it (#2285)', async () => {
5291
+ const { versionId, ref, s, subject, opened } = await pinnedErasureFixture();
5292
+ const fence = { versionId: null, servingRef: ref, epoch: 0, copyCount: 0, servingPointer: { ref, versionId: ulid() } };
5293
+ await expectRefusal(host.admin.shredSubject(staff, t1, s, subject, fence), 'precondition_failed');
5294
+ expect(await opened()).toEqual(['private']);
5295
+ expect(await host.admin.scopeErasureEpoch(staff, t1, s)).toBe(0);
5296
+ expect((await host.admin.shredSubject(staff, t1, s, subject, { ...fence, servingPointer: { ref, versionId } })).keyDestroyed)
5297
+ .toBe(true);
5298
+ expect(await opened()).toEqual([null]);
5299
+ // The claim moved the epoch every bind compares, as the orchestrated finalize's does.
5300
+ expect(await host.admin.scopeErasureEpoch(staff, t1, s)).toBe(1);
5301
+ });
5302
+ // #2286, L17: what the lifecycle reaches is every binding any version declared.
5303
+ it('keeps every scope-store binding a version declared, refuses a driver change at publish, and names none it cannot read', async () => {
5304
+ const slug = `bind-${ulid().toLowerCase().slice(-8)}`;
5305
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
5306
+ const publish = (scopeStores, raw) => {
5307
+ const id = ulid();
5308
+ return host.admin.publishVersion(staff, {
5309
+ id,
5310
+ verticalSlug: slug,
5311
+ version: `1.0.${id.slice(-6).toLowerCase()}`,
5312
+ manifestDigest: `m-${id}`,
5313
+ permissionDigest: 'p',
5314
+ migrationDigest: 'g',
5315
+ deploymentRef: null,
5316
+ manifestJson: raw ?? JSON.stringify({
5317
+ version: '1.0.0', entry: 'index.js', compatibilityDate: '2026-07-01',
5318
+ registry: { permissions: [], roles: [], entityGrants: [] },
5319
+ digests: { manifest: 'm', permission: 'p', migration: 'g' },
5320
+ ...(scopeStores ? { scopeStores } : {}),
5321
+ }),
5322
+ }).then(() => id);
5323
+ };
5324
+ await publish([{ binding: 'GRAPH', driver: 'graph' }, { binding: 'OLD', driver: 'graph' }]);
5325
+ await publish([{ binding: 'GRAPH', driver: 'graph' }]);
5326
+ // A dropped binding is still there.
5327
+ expect(await host.admin.verticalScopeStores(staff, slug)).toEqual([
5328
+ { binding: 'GRAPH', driver: 'graph' },
5329
+ { binding: 'OLD', driver: 'graph' },
5330
+ ]);
5331
+ // A driver change is refused, and the version is not stored.
5332
+ const before = (await host.admin.listVersions(staff, slug)).length;
5333
+ await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'OLD', driver: 'counter' }]), 'conflict');
5334
+ expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
5335
+ // So is a version that declares one binding twice.
5336
+ await expectRefusal(publish([{ binding: 'NEW', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]), 'conflict');
5337
+ expect((await host.admin.listVersions(staff, slug)).length).toBe(before);
5338
+ // The twin: the same driver again, with a new binding beside it, is published.
5339
+ await publish([{ binding: 'OLD', driver: 'graph' }, { binding: 'NEW', driver: 'graph' }]);
5340
+ expect((await host.admin.verticalScopeStores(staff, slug)).map((s) => s.binding)).toEqual(['GRAPH', 'NEW', 'OLD']);
5341
+ // A version whose manifest cannot be read may declare a store nobody can name.
5342
+ await publish(undefined, '{nope');
5343
+ await expectRefusal(host.admin.verticalScopeStores(staff, slug), 'unavailable');
5344
+ // An unknown vertical declared none.
5345
+ expect(await host.admin.verticalScopeStores(staff, `none-${slug}`)).toEqual([]);
5346
+ });
5239
5347
  // #1722 (Codex #2008 r7): the admin-log record of a staff resolution of a kept copy. The
5240
5348
  // resolution itself runs in the vertical's store; the directory only records it, in full.
5241
5349
  it('records a kept-copy resolution in the admin log, before and after, and refuses an unknown scope', async () => {
@@ -5744,7 +5852,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5744
5852
  const id = ulid();
5745
5853
  await host.admin.publishVersion(staff, {
5746
5854
  id, verticalSlug: 'migrationsapart', version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm',
5747
- permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null,
5855
+ permissionDigest: 'p', deploymentRef: null,
5856
+ // #2339: a set it carries is hashed as the host derives it; the others store none to check.
5857
+ migrationDigest: typeof manifest === 'string' || !Array.isArray(manifest.migrations) ? 'g' : await derivedDigestOf(manifest),
5748
5858
  manifestJson: typeof manifest === 'string' ? manifest : JSON.stringify(manifest),
5749
5859
  });
5750
5860
  return id;
@@ -5778,6 +5888,60 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5778
5888
  const junk = await publish('not json');
5779
5889
  expect(await host.admin.versionManifest(staff, 'migrationsapart', junk)).toBe('not json');
5780
5890
  expect(await host.admin.versionMigrations(staff, 'migrationsapart', junk)).toBeNull();
5891
+ // #2322: each migration's declared moves and discards are stored beside its SQL and read
5892
+ // back as published, whatever digest the publisher claimed; one declaring none has neither.
5893
+ const declared = [
5894
+ { ...migrations[0], moves: { legacy_a: 'a' } },
5895
+ { ...migrations[1] },
5896
+ { ...migrations[2], discards: { 'b.c': 'replaced by d; see "0003"' } },
5897
+ ];
5898
+ const withDeclarations = await publish({ ...base, migrations: declared });
5899
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', withDeclarations)).toEqual(declared);
5900
+ // A declaration the push boundary refuses (a discard with no reason) carries no SQL at all.
5901
+ const reasonless = await publish({ ...base, migrations: [{ ...migrations[0], discards: { a: '' } }] });
5902
+ expect(await host.admin.versionMigrations(staff, 'migrationsapart', reasonless)).toBeNull();
5903
+ });
5904
+ it("derives a version's migration digest from what it stores, and refuses a publish whose digest is not that (#2339)", async () => {
5905
+ const slug = `acme/digest-${ulid().slice(-8).toLowerCase()}`;
5906
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
5907
+ const A = [{ moduleId: 'helpdesk', version: '0001-init', sql: 'CREATE TABLE a (id TEXT);' }];
5908
+ const B = [...A, { moduleId: 'helpdesk', version: '0002-drop', sql: 'DROP TABLE a;' }];
5909
+ const declaredA = [{ ...A[0], discards: { 'a.id': 'nothing reads it' } }];
5910
+ const publish = async (migrationDigest, migrations, doClasses) => {
5911
+ const id = ulid();
5912
+ await host.admin.publishVersion(staff, {
5913
+ id, verticalSlug: slug, version: `1.0.${id.toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
5914
+ migrationDigest, deploymentRef: null,
5915
+ ...(migrations ? { manifestJson: JSON.stringify({ migrations, ...(doClasses ? { doClasses } : {}) }) } : {}),
5916
+ });
5917
+ return id;
5918
+ };
5919
+ const digestOfA = await derivedDigestOf({ migrations: A });
5920
+ const a = await publish(digestOfA, A);
5921
+ expect((await host.admin.getVersion(staff, a, slug))?.migrationDigest).toBe(digestOfA);
5922
+ // The base's digest over other SQL, over the same SQL with a declared loss added, or over other
5923
+ // store classes: refused, and nothing is stored.
5924
+ for (const [migrations, doClasses] of [[B, undefined], [declaredA, undefined], [A, ['OtherDO']]]) {
5925
+ const before = (await host.admin.listVersions(staff, slug)).length;
5926
+ await expectRefusal(publish(digestOfA, [...migrations], doClasses ? [...doClasses] : undefined), 'validation_failed', /is not the digest of the migrations it carries/);
5927
+ expect(await host.admin.listVersions(staff, slug)).toHaveLength(before);
5928
+ }
5929
+ // Its positive twin: the digest B's own migrations give is stored, and a promote over A asks.
5930
+ const b = await publish(await derivedDigestOf({ migrations: B }), B);
5931
+ await host.admin.admitVersion(staff, a);
5932
+ await host.admin.admitVersion(staff, b);
5933
+ await host.admin.promoteVersion(staff, slug, 'prod', a);
5934
+ await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', b), 'conflict', /changes migrations/);
5935
+ // A version that stores none cannot be checked: never refused, and it equals no other version.
5936
+ const bare = await publish(digestOfA);
5937
+ expect((await host.admin.getVersion(staff, bare, slug))?.migrationDigest).toBe(unheldMigrationDigest(bare));
5938
+ // The admin log records the digest the registry holds, not the one the push sent; so does a held one's.
5939
+ const published = async (id) => (await host.admin.auditLog(staff, {})).find((e) => e.action === 'publishVersion' && e.after?.id === id)
5940
+ ?.after?.migrationDigest;
5941
+ expect(await published(bare)).toBe(unheldMigrationDigest(bare));
5942
+ expect(await published(a)).toBe(digestOfA);
5943
+ await host.admin.admitVersion(staff, bare);
5944
+ await expectRefusal(host.admin.promoteVersion(staff, slug, 'prod', bare), 'conflict', /holds no migrations the platform can compare/);
5781
5945
  });
5782
5946
  it('an archived scope blocks the delete naming the reap step; a reaped tombstone never blocks', async () => {
5783
5947
  // A deleted app leaves an `archived` row (restorable via unarchive), then a
@@ -5885,12 +6049,24 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5885
6049
  version,
5886
6050
  manifestDigest: `man-${version}`,
5887
6051
  permissionDigest: digests.perm,
5888
- migrationDigest: digests.mig,
6052
+ ...(await heldMigrations(digests.mig)),
5889
6053
  deploymentRef: null,
5890
6054
  });
5891
6055
  await host.admin.admitVersion(staff, id);
5892
6056
  return id;
5893
6057
  };
6058
+ /**
6059
+ * #2333: the binding a review hands out, computed as the migrations read computes it — from
6060
+ * the stored rows of the version prod points at now and of the one being promoted.
6061
+ */
6062
+ const reviewOf = async (slug, versionId, base) => {
6063
+ const prod = base !== undefined ? base : ((await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId ?? null);
6064
+ const side = async (id) => ({
6065
+ ...(await host.admin.getVersion(staff, id, slug)),
6066
+ migrations: await host.admin.versionMigrations(staff, slug, id),
6067
+ });
6068
+ return { base: prod, digest: await promoteReviewDigest(prod === null ? null : await side(prod), await side(versionId)) };
6069
+ };
5894
6070
  it('promotes a first version with nothing to acknowledge', async () => {
5895
6071
  // The gate is about CHANGE, not existence. A first promotion has no
5896
6072
  // predecessor to diff against, so demanding an acknowledgement would be
@@ -5913,7 +6089,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5913
6089
  it('refuses a promotion that changes migrations until it is acknowledged', async () => {
5914
6090
  const v3 = await publish('2.2.0', { perm: 'pB', mig: 'gB' }); // migrations moved
5915
6091
  await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3), 'conflict', /changes migrations/);
5916
- await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true });
6092
+ // #2333: the acknowledgement that lets a migration reach live data names the review it answers.
6093
+ await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true }), 'precondition_failed', /needs the review it answers/);
6094
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v3, { migrationChange: true, review: await reviewOf('callout', v3) });
5917
6095
  expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')
5918
6096
  ?.versionId).toBe(v3);
5919
6097
  });
@@ -5950,9 +6128,259 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5950
6128
  await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
5951
6129
  // Positive twin: through its own vertical the same version promotes (the earlier tests
5952
6130
  // moved prod, so the digest checkpoints are acknowledged — they are not under test here).
5953
- await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
6131
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true, review: await reviewOf('callout', v) });
5954
6132
  expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
5955
6133
  });
6134
+ // -- the acknowledgement names the review it answers (#2333) ---------------
6135
+ // A review is computed over the channel's CURRENT version. A promote between showing it and
6136
+ // acknowledging it moves that base, and a bare flag would then acknowledge a diff that is not
6137
+ // the one applied. So the acknowledgement carries the review's binding, and the host refuses
6138
+ // one that no longer holds.
6139
+ describe('an acknowledgement bound to the review it answers (#2333)', () => {
6140
+ const MIGRATION = (discards, moves = {}) => ({
6141
+ moduleId: 'callout', version: '0002-drop', sql: 'ALTER TABLE t DROP COLUMN c;', discards, ...(Object.keys(moves).length ? { moves } : {}),
6142
+ });
6143
+ /** A fresh vertical with three admitted versions; `a` serves. */
6144
+ const fresh = async () => {
6145
+ const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
6146
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
6147
+ const put = async (perm, mig, migrations) => {
6148
+ const id = ulid();
6149
+ await host.admin.publishVersion(staff, {
6150
+ id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
6151
+ // #2339: the digest is the one the stored migrations give; `mig` names the set without them.
6152
+ permissionDigest: perm, deploymentRef: null,
6153
+ ...(migrations
6154
+ ? { migrationDigest: await derivedDigestOf({ migrations }), manifestJson: JSON.stringify({ migrations }) }
6155
+ : { migrationDigest: mig }),
6156
+ });
6157
+ await host.admin.admitVersion(staff, id);
6158
+ return id;
6159
+ };
6160
+ const a = await put('pA', 'gA', []);
6161
+ await host.admin.promoteVersion(staff, slug, 'prod', a);
6162
+ return { slug, put, a };
6163
+ };
6164
+ const prodOf = async (slug) => (await host.admin.listChannels(staff, slug)).find((c) => c.channel === 'prod')?.versionId;
6165
+ const refusal = async (p) => {
6166
+ const err = await p.then(() => undefined, (e) => e);
6167
+ return { kind: promoteReviewRefusalOf(err), message: err?.message };
6168
+ };
6169
+ it('a promote between show and acknowledge moves the base: the acknowledgement is refused, and a fresh review passes', async () => {
6170
+ const { slug, put, a } = await fresh();
6171
+ const c = await put('pC', 'gC', [MIGRATION({ c: 'the column is no longer read' })]);
6172
+ const shown = await reviewOf(slug, c);
6173
+ expect(shown.base).toBe(a);
6174
+ // Someone else promotes b in between: same migrations as a, a different permission surface.
6175
+ const b = await put('pB', 'gA', []);
6176
+ await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true, review: await reviewOf(slug, b) });
6177
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: shown }));
6178
+ expect(stale.kind).toBe('stale');
6179
+ expect(stale.message).toContain(`read against version ${a}`);
6180
+ expect(await prodOf(slug)).toBe(b);
6181
+ // The review read again names b, and is what an acknowledgement answers now.
6182
+ const again = await reviewOf(slug, c);
6183
+ expect(again).toEqual({ base: b, digest: expect.stringMatching(/^[0-9a-f]{64}$/) });
6184
+ expect(again.digest).not.toBe(shown.digest);
6185
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { permissionChange: true, migrationChange: true, review: again });
6186
+ expect(await prodOf(slug)).toBe(c);
6187
+ });
6188
+ it('two promotes bound to the same base, racing: one moves the channel, the other is refused as stale', async () => {
6189
+ const { slug, put, a } = await fresh();
6190
+ const b = await put('pB', 'gB', [MIGRATION({ c: 'gone' })]);
6191
+ const c = await put('pC', 'gC', [MIGRATION({ c: 'gone too' })]);
6192
+ const [forB, forC] = [await reviewOf(slug, b), await reviewOf(slug, c)];
6193
+ expect([forB.base, forC.base]).toEqual([a, a]);
6194
+ const ACK = { permissionChange: true, migrationChange: true };
6195
+ // Neither awaited before the other starts: each judges its gates against `a`.
6196
+ const settled = await Promise.allSettled([
6197
+ host.admin.promoteVersion(staff, slug, 'prod', b, { ...ACK, review: forB }),
6198
+ host.admin.promoteVersion(staff, slug, 'prod', c, { ...ACK, review: forC }),
6199
+ ]);
6200
+ const won = settled.flatMap((s, i) => (s.status === 'fulfilled' ? [[b, c][i]] : []));
6201
+ const lost = settled.flatMap((s) => (s.status === 'rejected' ? [s.reason] : []));
6202
+ expect(won).toHaveLength(1);
6203
+ expect(lost.map((e) => promoteReviewRefusalOf(e))).toEqual(['stale']);
6204
+ expect(await prodOf(slug)).toBe(won[0]);
6205
+ // One move away from `a`, by the winner; the loser recorded nothing.
6206
+ const history = await host.admin.listChannelHistory(staff, slug, 'prod');
6207
+ expect(history.filter((h) => h.fromVersionId === a).map((h) => h.versionId)).toEqual(won);
6208
+ expect(history.some((h) => h.versionId === (won[0] === b ? c : b))).toBe(false);
6209
+ });
6210
+ it('a binding is checked whatever it acknowledges: a moved base refuses even a promote that needs no acknowledgement', async () => {
6211
+ const { slug, put, a } = await fresh();
6212
+ const same = await put('pA', 'gA', []);
6213
+ const shown = await reviewOf(slug, same);
6214
+ const b = await put('pB', 'gA', []);
6215
+ await host.admin.promoteVersion(staff, slug, 'prod', b, { permissionChange: true });
6216
+ expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', same, { review: shown }))).kind).toBe('stale');
6217
+ expect(await prodOf(slug)).toBe(b);
6218
+ // Twin: bound to the base that still serves, it promotes.
6219
+ await host.admin.promoteVersion(staff, slug, 'prod', a, { permissionChange: true, review: await reviewOf(slug, a) });
6220
+ expect(await prodOf(slug)).toBe(a);
6221
+ });
6222
+ it('a review read before anything served is stale once something does', async () => {
6223
+ const slug = `acme/review-${ulid().slice(-8).toLowerCase()}`;
6224
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli' });
6225
+ const put = async () => {
6226
+ const id = ulid();
6227
+ await host.admin.publishVersion(staff, { id, verticalSlug: slug, version: id, manifestDigest: 'm', permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null });
6228
+ await host.admin.admitVersion(staff, id);
6229
+ return id;
6230
+ };
6231
+ const first = await put();
6232
+ const shown = await reviewOf(slug, first, null);
6233
+ expect(shown.base).toBeNull();
6234
+ const other = await put();
6235
+ await host.admin.promoteVersion(staff, slug, 'prod', other);
6236
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', first, { review: shown }));
6237
+ expect(stale.kind).toBe('stale');
6238
+ expect(stale.message).toContain('read against no serving version');
6239
+ });
6240
+ it('a digest that is not the one the stored rows give is refused, with the base right', async () => {
6241
+ const { slug, put, a } = await fresh();
6242
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
6243
+ const shown = await reviewOf(slug, c);
6244
+ // Another review's digest, as a client that bound the wrong answer would send.
6245
+ const other = await reviewOf(slug, await put('pA', 'gD', [MIGRATION({ c: 'gone, differently' })]));
6246
+ expect(other.base).toBe(a);
6247
+ const stale = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: other.digest } }));
6248
+ expect(stale.kind).toBe('stale');
6249
+ expect(stale.message).toMatch(/no longer give the digest/);
6250
+ // Never handed back: a refusal is not something to retry with.
6251
+ expect(stale.message).not.toContain(shown.digest);
6252
+ expect(await prodOf(slug)).toBe(a);
6253
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
6254
+ expect(await prodOf(slug)).toBe(c);
6255
+ });
6256
+ it('a migration acknowledgement without a binding is refused; a permission-only one is still honoured', async () => {
6257
+ const { slug, put } = await fresh();
6258
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' })]);
6259
+ const unbound = await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true }));
6260
+ expect(unbound.kind).toBe('unbound');
6261
+ // It names the fix: the client release that binds, and how to read the review.
6262
+ // By what the release does, never a version number the release tooling has not cut yet.
6263
+ expect(unbound.message).toContain("a CLI release whose 'substrat promote' supports --review");
6264
+ expect(unbound.message).toContain('/guide/deploying#acknowledgement-answers-one-review');
6265
+ expect(unbound.message).not.toMatch(/\d+\.\d+\.\d+/);
6266
+ // The compatibility rule: no migration moves, so an older client's bare flag still promotes.
6267
+ const p = await put('pP', 'gA', []);
6268
+ await host.admin.promoteVersion(staff, slug, 'prod', p, { permissionChange: true });
6269
+ expect(await prodOf(slug)).toBe(p);
6270
+ // And a migrationChange the gate does not consume needs no binding either.
6271
+ const q = await put('pP', 'gA', []);
6272
+ await host.admin.promoteVersion(staff, slug, 'prod', q, { migrationChange: true });
6273
+ expect(await prodOf(slug)).toBe(q);
6274
+ });
6275
+ it('the digest is over the stored declarations, and the binding is recorded', async () => {
6276
+ const { slug, put, a } = await fresh();
6277
+ const c = await put('pA', 'gC', [MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
6278
+ const shown = await reviewOf(slug, c);
6279
+ // What the host digests is what it stored: the declarations come back with the SQL…
6280
+ const stored = await host.admin.versionMigrations(staff, slug, c);
6281
+ expect(stored).toEqual([MIGRATION({ c: 'gone' }, { 't.a': 't.b' })]);
6282
+ // …and a review of the same version showing a reworded reason binds another digest.
6283
+ const side = async (id, migrations) => ({ ...(await host.admin.getVersion(staff, id, slug)), migrations: migrations });
6284
+ const reworded = await promoteReviewDigest(await side(a, await host.admin.versionMigrations(staff, slug, a)), await side(c, [MIGRATION({ c: 'gone, differently' }, { 't.a': 't.b' })]));
6285
+ expect(reworded).not.toBe(shown.digest);
6286
+ expect((await refusal(host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: { base: a, digest: reworded } }))).kind).toBe('stale');
6287
+ await host.admin.promoteVersion(staff, slug, 'prod', c, { migrationChange: true, review: shown });
6288
+ const row = (await host.admin.auditLog(staff, {})).find((e) => e.action === 'promoteVersion' && JSON.stringify(e.after).includes(c));
6289
+ expect(row?.after?.acknowledged).toEqual({ migrationChange: true, review: shown });
6290
+ });
6291
+ });
6292
+ // -- a rebind's route and pointer move in one write (#2347) ----------------
6293
+ // A rebind onto another lineage is judged against the version the scope is bound to and the
6294
+ // version the target serves. Flipping the route and crossing the pointer used to be two
6295
+ // writes, so a promote on the target between them left the route on the new script and the
6296
+ // pointer on the old lineage. Now they are one write, conditional on both sides.
6297
+ describe("a rebind's route and pointer move in one write, conditional on both sides (#2347)", () => {
6298
+ const lineages = async () => {
6299
+ const tag = ulid().slice(-8).toLowerCase();
6300
+ const [source, target] = [`acme/src-${tag}`, `acme/dst-${tag}`];
6301
+ const put = async (slug) => {
6302
+ const id = ulid();
6303
+ await host.admin.publishVersion(staff, {
6304
+ id, verticalSlug: slug, version: `1.0.${id.slice(-4).toLowerCase()}`, manifestDigest: 'm', permissionDigest: 'p',
6305
+ migrationDigest: 'g', deploymentRef: null,
6306
+ });
6307
+ await host.admin.admitVersion(staff, id);
6308
+ return id;
6309
+ };
6310
+ for (const slug of [source, target])
6311
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli', ownerTenant: t1 });
6312
+ const [s1, s2, t1v, t2v] = [await put(source), await put(source), await put(target), await put(target)];
6313
+ const ref = `serving-${tag}`;
6314
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
6315
+ const install = async () => {
6316
+ const sc = scopeId.parse(ulid());
6317
+ await host.provisionScope(staff, { tenantId: t1, scopeId: sc, vertical: source });
6318
+ await host.admin.activateScope(staff, t1, sc);
6319
+ await host.admin.bindScopeVersion(staff, t1, sc, s1);
6320
+ return sc;
6321
+ };
6322
+ const judged = { vertical: target, ref, versionId: t1v };
6323
+ return { source, target, s1, s2, t1v, t2v, ref, install, judged };
6324
+ };
6325
+ const where = async (sc) => {
6326
+ const r = (await host.admin.getScopeRecord(staff, t1, sc));
6327
+ return { vertical: r.vertical, versionId: r.verticalVersionId, servingRef: r.servingRef ?? null };
6328
+ };
6329
+ const refusal = (p) => p.then(() => undefined, (e) => e);
6330
+ it('lands route and pointer together when both sides still hold, and records both', async () => {
6331
+ const { source, target, s1, t1v, ref, install, judged } = await lineages();
6332
+ const sc = await install();
6333
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6334
+ await host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged });
6335
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
6336
+ const row = (await host.admin.auditLog(staff, { action: 'bindScopeVersion', scopeId: sc }))
6337
+ .find((e) => e.after?.versionId === t1v);
6338
+ expect(row?.before).toEqual({ servingRef: null });
6339
+ expect(row?.after).toMatchObject({ versionId: t1v, servingRef: ref, expectedVersionId: s1, expectedServing: judged });
6340
+ });
6341
+ it('a promote on the TARGET since the rebind was judged refuses it as stale, and moves neither route nor pointer', async () => {
6342
+ const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
6343
+ const sc = await install();
6344
+ // What the target serves moves on after the rebind read it.
6345
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' });
6346
+ const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
6347
+ expect(rebindReviewRefusalOf(err)).toBe('stale');
6348
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6349
+ // The twin: judged against what serves now, it lands.
6350
+ await host.admin.bindScopeVersion(staff, t1, sc, t2v, { servingRef: ref, expectedVersionId: s1, expectedServing: { ...judged, versionId: t2v } });
6351
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t2v, servingRef: ref });
6352
+ });
6353
+ it('a move of the SOURCE binding since the rebind was judged refuses it as stale; a plain conditional bind keeps its own refusal', async () => {
6354
+ const { source, s1, s2, t1v, ref, install, judged } = await lineages();
6355
+ const sc = await install();
6356
+ await host.admin.bindScopeVersion(staff, t1, sc, s2);
6357
+ const err = await refusal(host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }));
6358
+ expect(rebindReviewRefusalOf(err)).toBe('stale');
6359
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s2, servingRef: null });
6360
+ // Without `expectedServing` the bind is the #1722 compare-and-set it always was.
6361
+ const plain = await refusal(host.admin.bindScopeVersion(staff, t1, sc, s1, { expectedVersionId: s1 }));
6362
+ expect(rebindReviewRefusalOf(plain)).toBeNull();
6363
+ expect(String(plain)).toMatch(/scope binding changed/);
6364
+ });
6365
+ it('racing a promote on the target: either the rebind lands whole, or it is refused as stale and nothing moved', async () => {
6366
+ const { source, target, s1, t1v, t2v, ref, install, judged } = await lineages();
6367
+ for (let round = 0; round < 4; round++) {
6368
+ const sc = await install();
6369
+ await host.admin.setVerticalServing(staff, target, { ref, versionId: t1v, doClasses: [], migrationTag: 'g' });
6370
+ const [rebind] = await Promise.allSettled([
6371
+ host.admin.bindScopeVersion(staff, t1, sc, t1v, { servingRef: ref, expectedVersionId: s1, expectedServing: judged }),
6372
+ host.admin.setVerticalServing(staff, target, { ref, versionId: t2v, doClasses: [], migrationTag: 'g' }),
6373
+ ]);
6374
+ if (rebind.status === 'fulfilled') {
6375
+ expect(await where(sc)).toEqual({ vertical: target, versionId: t1v, servingRef: ref });
6376
+ }
6377
+ else {
6378
+ expect(rebindReviewRefusalOf(rebind.reason)).toBe('stale');
6379
+ expect(await where(sc)).toEqual({ vertical: source, versionId: s1, servingRef: null });
6380
+ }
6381
+ }
6382
+ });
6383
+ });
5956
6384
  // -- a version that strands people a link bound (#2178) -------------------
5957
6385
  // A binding shape's people keep it until they are released, and the levers need the shape:
5958
6386
  // an unbind needs it to bind, a retirement needs it to be a bootstrap shape, an erasure finds
@@ -5979,8 +6407,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5979
6407
  const id = ulid();
5980
6408
  await host.admin.publishVersion(staff, {
5981
6409
  id, verticalSlug: slug, version: `3.0.${id.slice(-4).toLowerCase()}`, manifestDigest: `m-${id}`,
5982
- permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null,
5983
- manifestJson: JSON.stringify({ registry: { permissions: [], roles: [], entityGrants } }),
6410
+ permissionDigest: 'p', deploymentRef: null,
6411
+ ...(await heldMigrations('g', { registry: { permissions: [], roles: [], entityGrants } })),
5984
6412
  });
5985
6413
  if (admit)
5986
6414
  await host.admin.admitVersion(staff, id);
@@ -6078,6 +6506,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6078
6506
  await host.admin.setScopeServingRef(staff, t1, sc, ref);
6079
6507
  expect(await routeOf()).toBe(ref);
6080
6508
  });
6509
+ it('a bind that routes the scope in the same write (#2347) is judged on the script it routes to', async () => {
6510
+ const { slug, v } = await listed();
6511
+ const sc = await install(slug, v.binds);
6512
+ const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
6513
+ await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.dropped, doClasses: [], migrationTag: 'g' });
6514
+ // The pointer stays on the binding version; the route moves onto a script that runs one
6515
+ // that drops the shape. Judged without the route, this bind would pass.
6516
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref }), 'precondition_failed', /drops the shape/);
6517
+ const r = await host.admin.getScopeRecord(staff, t1, sc);
6518
+ expect([r?.verticalVersionId, r?.servingRef ?? null]).toEqual([v.binds, null]);
6519
+ // The twin: the script serves a version that retires the keys, and both move in one write.
6520
+ await host.admin.setVerticalServing(staff, slug, { ref, versionId: v.retired, doClasses: [], migrationTag: 'g' });
6521
+ await host.admin.bindScopeVersion(staff, t1, sc, v.binds, { servingRef: ref });
6522
+ const after = await host.admin.getScopeRecord(staff, t1, sc);
6523
+ expect([after?.verticalVersionId, after?.servingRef]).toEqual([v.binds, ref]);
6524
+ });
6081
6525
  it('a promote is judged from the version served in place too, and replacing what serves is judged like a promote (review of #2181)', async () => {
6082
6526
  const { slug, v } = await listed();
6083
6527
  const ref = `serving-${ulid().slice(-8).toLowerCase()}`;
@@ -6467,7 +6911,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6467
6911
  version,
6468
6912
  manifestDigest: `man-${version}`,
6469
6913
  permissionDigest: digests?.perm ?? 'pp',
6470
- migrationDigest: digests?.mig ?? 'gg',
6914
+ ...(await heldMigrations(digests?.mig ?? 'gg')),
6471
6915
  deploymentRef: null,
6472
6916
  });
6473
6917
  return id;
@@ -6916,6 +7360,17 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6916
7360
  const reclaimed = (await host.admin.listHostnames(staff, { scopeId: s2 })).find((h) => h.hostname === 'eu.example.com');
6917
7361
  expect(reclaimed?.scopeId).toBe(s2);
6918
7362
  });
7363
+ it('an unbind for one scope leaves a name rebound to another scope bound (#2285)', async () => {
7364
+ // `eu.example.com` is s2's now (the test above). An unroute of s1 that listed the name while
7365
+ // it was s1's must not take it from s2: the owner is judged by the delete itself.
7366
+ const s2Names = async () => (await host.admin.listHostnames(staff, { scopeId: s2 })).map((h) => h.hostname);
7367
+ await host.admin.unbindHostname(staff, 'eu.example.com', { tenantId: t1, scopeId: s1 });
7368
+ expect(await s2Names()).toContain('eu.example.com');
7369
+ // The twin: the owner it names unbinds it, and the unbind is audited on that scope.
7370
+ await host.admin.unbindHostname(staff, 'EU.example.com', { tenantId: t2, scopeId: s2 });
7371
+ expect(await s2Names()).not.toContain('eu.example.com');
7372
+ expect((await host.admin.auditLog(staff, { scopeId: s2 })).map((e) => e.action)).toContain('unbindHostname');
7373
+ });
6919
7374
  it('rejects duplicate module registration', () => {
6920
7375
  expect(() => host.registerModule({ manifest: testModManifest })).toThrow(/already registered/);
6921
7376
  });