@substrat-run/contract-tests 0.133.0 → 0.135.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 (39) hide show
  1. package/dist/attachment-text-fixtures.d.ts +36 -0
  2. package/dist/attachment-text-fixtures.d.ts.map +1 -0
  3. package/dist/attachment-text-fixtures.js +956 -0
  4. package/dist/attachment-text-fixtures.js.map +1 -0
  5. package/dist/attachment-text-suite.d.ts +3 -0
  6. package/dist/attachment-text-suite.d.ts.map +1 -0
  7. package/dist/attachment-text-suite.js +359 -0
  8. package/dist/attachment-text-suite.js.map +1 -0
  9. package/dist/capability-attachment-suite.d.ts.map +1 -1
  10. package/dist/capability-attachment-suite.js +25 -0
  11. package/dist/capability-attachment-suite.js.map +1 -1
  12. package/dist/capability-suite.d.ts.map +1 -1
  13. package/dist/capability-suite.js +265 -0
  14. package/dist/capability-suite.js.map +1 -1
  15. package/dist/idempotency-suite.d.ts.map +1 -1
  16. package/dist/idempotency-suite.js +43 -2
  17. package/dist/idempotency-suite.js.map +1 -1
  18. package/dist/index.d.ts +5 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/list-suite.d.ts.map +1 -1
  23. package/dist/list-suite.js +18 -2
  24. package/dist/list-suite.js.map +1 -1
  25. package/dist/modules.d.ts +271 -1
  26. package/dist/modules.d.ts.map +1 -1
  27. package/dist/modules.js +95 -4
  28. package/dist/modules.js.map +1 -1
  29. package/dist/schedule-entitlement-suite.d.ts +23 -0
  30. package/dist/schedule-entitlement-suite.d.ts.map +1 -0
  31. package/dist/schedule-entitlement-suite.js +98 -0
  32. package/dist/schedule-entitlement-suite.js.map +1 -0
  33. package/dist/scope-host-suite.d.ts.map +1 -1
  34. package/dist/scope-host-suite.js +494 -12
  35. package/dist/scope-host-suite.js.map +1 -1
  36. package/dist/timeline-suite.d.ts.map +1 -1
  37. package/dist/timeline-suite.js +119 -1
  38. package/dist/timeline-suite.js.map +1 -1
  39. package/package.json +3 -3
@@ -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
@@ -1606,6 +1606,47 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1606
1606
  // `test/move` leads to two states where it is legal, so the attempt is not one of them.
1607
1607
  expect(flow.refused).toEqual([{ from: 'done', attempted: null, operation: 'test/move', count: 2, actors: { principal: 2 } }]);
1608
1608
  });
1609
+ it('reads a refused transition back as a row — record, states, actor, call — like a denial (#1745)', async () => {
1610
+ // A record of its own and a call id of its own, so the case above does not leak in.
1611
+ const stub = await host.getScope(alice, t1, s1);
1612
+ const call = ulid();
1613
+ await expect(stub.invoke('test/refuse', { entityId: 'r2', from: 'done' }, { invocationId: call })).rejects.toThrow(/invalid transition/);
1614
+ const rows = await host.admin.listRefusals(staff, t1, s1, { entityType: 'test-lifecycle', entityId: 'r2' });
1615
+ expect(rows).toHaveLength(1);
1616
+ expect(rows[0]).toMatchObject({
1617
+ kind: 'transition',
1618
+ reason: 'invalid_transition',
1619
+ actor: alice,
1620
+ actorKind: 'principal',
1621
+ tenantId: t1,
1622
+ scopeId: s1,
1623
+ entityType: 'test-lifecycle',
1624
+ entityId: 'r2',
1625
+ fromState: 'done',
1626
+ attemptedState: null,
1627
+ operation: 'test/move',
1628
+ invokedOperation: 'test/refuse',
1629
+ impersonation: null,
1630
+ invocationId: call,
1631
+ drainedAt: null,
1632
+ });
1633
+ expect(rows[0].decodeError).toBeUndefined();
1634
+ // The row survived the operation's rollback — and the operation itself left nothing
1635
+ // behind in the outbox for that call.
1636
+ expect((await host.admin.invocationEvents(staff, t1, s1, { invocationId: call })).events).toEqual([]);
1637
+ // Narrowed like the denial log: by call, by actor, by window; newest first, bounded.
1638
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: call })).toHaveLength(1);
1639
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: ulid() })).toEqual([]);
1640
+ const all = await host.admin.listRefusals(staff, t1, s1, { actor: alice });
1641
+ expect(all.length).toBeGreaterThanOrEqual(3); // r1 twice (the case above) + r2
1642
+ expect(all[0].entityId).toBe('r2');
1643
+ expect(await host.admin.listRefusals(staff, t1, s1, { until: all[all.length - 1].at })).toEqual([]);
1644
+ expect(await host.admin.listRefusals(staff, t1, s1, { limit: 1 })).toHaveLength(1);
1645
+ // K-24: the read is recorded; K-3: a mismatched pair is refused, never another tenant's log.
1646
+ const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listRefusals' });
1647
+ expect(logged.length).toBeGreaterThan(0);
1648
+ await expect(host.admin.listRefusals(staff, t2, s1)).rejects.toThrow();
1649
+ });
1609
1650
  // -- scope data introspection: the §5.4 admin-query RPC --------------------
