@substrat-run/contract-tests 0.132.1 → 0.134.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.
@@ -1,7 +1,7 @@
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, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, } from '@substrat-run/contracts';
4
- import { isSearchIndexTable, REDACTED_DELIVERY_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, ulid, } from '@substrat-run/kernel';
3
+ import { connectionId, dataSubjectId, domainEvent, errorCodeOf, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
+ import { isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, ulid, } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
6
6
  /**
7
7
  * Assert a refusal by the CODE it declared, not by the sentence it happens to carry
@@ -1641,6 +1641,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1641
1641
  it('rejects an unknown table', async () => {
1642
1642
  await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
1643
1643
  });
1644
+ it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
1645
+ // The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
1646
+ // option's name; a large but valid limit is still clamped rather than refused.
1647
+ const read = (limit, offset) => host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit, offset });
1648
+ const refused = (pattern) => (err) => errorCodeOf(err) === 'validation_failed' && pattern.test(err.message);
1649
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5]) {
1650
+ await expect(read(bad, 0)).rejects.toSatisfy(refused(/limit must be a positive integer/));
1651
+ }
1652
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
1653
+ await expect(read(1, bad)).rejects.toSatisfy(refused(/offset must be a non-negative integer/));
1654
+ }
1655
+ const clamped = await read(SCOPE_TABLE_PAGE_MAX + 1_000, 0);
1656
+ expect(clamped.limit).toBe(SCOPE_TABLE_PAGE_MAX);
1657
+ expect((await read(1, 0)).offset).toBe(0);
1658
+ });
1644
1659
  // #1524: the size behind the on-demand storage reading. The growth assertion is what
1645
1660
  // separates a real size from a constant. A host that answered a fixed number, or
1646
1661
  // the size of some other database, passes "positive integer" and fails this.
@@ -2204,6 +2219,401 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2204
2219
  const twice = (await journal(kind)).find((r) => r.id === id);
2205
2220
  expect(twice).toEqual(once);
2206
2221
  });
2222
+ // -- the free-text copies (#1632) ---------------------------------------
2223
+ //
2224
+ // The directory keeps failure text about a scope: an ops failure's message, its
2225
+ // issue's newest exemplar, a sweep record's error. The drain's failure row quotes the
2226
+ // intent it gave up on, so a row about a tombstoned intent is a copy of redacted text;
2227
+ // anything else is reached only when it names the subject's id.
2228
+ describe('the directory\'s failure text, and a queued sweep record (#1632)', () => {
2229
+ const op = (name) => `intent.erasure-${name}-${ulid()}`;
2230
+ const fail = (operation, message, tenant = t1, scope = s1) => host.admin.recordOpsFailure({
2231
+ actor: staff,
2232
+ operation,
2233
+ stage: 'terminal',
2234
+ tenantId: tenant,
2235
+ scopeId: scope,
2236
+ message,
2237
+ });
2238
+ const failureOf = async (operation) => (await host.admin.listOpsFailures(staff, { operation }))[0];
2239
+ const issueOf = async (operation) => (await host.admin.listIssues(staff, { operation }))[0];
2240
+ const sweepOf = async (unit) => (await host.admin.listSweepRuns(staff, { unit }))[0];
2241
+ const snapshot = async (ops, units) => {
2242
+ const out = {};
2243
+ for (const [k, o] of Object.entries(ops)) {
2244
+ out[`failure:${k}`] = await failureOf(o);
2245
+ out[`issue:${k}`] = await issueOf(o);
2246
+ }
2247
+ for (const [k, u] of Object.entries(units))
2248
+ out[`sweep:${k}`] = await sweepOf(u);
2249
+ return out;
2250
+ };
2251
+ it('redacts the subject\'s failure text, and leaves every other row as it was', async () => {
2252
+ const kind = `connector:erasure-${ulid()}`;
2253
+ const erased = dataSubjectId.parse(ulid());
2254
+ const spared = dataSubjectId.parse(ulid());
2255
+ const linked = await routeIntent(kind, erased, 'Anna Ek');
2256
+ const unlinked = await routeIntent(kind, spared, 'Bo Lund');
2257
+ const ops = {
2258
+ linked: op('linked'),
2259
+ byId: op('by-id'),
2260
+ platform: op('platform'),
2261
+ spared: op('spared'),
2262
+ otherTenant: op('other-tenant'),
2263
+ unlinked: op('unlinked'),
2264
+ nameOnly: op('name-only'),
2265
+ };
2266
+ await fail(ops.linked, platformIntentFailureMessage(linked, 'failed: HTTP 409: Anna Ek needs a personal number'));
2267
+ await fail(ops.byId, `schedule threw on contact ${erased} (Anna Ek)`);
2268
+ await fail(ops.platform, `digest named contact ${erased}`, null, null);
2269
+ await fail(ops.spared, `schedule threw on contact ${spared} (Bo Lund)`);
2270
+ await fail(ops.otherTenant, `schedule threw on contact ${erased}`, t2, null);
2271
+ await fail(ops.unlinked, platformIntentFailureMessage(unlinked, 'failed: HTTP 409: Bo Lund needs a personal number'));
2272
+ await fail(ops.nameOnly, 'schedule threw: Anna Ek has no postal address');
2273
+ const units = {
2274
+ byId: `${s1}:erasure/by-id-${ulid()}`,
2275
+ spared: `${s1}:erasure/spared-${ulid()}`,
2276
+ otherTenant: `${s1}:erasure/other-tenant-${ulid()}`,
2277
+ };
2278
+ const sweep = (unit, error, tenant = t1) => host.admin.recordSweepRun({
2279
+ kind: 'schedule',
2280
+ unit,
2281
+ outcome: 'failed',
2282
+ tenantId: tenant,
2283
+ scopeId: tenant === t1 ? s1 : null,
2284
+ operation: 'erasure/run',
2285
+ error,
2286
+ });
2287
+ await sweep(units.byId, `contact ${erased} (Anna Ek) bounced`);
2288
+ await sweep(units.spared, `contact ${spared} (Bo Lund) bounced`);
2289
+ await sweep(units.otherTenant, `contact ${erased} bounced`, t2);
2290
+ const before = await snapshot(ops, units);
2291
+ await host.admin.shredSubject(staff, t1, s1, erased);
2292
+ const after = await snapshot(ops, units);
2293
+ // Text about the subject: the drain's row about a tombstoned intent, and every row
2294
+ // in this tenant (or the platform's own) that names the subject's id.
2295
+ for (const k of ['linked', 'byId', 'platform']) {
2296
+ expect(after[`failure:${k}`]).toEqual({ ...before[`failure:${k}`], message: REDACTED_FAILURE_NOTE });
2297
+ // The platform's own exemplar is attributed as the platform's, and goes on a
2298
+ // direct id match exactly as its ops-failure row does.
2299
+ expect(after[`issue:${k}`]).toEqual({ ...before[`issue:${k}`], lastMessage: REDACTED_FAILURE_NOTE });
2300
+ }
2301
+ expect(after['sweep:byId']).toEqual({ ...before['sweep:byId'], error: REDACTED_FAILURE_NOTE });
2302
+ // The twins: another subject's text, another tenant's row, a drain row about an
2303
+ // intent the erasure did not touch — and the documented residual, a sentence that
2304
+ // names the person with neither their id nor an intent link (§13.1).
2305
+ for (const k of ['spared', 'otherTenant', 'unlinked', 'nameOnly']) {
2306
+ expect(after[`failure:${k}`]).toEqual(before[`failure:${k}`]);
2307
+ expect(after[`issue:${k}`]).toEqual(before[`issue:${k}`]);
2308
+ }
2309
+ expect(after['sweep:spared']).toEqual(before['sweep:spared']);
2310
+ expect(after['sweep:otherTenant']).toEqual(before['sweep:otherTenant']);
2311
+ // Idempotent: the note names neither the subject nor an intent.
2312
+ await host.admin.shredSubject(staff, t1, s1, erased);
2313
+ expect(await snapshot(ops, units)).toEqual(after);
2314
+ await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
2315
+ });
2316
+ it('reaches a drain failure recorded after an earlier erasure tombstoned its intent', async () => {
2317
+ // An erasure that crashed between its scope half and its directory half, re-run:
2318
+ // the intent is already a tombstone, so the link has to come from every tombstone
2319
+ // in the journal and not only the ones this pass wrote.
2320
+ const kind = `connector:erasure-${ulid()}`;
2321
+ const erased = dataSubjectId.parse(ulid());
2322
+ const id = await routeIntent(kind, erased, 'Anna Ek');
2323
+ await host.admin.shredSubject(staff, t1, s1, erased);
2324
+ const operation = op('late');
2325
+ await fail(operation, platformIntentFailureMessage(id, 'gave up after 5 drain attempts — last error: Anna Ek'));
2326
+ await host.admin.shredSubject(staff, t1, s1, erased);
2327
+ expect((await failureOf(operation)).message).toBe(REDACTED_FAILURE_NOTE);
2328
+ expect((await issueOf(operation)).lastMessage).toBe(REDACTED_FAILURE_NOTE);
2329
+ });
2330
+ /** Whose an exemplar is, as the directory stores it — not on the issue read shape. */
2331
+ const exemplarOwnerOf = async (operation) => {
2332
+ const dump = await host.admin.exportDirectory(staff);
2333
+ const t = dump.tables.find((x) => x.name === '_substrat_issues');
2334
+ const row = t.rows.find((r) => r[t.columns.indexOf('operation')] === operation);
2335
+ return { kind: row[t.columns.indexOf('last_owner_kind')], tenant: row[t.columns.indexOf('last_tenant_id')] };
2336
+ };
2337
+ it('rewrites an issue exemplar only when its writer attributed it to the erasing tenant', async () => {
2338
+ // An issue has no tenant of its own and outlives its ops-failure rows (180 days vs
2339
+ // 90), so the writer records whose failure each exemplar copied, and erasure reads
2340
+ // that rather than the rows. Expiry is simulated by restoring a directory copy with
2341
+ // the source rows taken out — what the 90-day prune leaves behind.
2342
+ const erased = dataSubjectId.parse(ulid());
2343
+ const ops = {
2344
+ otherExpired: op('other-expired'),
2345
+ ownExpired: op('own-expired'),
2346
+ legacy: op('legacy'),
2347
+ legacyPlatform: op('legacy-platform'),
2348
+ };
2349
+ await fail(ops.otherExpired, `schedule threw on contact ${erased}`, t2, null);
2350
+ await fail(ops.ownExpired, `schedule threw on contact ${erased} (Anna Ek)`);
2351
+ await fail(ops.legacy, `schedule threw on contact ${erased} (Anna Ek)`);
2352
+ await fail(ops.legacyPlatform, `digest named contact ${erased}`, null, null);
2353
+ const copy = await host.admin.exportDirectory(staff);
2354
+ const expired = new Set(Object.values(ops));
2355
+ await host.admin.restoreDirectory(staff, {
2356
+ ...copy,
2357
+ tables: copy.tables.map((t) => {
2358
+ if (t.name === '_substrat_ops_failures') {
2359
+ const col = t.columns.indexOf('operation');
2360
+ return { ...t, rows: t.rows.filter((r) => !expired.has(r[col])) };
2361
+ }
2362
+ if (t.name === '_substrat_issues') {
2363
+ // Issues from before attribution that nothing could backfill: owner unknown.
2364
+ const op = t.columns.indexOf('operation');
2365
+ const owner = [t.columns.indexOf('last_tenant_id'), t.columns.indexOf('last_owner_kind')];
2366
+ const legacy = new Set([ops.legacy, ops.legacyPlatform]);
2367
+ return {
2368
+ ...t,
2369
+ rows: t.rows.map((r) => (legacy.has(r[op]) ? r.map((c, i) => (owner.includes(i) ? null : c)) : r)),
2370
+ };
2371
+ }
2372
+ return t;
2373
+ }),
2374
+ });
2375
+ expect(await host.admin.listOpsFailures(staff, { operation: ops.ownExpired })).toEqual([]);
2376
+ const before = {
2377
+ otherExpired: await issueOf(ops.otherExpired),
2378
+ ownExpired: await issueOf(ops.ownExpired),
2379
+ legacy: await issueOf(ops.legacy),
2380
+ legacyPlatform: await issueOf(ops.legacyPlatform),
2381
+ };
2382
+ await host.admin.shredSubject(staff, t1, s1, erased);
2383
+ // Another tenant's exemplar, its rows expired: byte-equal.
2384
+ expect(await issueOf(ops.otherExpired)).toEqual(before.otherExpired);
2385
+ // This tenant's, its rows expired: the attribution is enough.
2386
+ expect(await issueOf(ops.ownExpired)).toEqual({ ...before.ownExpired, lastMessage: REDACTED_FAILURE_NOTE });
2387
+ // Owner unknown: skipped — the legacy residual (§13.1 limit 9), never a guess, whether
2388
+ // the text was a tenant's or the platform's.
2389
+ expect(await issueOf(ops.legacy)).toEqual(before.legacy);
2390
+ expect(await issueOf(ops.legacyPlatform)).toEqual(before.legacyPlatform);
2391
+ });
2392
+ it('attributes each exemplar to the failure it copied, and a newer one takes it over', async () => {
2393
+ const erased = dataSubjectId.parse(ulid());
2394
+ const shared = op('shared');
2395
+ const text = `schedule threw on contact ${erased}`;
2396
+ await fail(shared, text);
2397
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t1 });
2398
+ // The same group, a newer exemplar from another tenant: the attribution moves with it.
2399
+ await fail(shared, text, t2, null);
2400
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t2 });
2401
+ const exemplar = await issueOf(shared);
2402
+ await host.admin.shredSubject(staff, t1, s1, erased);
2403
+ // This tenant's own row goes; the exemplar is now another tenant's and stays.
2404
+ const rows = await host.admin.listOpsFailures(staff, { operation: shared });
2405
+ expect(rows.find((r) => r.tenantId === t1).message).toBe(REDACTED_FAILURE_NOTE);
2406
+ expect(rows.find((r) => r.tenantId === t2).message).toBe(text);
2407
+ expect(await issueOf(shared)).toEqual(exemplar);
2408
+ // And the platform's own failure leaves the platform's exemplar — never "unknown".
2409
+ await fail(shared, 'the platform failed', null, null);
2410
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'platform', tenant: null });
2411
+ });
2412
+ it('ages telemetry out on the scheduled pass, not only when something new is written', async () => {
2413
+ // Issues, ops failures and sweep runs prune on write, which bounds them only while
2414
+ // something is written. Erasure skips an issue of unknown owner on the promise that
2415
+ // it ages out within its retention, so a quiet directory must shed it on time too.
2416
+ // Aged by restoring a directory copy with the rows' timestamps moved past retention.
2417
+ const expired = { op: op('expired'), unit: `${s1}:erasure/expired-${ulid()}` };
2418
+ const fresh = { op: op('fresh'), unit: `${s1}:erasure/fresh-${ulid()}` };
2419
+ for (const x of [expired, fresh]) {
2420
+ await fail(x.op, 'a failure');
2421
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2422
+ }
2423
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2424
+ const copy = await host.admin.exportDirectory(staff);
2425
+ const age = (t, match, matchCol, cols) => {
2426
+ const m = t.columns.indexOf(matchCol);
2427
+ const at = cols.map((c) => t.columns.indexOf(c));
2428
+ return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2429
+ };
2430
+ await host.admin.restoreDirectory(staff, {
2431
+ ...copy,
2432
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
2433
+ : t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
2434
+ : t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
2435
+ : t),
2436
+ });
2437
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
2438
+ const report = await runPlatformSweep(host, {
2439
+ actor: staff,
2440
+ fetch: connectorTestFetch,
2441
+ sweepers: {},
2442
+ drainRetries: false,
2443
+ });
2444
+ expect(report.errors.filter((e) => e.kind === 'telemetry')).toEqual([]);
2445
+ expect(report.telemetry?.opsFailures).toBeGreaterThanOrEqual(1);
2446
+ expect(report.telemetry?.issues).toBeGreaterThanOrEqual(1);
2447
+ expect(report.telemetry?.sweepRuns).toBeGreaterThanOrEqual(1);
2448
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
2449
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
2450
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
2451
+ // The positive twin: rows inside their retention stay.
2452
+ expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
2453
+ expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
2454
+ expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
2455
+ });
2456
+ it('drains a telemetry backlog larger than the batch over passes, oldest first', async () => {
2457
+ // After a long pause or a restore the backlog can be months of rows, and one
2458
+ // unbounded DELETE over it fails on the Durable Object every tick. Each pass
2459
+ // deletes at most `limit` rows per table, so the backlog converges instead.
2460
+ await host.admin.pruneTelemetry(staff, 100_000); // whatever earlier tests left
2461
+ const backlog = Array.from({ length: 5 }, (_, i) => ({ op: op(`backlog-${i}`), unit: `${s1}:erasure/backlog-${i}-${ulid()}` }));
2462
+ const fresh = { op: op('kept'), unit: `${s1}:erasure/kept-${ulid()}` };
2463
+ for (const x of [...backlog, fresh]) {
2464
+ await fail(x.op, 'a failure');
2465
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2466
+ }
2467
+ const ops = new Set(backlog.map((b) => b.op));
2468
+ const units = new Set(backlog.map((b) => b.unit));
2469
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2470
+ const copy = await host.admin.exportDirectory(staff);
2471
+ const age = (t, match, matchCol, cols) => {
2472
+ const m = t.columns.indexOf(matchCol);
2473
+ const at = cols.map((c) => t.columns.indexOf(c));
2474
+ return { ...t, rows: t.rows.map((r) => (match.has(r[m]) ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2475
+ };
2476
+ await host.admin.restoreDirectory(staff, {
2477
+ ...copy,
2478
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, ops, 'operation', ['at'])
2479
+ : t.name === '_substrat_issues' ? age(t, ops, 'operation', ['first_seen', 'last_seen'])
2480
+ : t.name === '_substrat_sweep_runs' ? age(t, units, 'unit', ['at'])
2481
+ : t),
2482
+ });
2483
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 3, issues: 3, sweepRuns: 3 });
2484
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 2, issues: 2, sweepRuns: 2 });
2485
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 0, issues: 0, sweepRuns: 0 });
2486
+ for (const b of backlog) {
2487
+ expect(await host.admin.listOpsFailures(staff, { operation: b.op })).toEqual([]);
2488
+ expect(await host.admin.listIssues(staff, { operation: b.op })).toEqual([]);
2489
+ expect(await host.admin.listSweepRuns(staff, { unit: b.unit })).toEqual([]);
2490
+ }
2491
+ // Rows inside their retention are never part of a batch.
2492
+ expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
2493
+ expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
2494
+ expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
2495
+ });
2496
+ it('refuses a row bound that is not a positive integer, wherever one reaches a LIMIT', async () => {
2497
+ // SQLite reads a negative LIMIT as no limit at all, so `-1` turned the bounded
2498
+ // telemetry prune into an unbounded one; a fraction or a non-finite value fails
2499
+ // inside SQLite on every later call. Each is refused with the option's name, and
2500
+ // nothing is deleted on the way.
2501
+ const expired = { op: op('bound'), unit: `${s1}:erasure/bound-${ulid()}` };
2502
+ await fail(expired.op, 'a failure');
2503
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: expired.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2504
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2505
+ const copy = await host.admin.exportDirectory(staff);
2506
+ const age = (t, match, matchCol, cols) => {
2507
+ const m = t.columns.indexOf(matchCol);
2508
+ const at = cols.map((c) => t.columns.indexOf(c));
2509
+ return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2510
+ };
2511
+ await host.admin.restoreDirectory(staff, {
2512
+ ...copy,
2513
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
2514
+ : t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
2515
+ : t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
2516
+ : t),
2517
+ });
2518
+ const sweep = { actor: staff, fetch: connectorTestFetch, sweepers: {}, drainRetries: false };
2519
+ const refused = (name) => (err) => errorCodeOf(err) === 'validation_failed' && new RegExp(`${name} must be a positive integer`).test(err.message);
2520
+ for (const bad of [-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
2521
+ await expect(host.admin.pruneTelemetry(staff, bad)).rejects.toSatisfy(refused('limit'));
2522
+ await expect(host.admin.pruneAccessLog(staff, bad)).rejects.toSatisfy(refused('limit'));
2523
+ await expect(runPlatformSweep(host, { ...sweep, telemetryBatch: bad })).rejects.toSatisfy(refused('telemetryBatch'));
2524
+ await expect(runPlatformSweep(host, { ...sweep, accessLogBatch: bad })).rejects.toSatisfy(refused('accessLogBatch'));
2525
+ expect(() => startPlatformSweeper(host, { ...sweep, telemetryBatch: bad, intervalMs: 60_000 })).toThrow(/telemetryBatch must be a positive integer/);
2526
+ await expect(host.runDueJobs(t1, s1, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
2527
+ await expect(host.admin.readUndrainedEvents(staff, t1, s1, bad)).rejects.toThrow(/limit must be a positive integer/);
2528
+ await expect(host.admin.auditLog(staff, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
2529
+ }
2530
+ // Nothing went on the way: the expired rows are all still there.
2531
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toHaveLength(1);
2532
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
2533
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toHaveLength(1);
2534
+ // The positive twin: a valid bound drains them.
2535
+ while (Object.values(await host.admin.pruneTelemetry(staff, 1)).some((n) => n > 0))
2536
+ ;
2537
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
2538
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
2539
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
2540
+ });
2541
+ it('rewrites a queued sweep-runs entry\'s error, and keeps the rest of the intent', async () => {
2542
+ // A CP-less pass queues its schedule outcomes as one `sweep-runs` intent, and the
2543
+ // drain lands each entry's `error` in `_substrat_sweep_runs`. The intent is kept like
2544
+ // every other, so the same text is held twice. `sweep-runs` is platform-authored —
2545
+ // module code cannot enqueue it — so the rows are planted through a restore.
2546
+ const erased = dataSubjectId.parse(ulid());
2547
+ const spared = dataSubjectId.parse(ulid());
2548
+ // Guarantees the journal has a row to copy the envelope columns from.
2549
+ const template = await routeIntent(`connector:erasure-${ulid()}`, spared, 'Bo Lund');
2550
+ const backup = await host.admin.exportScope(staff, t1, s1);
2551
+ const table = backup.tables.find((t) => t.name === '_substrat_platform_requests');
2552
+ const idCol = table.columns.indexOf('id');
2553
+ const base = table.rows.find((r) => r[idCol] === template);
2554
+ const at = '2026-09-20T00:00:00.000Z';
2555
+ const entry = (operation, error) => ({
2556
+ kind: 'schedule',
2557
+ operation,
2558
+ outcome: error === null ? 'ok' : 'failed',
2559
+ at,
2560
+ error,
2561
+ elapsedMs: 12,
2562
+ });
2563
+ const payloads = {
2564
+ mine: {
2565
+ version: null,
2566
+ entries: [
2567
+ entry('crm/sync', `contact ${erased} (Anna Ek) bounced`),
2568
+ entry('crm/tidy', 'nothing to tidy'),
2569
+ entry('crm/ok', null),
2570
+ ],
2571
+ },
2572
+ theirs: { version: 'v-1', entries: [entry('crm/sync', `contact ${spared} (Bo Lund) bounced`)] },
2573
+ };
2574
+ const ids = { mine: ulid(), theirs: ulid() };
2575
+ const plant = (id, payload) => table.columns.map((c, i) => c === 'id' ? id
2576
+ : c === 'kind' ? SWEEP_RUNS_KIND
2577
+ : c === 'payload' ? JSON.stringify(payload)
2578
+ : c === 'status' ? 'pending'
2579
+ : c === 'attempts' ? 0
2580
+ : c === 'settled_at' || c === 'last_error' || c === 'last_failure' || c === 'result' ? null
2581
+ : base[i]);
2582
+ const s = scopeId.parse(ulid());
2583
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
2584
+ await host.admin.activateScope(staff, t1, s);
2585
+ await host.restoreScope(staff, t1, s, {
2586
+ ...backup,
2587
+ scopeId: s,
2588
+ tables: backup.tables.map((t) => t === table ? { ...t, rows: [...t.rows, plant(ids.mine, payloads.mine), plant(ids.theirs, payloads.theirs)] } : t),
2589
+ });
2590
+ await host.settlePlatformRequest(t1, s1, template, { status: 'done' });
2591
+ const queued = async () => {
2592
+ const rows = await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND });
2593
+ return { mine: rows.find((r) => r.id === ids.mine), theirs: rows.find((r) => r.id === ids.theirs) };
2594
+ };
2595
+ const before = await queued();
2596
+ await host.admin.shredSubject(staff, t1, s, erased);
2597
+ const after = await queued();
2598
+ // Only the error that named the subject; the shape, the other entries, the
2599
+ // version and the row's own columns are as they were — still pending, so the
2600
+ // drain lands the note instead of the text.
2601
+ expect(after.mine).toEqual({
2602
+ ...before.mine,
2603
+ payload: {
2604
+ ...payloads.mine,
2605
+ entries: [
2606
+ { ...payloads.mine.entries[0], error: REDACTED_FAILURE_NOTE },
2607
+ payloads.mine.entries[1],
2608
+ payloads.mine.entries[2],
2609
+ ],
2610
+ },
2611
+ });
2612
+ expect(after.theirs).toEqual(before.theirs);
2613
+ await host.admin.shredSubject(staff, t1, s, erased);
2614
+ expect(await queued()).toEqual(after);
2615
+ });
2616
+ });
2207
2617
  });
