@substrat-run/contract-tests 0.133.0 → 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());
@@ -4024,7 +4434,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4024
4434
  // The refusal the registry exists for. Without it "a push lands pending" is
4025
4435
  // a convention, and D-30's lockstep-upgrade argument is that conventions are
4026
4436
  // what we cannot afford here.
4027
- 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');
4028
4438
  await host.admin.admitVersion(staff, versionId);
4029
4439
  await host.admin.bindScopeVersion(staff, t1, s1, versionId);
4030
4440
  const scope = await host.admin.getScopeRecord(staff, t1, s1);
@@ -4085,7 +4495,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4085
4495
  await host.admin.bindScopeVersion(staff, t1, preview, pending);
4086
4496
  expect((await host.admin.getScopeRecord(staff, t1, preview))?.verticalVersionId).toBe(pending);
4087
4497
  // …while a normal (serving) scope keeps the refusal the registry exists for.
4088
- 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');
4089
4499
  });
4090
4500
  it('routes a PREVIEW to its bound version, not the vertical serving script (#527)', async () => {
4091
4501
  // The bug: a preview forks into — and binds — a specific (usually not-yet-serving)
@@ -4238,19 +4648,20 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4238
4648
  ownerTenant: t2,
4239
4649
  });
4240
4650
  // A slug's owner is fixed at first push: a different owner (or claiming a
4241
- // platform vertical) is refused, naming both owners.
4242
- 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, {
4243
4654
  slug: 'helpdesk',
4244
4655
  name: 'Helpdesk',
4245
4656
  source: 'cli',
4246
4657
  ownerTenant: t1,
4247
- })).rejects.toThrow(/owned by/);
4248
- await expect(host.admin.registerVertical(staff, {
4658
+ }), 'conflict', /owned by/);
4659
+ await expectRefusal(host.admin.registerVertical(staff, {
4249
4660
  slug: 'callout',
4250
4661
  name: 'Callout',
4251
4662
  source: 'builtin',
4252
4663
  ownerTenant: t2,
4253
- })).rejects.toThrow(/owned by the platform/);
4664
+ }), 'conflict', /owned by the platform/);
4254
4665
  });
4255
4666
  it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
4256
4667
  // ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
@@ -4560,7 +4971,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4560
4971
  deploymentRef: null,
4561
4972
  });
4562
4973
  await host.admin.rejectVersion(staff, versionId, 'permission diff widened a role');
4563
- 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');
4564
4975
  // Terminal: a rejected version is not resurrected, a new one is published.
4565
4976
  await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
4566
4977
  const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
@@ -4656,7 +5067,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4656
5067
  migrationDigest: 'g',
4657
5068
  deploymentRef: null,
4658
5069
  });
4659
- 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');
4660
5071
  });
4661
5072
  // -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
4662
5073
  // A private vertical's blast radius is its own tenant — dev/staging already
@@ -4798,7 +5209,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4798
5209
  // Listing is the moment OTHER tenants start trusting this code, so the version
4799
5210
  // they would install needs a recorded human decision — the auto-admission note
4800
5211
  // is exactly what marks its absence.
4801
- 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');
4802
5213
  // A staff admit of the already-admitted version upgrades it to a manual vouch
4803
5214
  // (clears the note, audited) — then listing passes.
4804
5215
  const prodNow = (await host.admin.listChannels(staff, 'tenant-a/crm')).find((c) => c.channel === 'prod')