1610
1651
  //
1611
1652
  // A read-only window into a scope's OWN database (the console/dashboard Data
@@ -1641,6 +1682,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1641
1682
  it('rejects an unknown table', async () => {
1642
1683
  await expect(host.admin.readScopeTable(staff, t1, s1, { table: 'no_such_table', limit: 50, offset: 0 })).rejects.toThrow(/unknown table/);
1643
1684
  });
1685
+ it('refuses a page bound SQLite would misread, and still clamps to the ceiling (#1632)', async () => {
1686
+ // The clamp passed NaN straight through to LIMIT / OFFSET. Refused instead, with the
1687
+ // option's name; a large but valid limit is still clamped rather than refused.
1688
+ const read = (limit, offset) => host.admin.readScopeTable(staff, t1, s1, { table: 'marker', limit, offset });
1689
+ const refused = (pattern) => (err) => errorCodeOf(err) === 'validation_failed' && pattern.test(err.message);
1690
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5]) {
1691
+ await expect(read(bad, 0)).rejects.toSatisfy(refused(/limit must be a positive integer/));
1692
+ }
1693
+ for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
1694
+ await expect(read(1, bad)).rejects.toSatisfy(refused(/offset must be a non-negative integer/));
1695
+ }
1696
+ const clamped = await read(SCOPE_TABLE_PAGE_MAX + 1_000, 0);
1697
+ expect(clamped.limit).toBe(SCOPE_TABLE_PAGE_MAX);
1698
+ expect((await read(1, 0)).offset).toBe(0);
1699
+ });
1644
1700
  // #1524: the size behind the on-demand storage reading. The growth assertion is what
1645
1701
  // separates a real size from a constant. A host that answered a fixed number, or
1646
1702
  // the size of some other database, passes "positive integer" and fails this.
@@ -2204,6 +2260,401 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2204
2260
  const twice = (await journal(kind)).find((r) => r.id === id);
2205
2261
  expect(twice).toEqual(once);
2206
2262
  });