2208
2618
  it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
2209
2619
  const subject = dataSubjectId.parse(ulid());
@@ -3972,6 +4382,37 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3972
4382
  // A scope binds to a VERSION, so dev/staging/prod are the same vertical pinned
3973
4383
  // differently. The invariant that earns the registry its keep is that a push
3974
4384
  // is not a deploy.
4385
+ it('compare-and-set binding refuses stale moves and permits only one concurrent winner (#1722)', async () => {
4386
+ const slug = `cas-bind-${ulid().toLowerCase()}`;
4387
+ await host.admin.registerVertical(staff, { slug, name: 'Binding guard', source: 'cli', ownerTenant: t1 });
4388
+ const versions = [ulid(), ulid(), ulid()];
4389
+ for (const [index, id] of versions.entries()) {
4390
+ await host.admin.publishVersion(staff, { id, verticalSlug: slug, version: `1.0.${index}`,
4391
+ manifestDigest: 'm', permissionDigest: 'p', migrationDigest: 'g', deploymentRef: null });
4392
+ }
4393
+ const s = scopeId.parse(ulid());
4394
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: slug });
4395
+ await host.admin.activateScope(staff, t1, s);
4396
+ const [v1, v2, v3] = versions;
4397
+ await host.admin.bindScopeVersion(staff, t1, s, v1, { expectedVersionId: null });
4398
+ const before = await host.admin.getScopeRecord(staff, t1, s);
4399
+ await expect(host.admin.bindScopeVersion(staff, t1, s, v2, { expectedVersionId: null }))
4400
+ .rejects.toThrow(/scope binding changed/);
4401
+ expect(await host.admin.getScopeRecord(staff, t1, s)).toEqual(before);
4402
+ const moves = await Promise.allSettled([
4403
+ host.admin.bindScopeVersion(staff, t1, s, v2, { expectedVersionId: v1 }),
4404
+ host.admin.bindScopeVersion(staff, t1, s, v3, { expectedVersionId: v1 }),
4405
+ ]);
4406
+ expect(moves.filter((move) => move.status === 'fulfilled')).toHaveLength(1);
4407
+ const loser = moves.find((move) => move.status === 'rejected');
4408
+ expect(String(loser.reason)).toMatch(/scope binding changed/);
4409
+ const winner = (await host.admin.getScopeRecord(staff, t1, s)).verticalVersionId;
4410
+ expect([v2, v3]).toContain(winner);
4411
+ const log = await host.admin.auditLog(staff, { action: 'bindScopeVersion', scopeId: s });
4412
+ expect(log).toHaveLength(2); // first bind and winner; refusals record no successful move
4413
+ await host.admin.bindScopeVersion(staff, t1, s, v1); // legacy unconditional caller
4414
+ expect((await host.admin.getScopeRecord(staff, t1, s)).verticalVersionId).toBe(v1);
4415
+ });
3975
4416
  it('publishes a version as pending, and refuses to bind it until admitted', async () => {
3976
4417
  const versionId = ulid();
3977
4418
  await host.admin.registerVertical(staff, {
@@ -3993,7 +4434,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3993
4434
  // The refusal the registry exists for. Without it "a push lands pending" is
3994
4435
  // a convention, and D-30's lockstep-upgrade argument is that conventions are
3995
4436
  // what we cannot afford here.
3996
- await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/pending, not admitted/);
4437
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
3997
4438
  await host.admin.admitVersion(staff, versionId);
3998
4439
  await host.admin.bindScopeVersion(staff, t1, s1, versionId);
3999
4440
  const scope = await host.admin.getScopeRecord(staff, t1, s1);
@@ -4054,7 +4495,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4054
4495
  await host.admin.bindScopeVersion(staff, t1, preview, pending);
4055
4496
  expect((await host.admin.getScopeRecord(staff, t1, preview))?.verticalVersionId).toBe(pending);
4056
4497
  // …while a normal (serving) scope keeps the refusal the registry exists for.
4057
- await expect(host.admin.bindScopeVersion(staff, t1, s1, pending)).rejects.toThrow(/pending, not admitted/);
4498
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, pending), 'conflict');
4058
4499
  });