2263
+ // -- the free-text copies (#1632) ---------------------------------------
2264
+ //
2265
+ // The directory keeps failure text about a scope: an ops failure's message, its
2266
+ // issue's newest exemplar, a sweep record's error. The drain's failure row quotes the
2267
+ // intent it gave up on, so a row about a tombstoned intent is a copy of redacted text;
2268
+ // anything else is reached only when it names the subject's id.
2269
+ describe('the directory\'s failure text, and a queued sweep record (#1632)', () => {
2270
+ const op = (name) => `intent.erasure-${name}-${ulid()}`;
2271
+ const fail = (operation, message, tenant = t1, scope = s1) => host.admin.recordOpsFailure({
2272
+ actor: staff,
2273
+ operation,
2274
+ stage: 'terminal',
2275
+ tenantId: tenant,
2276
+ scopeId: scope,
2277
+ message,
2278
+ });
2279
+ const failureOf = async (operation) => (await host.admin.listOpsFailures(staff, { operation }))[0];
2280
+ const issueOf = async (operation) => (await host.admin.listIssues(staff, { operation }))[0];
2281
+ const sweepOf = async (unit) => (await host.admin.listSweepRuns(staff, { unit }))[0];
2282
+ const snapshot = async (ops, units) => {
2283
+ const out = {};
2284
+ for (const [k, o] of Object.entries(ops)) {
2285
+ out[`failure:${k}`] = await failureOf(o);
2286
+ out[`issue:${k}`] = await issueOf(o);
2287
+ }
2288
+ for (const [k, u] of Object.entries(units))
2289
+ out[`sweep:${k}`] = await sweepOf(u);
2290
+ return out;
2291
+ };
2292
+ it('redacts the subject\'s failure text, and leaves every other row as it was', async () => {
2293
+ const kind = `connector:erasure-${ulid()}`;
2294
+ const erased = dataSubjectId.parse(ulid());
2295
+ const spared = dataSubjectId.parse(ulid());
2296
+ const linked = await routeIntent(kind, erased, 'Anna Ek');
2297
+ const unlinked = await routeIntent(kind, spared, 'Bo Lund');
2298
+ const ops = {
2299
+ linked: op('linked'),
2300
+ byId: op('by-id'),
2301
+ platform: op('platform'),
2302
+ spared: op('spared'),
2303
+ otherTenant: op('other-tenant'),
2304
+ unlinked: op('unlinked'),
2305
+ nameOnly: op('name-only'),
2306
+ };
2307
+ await fail(ops.linked, platformIntentFailureMessage(linked, 'failed: HTTP 409: Anna Ek needs a personal number'));
2308
+ await fail(ops.byId, `schedule threw on contact ${erased} (Anna Ek)`);
2309
+ await fail(ops.platform, `digest named contact ${erased}`, null, null);
2310
+ await fail(ops.spared, `schedule threw on contact ${spared} (Bo Lund)`);
2311
+ await fail(ops.otherTenant, `schedule threw on contact ${erased}`, t2, null);
2312
+ await fail(ops.unlinked, platformIntentFailureMessage(unlinked, 'failed: HTTP 409: Bo Lund needs a personal number'));
2313
+ await fail(ops.nameOnly, 'schedule threw: Anna Ek has no postal address');
2314
+ const units = {
2315
+ byId: `${s1}:erasure/by-id-${ulid()}`,
2316
+ spared: `${s1}:erasure/spared-${ulid()}`,
2317
+ otherTenant: `${s1}:erasure/other-tenant-${ulid()}`,
2318
+ };
2319
+ const sweep = (unit, error, tenant = t1) => host.admin.recordSweepRun({
2320
+ kind: 'schedule',
2321
+ unit,
2322
+ outcome: 'failed',
2323
+ tenantId: tenant,
2324
+ scopeId: tenant === t1 ? s1 : null,
2325
+ operation: 'erasure/run',
2326
+ error,
2327
+ });
2328
+ await sweep(units.byId, `contact ${erased} (Anna Ek) bounced`);
2329
+ await sweep(units.spared, `contact ${spared} (Bo Lund) bounced`);
2330
+ await sweep(units.otherTenant, `contact ${erased} bounced`, t2);
2331
+ const before = await snapshot(ops, units);
2332
+ await host.admin.shredSubject(staff, t1, s1, erased);
2333
+ const after = await snapshot(ops, units);
2334
+ // Text about the subject: the drain's row about a tombstoned intent, and every row
2335
+ // in this tenant (or the platform's own) that names the subject's id.
2336
+ for (const k of ['linked', 'byId', 'platform']) {
2337
+ expect(after[`failure:${k}`]).toEqual({ ...before[`failure:${k}`], message: REDACTED_FAILURE_NOTE });
2338
+ // The platform's own exemplar is attributed as the platform's, and goes on a
2339
+ // direct id match exactly as its ops-failure row does.
2340
+ expect(after[`issue:${k}`]).toEqual({ ...before[`issue:${k}`], lastMessage: REDACTED_FAILURE_NOTE });
2341
+ }
2342
+ expect(after['sweep:byId']).toEqual({ ...before['sweep:byId'], error: REDACTED_FAILURE_NOTE });
2343
+ // The twins: another subject's text, another tenant's row, a drain row about an
2344
+ // intent the erasure did not touch — and the documented residual, a sentence that
2345
+ // names the person with neither their id nor an intent link (§13.1).
2346
+ for (const k of ['spared', 'otherTenant', 'unlinked', 'nameOnly']) {
2347
+ expect(after[`failure:${k}`]).toEqual(before[`failure:${k}`]);
2348
+ expect(after[`issue:${k}`]).toEqual(before[`issue:${k}`]);
2349
+ }
2350
+ expect(after['sweep:spared']).toEqual(before['sweep:spared']);
2351
+ expect(after['sweep:otherTenant']).toEqual(before['sweep:otherTenant']);
2352
+ // Idempotent: the note names neither the subject nor an intent.
2353
+ await host.admin.shredSubject(staff, t1, s1, erased);
2354
+ expect(await snapshot(ops, units)).toEqual(after);
2355
+ await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
2356
+ });
2357
+ it('reaches a drain failure recorded after an earlier erasure tombstoned its intent', async () => {
2358
+ // An erasure that crashed between its scope half and its directory half, re-run:
2359
+ // the intent is already a tombstone, so the link has to come from every tombstone
2360
+ // in the journal and not only the ones this pass wrote.
2361
+ const kind = `connector:erasure-${ulid()}`;
2362
+ const erased = dataSubjectId.parse(ulid());
2363
+ const id = await routeIntent(kind, erased, 'Anna Ek');
2364
+ await host.admin.shredSubject(staff, t1, s1, erased);
2365
+ const operation = op('late');
2366
+ await fail(operation, platformIntentFailureMessage(id, 'gave up after 5 drain attempts — last error: Anna Ek'));
2367
+ await host.admin.shredSubject(staff, t1, s1, erased);
2368
+ expect((await failureOf(operation)).message).toBe(REDACTED_FAILURE_NOTE);
2369
+ expect((await issueOf(operation)).lastMessage).toBe(REDACTED_FAILURE_NOTE);
2370
+ });
2371
+ /** Whose an exemplar is, as the directory stores it — not on the issue read shape. */
2372
+ const exemplarOwnerOf = async (operation) => {
2373
+ const dump = await host.admin.exportDirectory(staff);
2374
+ const t = dump.tables.find((x) => x.name === '_substrat_issues');
2375
+ const row = t.rows.find((r) => r[t.columns.indexOf('operation')] === operation);
2376
+ return { kind: row[t.columns.indexOf('last_owner_kind')], tenant: row[t.columns.indexOf('last_tenant_id')] };
2377
+ };
2378
+ it('rewrites an issue exemplar only when its writer attributed it to the erasing tenant', async () => {
2379
+ // An issue has no tenant of its own and outlives its ops-failure rows (180 days vs
2380
+ // 90), so the writer records whose failure each exemplar copied, and erasure reads
2381
+ // that rather than the rows. Expiry is simulated by restoring a directory copy with
2382
+ // the source rows taken out — what the 90-day prune leaves behind.
2383
+ const erased = dataSubjectId.parse(ulid());
2384
+ const ops = {
2385
+ otherExpired: op('other-expired'),
2386
+ ownExpired: op('own-expired'),
2387
+ legacy: op('legacy'),
2388
+ legacyPlatform: op('legacy-platform'),
2389
+ };
2390
+ await fail(ops.otherExpired, `schedule threw on contact ${erased}`, t2, null);
2391
+ await fail(ops.ownExpired, `schedule threw on contact ${erased} (Anna Ek)`);
2392
+ await fail(ops.legacy, `schedule threw on contact ${erased} (Anna Ek)`);
2393
+ await fail(ops.legacyPlatform, `digest named contact ${erased}`, null, null);
2394
+ const copy = await host.admin.exportDirectory(staff);
2395
+ const expired = new Set(Object.values(ops));
2396
+ await host.admin.restoreDirectory(staff, {
2397
+ ...copy,
2398
+ tables: copy.tables.map((t) => {
2399
+ if (t.name === '_substrat_ops_failures') {
2400
+ const col = t.columns.indexOf('operation');
2401
+ return { ...t, rows: t.rows.filter((r) => !expired.has(r[col])) };
2402
+ }
2403
+ if (t.name === '_substrat_issues') {
2404
+ // Issues from before attribution that nothing could backfill: owner unknown.
2405
+ const op = t.columns.indexOf('operation');
2406
+ const owner = [t.columns.indexOf('last_tenant_id'), t.columns.indexOf('last_owner_kind')];
2407
+ const legacy = new Set([ops.legacy, ops.legacyPlatform]);
2408
+ return {
2409
+ ...t,
2410
+ rows: t.rows.map((r) => (legacy.has(r[op]) ? r.map((c, i) => (owner.includes(i) ? null : c)) : r)),
2411
+ };
2412
+ }
2413
+ return t;
2414
+ }),
2415
+ });
2416
+ expect(await host.admin.listOpsFailures(staff, { operation: ops.ownExpired })).toEqual([]);
2417
+ const before = {
2418
+ otherExpired: await issueOf(ops.otherExpired),
2419
+ ownExpired: await issueOf(ops.ownExpired),
2420
+ legacy: await issueOf(ops.legacy),
2421
+ legacyPlatform: await issueOf(ops.legacyPlatform),
2422
+ };
2423
+ await host.admin.shredSubject(staff, t1, s1, erased);
2424
+ // Another tenant's exemplar, its rows expired: byte-equal.
2425
+ expect(await issueOf(ops.otherExpired)).toEqual(before.otherExpired);
2426
+ // This tenant's, its rows expired: the attribution is enough.
2427
+ expect(await issueOf(ops.ownExpired)).toEqual({ ...before.ownExpired, lastMessage: REDACTED_FAILURE_NOTE });
2428
+ // Owner unknown: skipped — the legacy residual (§13.1 limit 9), never a guess, whether
2429
+ // the text was a tenant's or the platform's.
2430
+ expect(await issueOf(ops.legacy)).toEqual(before.legacy);
2431
+ expect(await issueOf(ops.legacyPlatform)).toEqual(before.legacyPlatform);
2432
+ });
2433
+ it('attributes each exemplar to the failure it copied, and a newer one takes it over', async () => {
2434
+ const erased = dataSubjectId.parse(ulid());
2435
+ const shared = op('shared');
2436
+ const text = `schedule threw on contact ${erased}`;
2437
+ await fail(shared, text);
2438
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t1 });
2439
+ // The same group, a newer exemplar from another tenant: the attribution moves with it.
2440
+ await fail(shared, text, t2, null);
2441
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'tenant', tenant: t2 });
2442
+ const exemplar = await issueOf(shared);
2443
+ await host.admin.shredSubject(staff, t1, s1, erased);
2444
+ // This tenant's own row goes; the exemplar is now another tenant's and stays.
2445
+ const rows = await host.admin.listOpsFailures(staff, { operation: shared });
2446
+ expect(rows.find((r) => r.tenantId === t1).message).toBe(REDACTED_FAILURE_NOTE);
2447
+ expect(rows.find((r) => r.tenantId === t2).message).toBe(text);
2448
+ expect(await issueOf(shared)).toEqual(exemplar);
2449
+ // And the platform's own failure leaves the platform's exemplar — never "unknown".
2450
+ await fail(shared, 'the platform failed', null, null);
2451
+ expect(await exemplarOwnerOf(shared)).toEqual({ kind: 'platform', tenant: null });
2452
+ });
2453
+ it('ages telemetry out on the scheduled pass, not only when something new is written', async () => {
2454
+ // Issues, ops failures and sweep runs prune on write, which bounds them only while
2455
+ // something is written. Erasure skips an issue of unknown owner on the promise that
2456
+ // it ages out within its retention, so a quiet directory must shed it on time too.
2457
+ // Aged by restoring a directory copy with the rows' timestamps moved past retention.
2458
+ const expired = { op: op('expired'), unit: `${s1}:erasure/expired-${ulid()}` };
2459
+ const fresh = { op: op('fresh'), unit: `${s1}:erasure/fresh-${ulid()}` };
2460
+ for (const x of [expired, fresh]) {
2461
+ await fail(x.op, 'a failure');
2462
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2463
+ }
2464
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2465
+ const copy = await host.admin.exportDirectory(staff);
2466
+ const age = (t, match, matchCol, cols) => {
2467
+ const m = t.columns.indexOf(matchCol);
2468
+ const at = cols.map((c) => t.columns.indexOf(c));
2469
+ return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2470
+ };
2471
+ await host.admin.restoreDirectory(staff, {
2472
+ ...copy,
2473
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
2474
+ : t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
2475
+ : t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
2476
+ : t),
2477
+ });
2478
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
2479
+ const report = await runPlatformSweep(host, {
2480
+ actor: staff,
2481
+ fetch: connectorTestFetch,
2482
+ sweepers: {},
2483
+ drainRetries: false,
2484
+ });
2485
+ expect(report.errors.filter((e) => e.kind === 'telemetry')).toEqual([]);
2486
+ expect(report.telemetry?.opsFailures).toBeGreaterThanOrEqual(1);
2487
+ expect(report.telemetry?.issues).toBeGreaterThanOrEqual(1);
2488
+ expect(report.telemetry?.sweepRuns).toBeGreaterThanOrEqual(1);
2489
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
2490
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
2491
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
2492
+ // The positive twin: rows inside their retention stay.
2493
+ expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
2494
+ expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
2495
+ expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
2496
+ });
2497
+ it('drains a telemetry backlog larger than the batch over passes, oldest first', async () => {
2498
+ // After a long pause or a restore the backlog can be months of rows, and one
2499
+ // unbounded DELETE over it fails on the Durable Object every tick. Each pass
2500
+ // deletes at most `limit` rows per table, so the backlog converges instead.
2501
+ await host.admin.pruneTelemetry(staff, 100_000); // whatever earlier tests left
2502
+ const backlog = Array.from({ length: 5 }, (_, i) => ({ op: op(`backlog-${i}`), unit: `${s1}:erasure/backlog-${i}-${ulid()}` }));
2503
+ const fresh = { op: op('kept'), unit: `${s1}:erasure/kept-${ulid()}` };
2504
+ for (const x of [...backlog, fresh]) {
2505
+ await fail(x.op, 'a failure');
2506
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: x.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2507
+ }
2508
+ const ops = new Set(backlog.map((b) => b.op));
2509
+ const units = new Set(backlog.map((b) => b.unit));
2510
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2511
+ const copy = await host.admin.exportDirectory(staff);
2512
+ const age = (t, match, matchCol, cols) => {
2513
+ const m = t.columns.indexOf(matchCol);
2514
+ const at = cols.map((c) => t.columns.indexOf(c));
2515
+ return { ...t, rows: t.rows.map((r) => (match.has(r[m]) ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2516
+ };
2517
+ await host.admin.restoreDirectory(staff, {
2518
+ ...copy,
2519
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, ops, 'operation', ['at'])
2520
+ : t.name === '_substrat_issues' ? age(t, ops, 'operation', ['first_seen', 'last_seen'])
2521
+ : t.name === '_substrat_sweep_runs' ? age(t, units, 'unit', ['at'])
2522
+ : t),
2523
+ });
2524
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 3, issues: 3, sweepRuns: 3 });
2525
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 2, issues: 2, sweepRuns: 2 });
2526
+ expect(await host.admin.pruneTelemetry(staff, 3)).toEqual({ opsFailures: 0, issues: 0, sweepRuns: 0 });
2527
+ for (const b of backlog) {
2528
+ expect(await host.admin.listOpsFailures(staff, { operation: b.op })).toEqual([]);
2529
+ expect(await host.admin.listIssues(staff, { operation: b.op })).toEqual([]);
2530
+ expect(await host.admin.listSweepRuns(staff, { unit: b.unit })).toEqual([]);
2531
+ }
2532
+ // Rows inside their retention are never part of a batch.
2533
+ expect(await host.admin.listOpsFailures(staff, { operation: fresh.op })).toHaveLength(1);
2534
+ expect(await host.admin.listIssues(staff, { operation: fresh.op })).toHaveLength(1);
2535
+ expect(await host.admin.listSweepRuns(staff, { unit: fresh.unit })).toHaveLength(1);
2536
+ });
2537
+ it('refuses a row bound that is not a positive integer, wherever one reaches a LIMIT', async () => {
2538
+ // SQLite reads a negative LIMIT as no limit at all, so `-1` turned the bounded
2539
+ // telemetry prune into an unbounded one; a fraction or a non-finite value fails
2540
+ // inside SQLite on every later call. Each is refused with the option's name, and
2541
+ // nothing is deleted on the way.
2542
+ const expired = { op: op('bound'), unit: `${s1}:erasure/bound-${ulid()}` };
2543
+ await fail(expired.op, 'a failure');
2544
+ await host.admin.recordSweepRun({ kind: 'schedule', unit: expired.unit, outcome: 'failed', tenantId: t1, scopeId: s1, operation: 'erasure/run', error: 'x' });
2545
+ const LONG_AGO = '2020-01-01T00:00:00.000Z';
2546
+ const copy = await host.admin.exportDirectory(staff);
2547
+ const age = (t, match, matchCol, cols) => {
2548
+ const m = t.columns.indexOf(matchCol);
2549
+ const at = cols.map((c) => t.columns.indexOf(c));
2550
+ return { ...t, rows: t.rows.map((r) => (r[m] === match ? r.map((c, i) => (at.includes(i) ? LONG_AGO : c)) : r)) };
2551
+ };
2552
+ await host.admin.restoreDirectory(staff, {
2553
+ ...copy,
2554
+ tables: copy.tables.map((t) => t.name === '_substrat_ops_failures' ? age(t, expired.op, 'operation', ['at'])
2555
+ : t.name === '_substrat_issues' ? age(t, expired.op, 'operation', ['first_seen', 'last_seen'])
2556
+ : t.name === '_substrat_sweep_runs' ? age(t, expired.unit, 'unit', ['at'])
2557
+ : t),
2558
+ });
2559
+ const sweep = { actor: staff, fetch: connectorTestFetch, sweepers: {}, drainRetries: false };
2560
+ const refused = (name) => (err) => errorCodeOf(err) === 'validation_failed' && new RegExp(`${name} must be a positive integer`).test(err.message);
2561
+ for (const bad of [-1, 0, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
2562
+ await expect(host.admin.pruneTelemetry(staff, bad)).rejects.toSatisfy(refused('limit'));
2563
+ await expect(host.admin.pruneAccessLog(staff, bad)).rejects.toSatisfy(refused('limit'));
2564
+ await expect(runPlatformSweep(host, { ...sweep, telemetryBatch: bad })).rejects.toSatisfy(refused('telemetryBatch'));
2565
+ await expect(runPlatformSweep(host, { ...sweep, accessLogBatch: bad })).rejects.toSatisfy(refused('accessLogBatch'));
2566
+ expect(() => startPlatformSweeper(host, { ...sweep, telemetryBatch: bad, intervalMs: 60_000 })).toThrow(/telemetryBatch must be a positive integer/);
2567
+ await expect(host.runDueJobs(t1, s1, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
2568
+ await expect(host.admin.readUndrainedEvents(staff, t1, s1, bad)).rejects.toThrow(/limit must be a positive integer/);
2569
+ await expect(host.admin.auditLog(staff, { limit: bad })).rejects.toThrow(/limit must be a positive integer/);
2570
+ }
2571
+ // Nothing went on the way: the expired rows are all still there.
2572
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toHaveLength(1);
2573
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toHaveLength(1);
2574
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toHaveLength(1);
2575
+ // The positive twin: a valid bound drains them.
2576
+ while (Object.values(await host.admin.pruneTelemetry(staff, 1)).some((n) => n > 0))
2577
+ ;
2578
+ expect(await host.admin.listOpsFailures(staff, { operation: expired.op })).toEqual([]);
2579
+ expect(await host.admin.listIssues(staff, { operation: expired.op })).toEqual([]);
2580
+ expect(await host.admin.listSweepRuns(staff, { unit: expired.unit })).toEqual([]);
2581
+ });
2582
+ it('rewrites a queued sweep-runs entry\'s error, and keeps the rest of the intent', async () => {
2583
+ // A CP-less pass queues its schedule outcomes as one `sweep-runs` intent, and the
2584
+ // drain lands each entry's `error` in `_substrat_sweep_runs`. The intent is kept like
2585
+ // every other, so the same text is held twice. `sweep-runs` is platform-authored —
2586
+ // module code cannot enqueue it — so the rows are planted through a restore.
2587
+ const erased = dataSubjectId.parse(ulid());
2588
+ const spared = dataSubjectId.parse(ulid());
2589
+ // Guarantees the journal has a row to copy the envelope columns from.
2590
+ const template = await routeIntent(`connector:erasure-${ulid()}`, spared, 'Bo Lund');
2591
+ const backup = await host.admin.exportScope(staff, t1, s1);
2592
+ const table = backup.tables.find((t) => t.name === '_substrat_platform_requests');
2593
+ const idCol = table.columns.indexOf('id');
2594
+ const base = table.rows.find((r) => r[idCol] === template);
2595
+ const at = '2026-09-20T00:00:00.000Z';
2596
+ const entry = (operation, error) => ({
2597
+ kind: 'schedule',
2598
+ operation,
2599
+ outcome: error === null ? 'ok' : 'failed',
2600
+ at,
2601
+ error,
2602
+ elapsedMs: 12,
2603
+ });
2604
+ const payloads = {
2605
+ mine: {
2606
+ version: null,
2607
+ entries: [
2608
+ entry('crm/sync', `contact ${erased} (Anna Ek) bounced`),
2609
+ entry('crm/tidy', 'nothing to tidy'),
2610
+ entry('crm/ok', null),
2611
+ ],
2612
+ },
2613
+ theirs: { version: 'v-1', entries: [entry('crm/sync', `contact ${spared} (Bo Lund) bounced`)] },
2614
+ };
2615
+ const ids = { mine: ulid(), theirs: ulid() };
2616
+ const plant = (id, payload) => table.columns.map((c, i) => c === 'id' ? id
2617
+ : c === 'kind' ? SWEEP_RUNS_KIND
2618
+ : c === 'payload' ? JSON.stringify(payload)
2619
+ : c === 'status' ? 'pending'
2620
+ : c === 'attempts' ? 0
2621
+ : c === 'settled_at' || c === 'last_error' || c === 'last_failure' || c === 'result' ? null
2622
+ : base[i]);
2623
+ const s = scopeId.parse(ulid());
2624
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
2625
+ await host.admin.activateScope(staff, t1, s);
2626
+ await host.restoreScope(staff, t1, s, {
2627
+ ...backup,
2628
+ scopeId: s,
2629
+ tables: backup.tables.map((t) => t === table ? { ...t, rows: [...t.rows, plant(ids.mine, payloads.mine), plant(ids.theirs, payloads.theirs)] } : t),
2630
+ });
2631
+ await host.settlePlatformRequest(t1, s1, template, { status: 'done' });
2632
+ const queued = async () => {
2633
+ const rows = await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND });
2634
+ return { mine: rows.find((r) => r.id === ids.mine), theirs: rows.find((r) => r.id === ids.theirs) };
2635
+ };
2636
+ const before = await queued();
2637
+ await host.admin.shredSubject(staff, t1, s, erased);
2638
+ const after = await queued();
2639
+ // Only the error that named the subject; the shape, the other entries, the
2640
+ // version and the row's own columns are as they were — still pending, so the
2641
+ // drain lands the note instead of the text.
2642
+ expect(after.mine).toEqual({
2643
+ ...before.mine,
2644
+ payload: {
2645
+ ...payloads.mine,
2646
+ entries: [
2647
+ { ...payloads.mine.entries[0], error: REDACTED_FAILURE_NOTE },
2648
+ payloads.mine.entries[1],
2649
+ payloads.mine.entries[2],
2650
+ ],
2651
+ },
2652
+ });
2653
+ expect(after.theirs).toEqual(before.theirs);
2654
+ await host.admin.shredSubject(staff, t1, s, erased);
2655
+ expect(await queued()).toEqual(after);
2656
+ });
2657
+ });
2207
2658
  });