4059
4500
  it('routes a PREVIEW to its bound version, not the vertical serving script (#527)', async () => {
4060
4501
  // The bug: a preview forks into — and binds — a specific (usually not-yet-serving)
@@ -4207,19 +4648,20 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4207
4648
  ownerTenant: t2,
4208
4649
  });
4209
4650
  // A slug's owner is fixed at first push: a different owner (or claiming a
4210
- // platform vertical) is refused, naming both owners.
4211
- await expect(host.admin.registerVertical(staff, {
4651
+ // platform vertical) is refused, naming both owners. The message is asserted beside
4652
+ // the code only for WHICH owner it names; the code is the contract.
4653
+ await expectRefusal(host.admin.registerVertical(staff, {
4212
4654
  slug: 'helpdesk',
4213
4655
  name: 'Helpdesk',
4214
4656
  source: 'cli',
4215
4657
  ownerTenant: t1,
4216
- })).rejects.toThrow(/owned by/);
4217
- await expect(host.admin.registerVertical(staff, {
4658
+ }), 'conflict', /owned by/);
4659
+ await expectRefusal(host.admin.registerVertical(staff, {
4218
4660
  slug: 'callout',
4219
4661
  name: 'Callout',
4220
4662
  source: 'builtin',
4221
4663
  ownerTenant: t2,
4222
- })).rejects.toThrow(/owned by the platform/);
4664
+ }), 'conflict', /owned by the platform/);
4223
4665
  });