2208
2659
  it('records the erasure in BOTH logs — mutation and evidence-destruction', async () => {
2209
2660
  const subject = dataSubjectId.parse(ulid());
@@ -4024,7 +4475,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4024
4475
  // The refusal the registry exists for. Without it "a push lands pending" is
4025
4476
  // a convention, and D-30's lockstep-upgrade argument is that conventions are
4026
4477
  // what we cannot afford here.
4027
- await expect(host.admin.bindScopeVersion(staff, t1, s1, versionId)).rejects.toThrow(/pending, not admitted/);
4478
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
4028
4479
  await host.admin.admitVersion(staff, versionId);
4029
4480
  await host.admin.bindScopeVersion(staff, t1, s1, versionId);
4030
4481
  const scope = await host.admin.getScopeRecord(staff, t1, s1);
@@ -4085,7 +4536,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4085
4536
  await host.admin.bindScopeVersion(staff, t1, preview, pending);
4086
4537
  expect((await host.admin.getScopeRecord(staff, t1, preview))?.verticalVersionId).toBe(pending);
4087
4538
  // …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/);
4539
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, pending), 'conflict');
4089
4540
  });
4090
4541
  it('routes a PREVIEW to its bound version, not the vertical serving script (#527)', async () => {
4091
4542
  // The bug: a preview forks into — and binds — a specific (usually not-yet-serving)
@@ -4238,19 +4689,20 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4238
4689
  ownerTenant: t2,
4239
4690
  });
4240
4691
  // 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, {
4692
+ // platform vertical) is refused, naming both owners. The message is asserted beside
4693
+ // the code only for WHICH owner it names; the code is the contract.
4694
+ await expectRefusal(host.admin.registerVertical(staff, {
4243
4695
  slug: 'helpdesk',
4244
4696
  name: 'Helpdesk',
4245
4697
  source: 'cli',
4246
4698
  ownerTenant: t1,
4247
- })).rejects.toThrow(/owned by/);
4248
- await expect(host.admin.registerVertical(staff, {
4699
+ }), 'conflict', /owned by/);
4700
+ await expectRefusal(host.admin.registerVertical(staff, {
4249
4701
  slug: 'callout',
4250
4702
  name: 'Callout',
4251
4703
  source: 'builtin',
4252
4704
  ownerTenant: t2,
4253
- })).rejects.toThrow(/owned by the platform/);
4705
+ }), 'conflict', /owned by the platform/);
4254
4706
  });
4255
4707
  it('carries registry-driven install metadata (marketplace-publish.md §3) and refreshes it', async () => {
4256
4708
  // ownerGrants/entitlements/provides/requires ride the registry (one install_spec JSON
@@ -4560,12 +5012,31 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4560
5012
  deploymentRef: null,
4561
5013
  });