4224
4666
  it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
4225
4667
  // ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
@@ -4529,7 +4971,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4529
4971
  deploymentRef: null,
4530
4972
  });
4531
4973
  await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
4532
- await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/rejected, not admitted/);
4974
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
4533
4975
  // Terminal: a rejected version is not resurrected, a new one is published.
4534
4976
  await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
4535
4977
  const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
@@ -4625,7 +5067,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4625
5067
  migrationDigest: 'g',
4626
5068
  deploymentRef: null,
4627
5069
  });
4628
- await expect(host.admin.promoteVersion(staff, 'callout', 'prod', pending)).rejects.toThrow(/pending, not admitted/);
5070
+ await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', pending), 'conflict');
4629
5071
  });
4630
5072
  // -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
4631
5073
  // A private vertical's blast radius is its own tenant — dev/staging already
@@ -4767,7 +5209,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4767
5209
  // Listing is the moment OTHER tenants start trusting this code, so the version
4768
5210
  // they would install needs a recorded human decision — the auto-admission note
4769
5211
  // is exactly what marks its absence.
4770
- await expect(host.admin.setVerticalListed(staff, 'tenant-a/crm', true)).rejects.toThrow(/auto-admitted.*staff admit/);
5212
+ await expectRefusal(host.admin.setVerticalListed(staff, 'tenant-a/crm', true), 'conflict');
4771
5213
  // A staff admit of the already-admitted version upgrades it to a manual vouch
4772
5214
  // (clears the note, audited) — then listing passes.
4773
5215
  const prodNow = (await host.admin.listChannels(staff, 'tenant-a/crm')).find((c) => c.channel === 'prod')