4562
5014
  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/);
5015
+ await expectRefusal(host.admin.bindScopeVersion(staff, t1, s1, versionId), 'conflict');
4564
5016
  // Terminal: a rejected version is not resurrected, a new one is published.
4565
5017
  await expectRefusal(host.admin.admitVersion(staff, versionId), 'conflict');
4566
5018
  const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
4567
5019
  expect(rejected?.admissionNote).toContain('widened a role');
4568
5020
  });
5021
+ it('refuses to reject a version that is already admitted, and leaves it admitted', async () => {
5022
+ // `conflict` (#113 phase 6): the version may already be bound, so admission is one-way
5023
+ // from here. Rejecting an admitted version is the one refusal `rejectVersion` makes.
5024
+ const versionId = ulid();
5025
+ await host.admin.publishVersion(staff, {
5026
+ id: versionId,
5027
+ verticalSlug: 'callout',
5028
+ version: '1.1.0-vouched',
5029
+ manifestDigest: 'm3',
5030
+ permissionDigest: 'p3',
5031
+ migrationDigest: 'g3',
5032
+ deploymentRef: null,
5033
+ });
5034
+ await host.admin.admitVersion(staff, versionId);
5035
+ await expectRefusal(host.admin.rejectVersion(staff, versionId, 'changed my mind'), 'conflict');
5036
+ const after = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
5037
+ expect(after?.admission).toBe('admitted');
5038
+ expect(after?.admissionNote).toBeNull();
5039
+ });
4569
5040
  it('carries the digests promotion compares', async () => {
4570
5041
  // "Has the permission surface changed between what is in prod and what I am
4571
5042
  // promoting?" is a string comparison here. Today it is a person remembering
@@ -4656,7 +5127,18 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4656
5127
  migrationDigest: 'g',
4657
5128
  deploymentRef: null,
4658
5129
  });
4659
- await expect(host.admin.promoteVersion(staff, 'callout', 'prod', pending)).rejects.toThrow(/pending, not admitted/);
5130
+ await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', pending), 'conflict');
5131
+ });
5132
+ it('refuses to promote a version through a vertical that does not own it', async () => {
5133
+ // `conflict` (#113 phase 6). The version is real and admitted — it is the pairing that
5134
+ // is wrong — so this is neither `not_found` (the version exists) nor a state to wait out.
5135
+ const v = await publish('2.9.0', { perm: 'pZ', mig: 'gZ' });
5136
+ await host.admin.registerVertical(staff, { slug: 'elsewhere', name: 'Elsewhere', source: 'builtin' });
5137
+ await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
5138
+ // Positive twin: through its own vertical the same version promotes (the earlier tests
5139
+ // moved prod, so the digest checkpoints are acknowledged — they are not under test here).
5140
+ await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
5141
+ expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
4660
5142
  });
4661
5143
  // -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
4662
5144
  // A private vertical's blast radius is its own tenant — dev/staging already
@@ -4798,7 +5280,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4798
5280
  // Listing is the moment OTHER tenants start trusting this code, so the version
4799
5281
  // they would install needs a recorded human decision — the auto-admission note
4800
5282
  // is exactly what marks its absence.
4801
- await expect(host.admin.setVerticalListed(staff, 'tenant-a/crm', true)).rejects.toThrow(/auto-admitted.*staff admit/);
5283
+ await expectRefusal(host.admin.setVerticalListed(staff, 'tenant-a/crm', true), 'conflict');
4802
5284
  // A staff admit of the already-admitted version upgrades it to a manual vouch
4803
5285
  // (clears the note, audited) — then listing passes.
4804
5286
  const prodNow = (await host.admin.listChannels(staff, 'tenant-a/crm')).find((c) => c.channel === 'prod')