@substrat-run/contract-tests 0.141.0 → 0.143.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 (63) hide show
  1. package/dist/become-mint-suite.d.ts +12 -0
  2. package/dist/become-mint-suite.d.ts.map +1 -0
  3. package/dist/become-mint-suite.js +457 -0
  4. package/dist/become-mint-suite.js.map +1 -0
  5. package/dist/caused-by-module.d.ts.map +1 -1
  6. package/dist/caused-by-module.js +3 -2
  7. package/dist/caused-by-module.js.map +1 -1
  8. package/dist/derived-module.d.ts +350 -0
  9. package/dist/derived-module.d.ts.map +1 -0
  10. package/dist/derived-module.js +153 -0
  11. package/dist/derived-module.js.map +1 -0
  12. package/dist/derived-suite.d.ts +3 -0
  13. package/dist/derived-suite.d.ts.map +1 -0
  14. package/dist/derived-suite.js +225 -0
  15. package/dist/derived-suite.js.map +1 -0
  16. package/dist/directory-restore-suite.js +1 -1
  17. package/dist/directory-restore-suite.js.map +1 -1
  18. package/dist/entity-state-module.d.ts.map +1 -1
  19. package/dist/entity-state-module.js +12 -6
  20. package/dist/entity-state-module.js.map +1 -1
  21. package/dist/entity-trash-module.d.ts.map +1 -1
  22. package/dist/entity-trash-module.js +4 -4
  23. package/dist/entity-trash-module.js.map +1 -1
  24. package/dist/entity-trash-suite.d.ts.map +1 -1
  25. package/dist/entity-trash-suite.js +17 -8
  26. package/dist/entity-trash-suite.js.map +1 -1
  27. package/dist/erasure-module.d.ts.map +1 -1
  28. package/dist/erasure-module.js +3 -2
  29. package/dist/erasure-module.js.map +1 -1
  30. package/dist/index.d.ts +5 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +5 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/input-parse-suite.d.ts.map +1 -1
  35. package/dist/input-parse-suite.js +33 -2
  36. package/dist/input-parse-suite.js.map +1 -1
  37. package/dist/membership-module.d.ts.map +1 -1
  38. package/dist/membership-module.js +3 -2
  39. package/dist/membership-module.js.map +1 -1
  40. package/dist/migration-comments.d.ts.map +1 -1
  41. package/dist/migration-comments.js +3 -2
  42. package/dist/migration-comments.js.map +1 -1
  43. package/dist/modules.d.ts.map +1 -1
  44. package/dist/modules.js +74 -69
  45. package/dist/modules.js.map +1 -1
  46. package/dist/permission-suite.d.ts.map +1 -1
  47. package/dist/permission-suite.js +30 -2
  48. package/dist/permission-suite.js.map +1 -1
  49. package/dist/scope-host-suite.d.ts.map +1 -1
  50. package/dist/scope-host-suite.js +485 -32
  51. package/dist/scope-host-suite.js.map +1 -1
  52. package/dist/shape-reconcile-plan.d.ts +23 -0
  53. package/dist/shape-reconcile-plan.d.ts.map +1 -0
  54. package/dist/shape-reconcile-plan.js +87 -0
  55. package/dist/shape-reconcile-plan.js.map +1 -0
  56. package/dist/test-operations.d.ts +25 -0
  57. package/dist/test-operations.d.ts.map +1 -0
  58. package/dist/test-operations.js +22 -0
  59. package/dist/test-operations.js.map +1 -0
  60. package/dist/vertical-events-suite.d.ts.map +1 -1
  61. package/dist/vertical-events-suite.js +72 -7
  62. package/dist/vertical-events-suite.js.map +1 -1
  63. package/package.json +3 -3
@@ -1,8 +1,21 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
- import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
- import { INERT_SCOPE_REASON, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
3
+ import { connectionId, dataSubjectId, domainEvent, errorCodeOf, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, } from '@substrat-run/contracts';
4
+ import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
6
+ import { testOperations } from './test-operations.js';
7
+ /**
8
+ * A journal read whose every row is whole. A test that planted nothing has no business meeting
9
+ * an `UndecodablePlatformRequest` (#1637), so one fails here, naming why, rather than being
10
+ * narrowed away — the read a vertical makes is held to the same union, and must narrow too.
11
+ */
12
+ function whole(entries) {
13
+ return entries.map((e) => {
14
+ if (isUndecodablePlatformRequest(e))
15
+ throw new Error(`unexpected undecodable intent row: ${e.decodeError}`);
16
+ return e;
17
+ });
18
+ }
6
19
  /**
7
20
  * Assert a refusal by the CODE it declared, not by the sentence it happens to carry
8
21
  * (#113 phase 5).
@@ -258,7 +271,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
258
271
  it('the platform lists pending intents and settles them done (drain surface, Phase B)', async () => {
259
272
  const stub = await host.getScope(alice, t1, s1);
260
273
  const id = await stub.invoke('platform/request', { kind: 'provision-sibling', payload: { slug: 'padel' } });
261
- const pending = await host.listPlatformRequests(t1, s1);
274
+ const pending = whole(await host.listPlatformRequests(t1, s1));
262
275
  const mine = pending.find((r) => r.id === id);
263
276
  expect(mine).toBeDefined();
264
277
  expect(mine.kind).toBe('provision-sibling');
@@ -266,7 +279,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
266
279
  expect(mine.payload).toEqual({ slug: 'padel' }); // JSON parsed back to the contract shape
267
280
  await host.settlePlatformRequest(t1, s1, mine.id, { status: 'done', result: { scopeId: 'NEWSITE' } });
268
281
  // A settled intent drops out of the pending list…
269
- expect((await host.listPlatformRequests(t1, s1)).some((r) => r.id === id)).toBe(false);
282
+ expect((whole(await host.listPlatformRequests(t1, s1))).some((r) => r.id === id)).toBe(false);
270
283
  // …and the row now reads done with its handler result recorded.
271
284
  const rows = await stub.invoke('platform/read-requests');
272
285
  const settled = rows.find((r) => r.id === id);
@@ -297,7 +310,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
297
310
  // another request. The row stays exactly as it was.
298
311
  await expect(host.settlePlatformRequest(t1, s1, platformRequestId.parse(id), { status: 'pending', event: event(id) })).rejects.toThrow();
299
312
  await expect(host.settlePlatformRequest(t1, s1, platformRequestId.parse(id), { status: 'done', event: event(ulid()) })).rejects.toThrow();
300
- expect((await host.listPlatformRequests(t1, s1)).find((r) => r.id === id)?.attempts).toBe(0);
313
+ expect((whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id)?.attempts).toBe(0);
301
314
  expect(await mine()).toEqual([]);
302
315
  await host.settlePlatformRequest(t1, s1, platformRequestId.parse(id), { status: 'done', event: event(id) });
303
316
  const written = await mine();
@@ -315,10 +328,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
315
328
  it('a transient failure keeps the intent pending and preserves a two-phase result (retry)', async () => {
316
329
  const stub = await host.getScope(alice, t1, s1);
317
330
  const id = await stub.invoke('platform/request', { kind: 'provision-sibling', payload: {} });
318
- const mine = (await host.listPlatformRequests(t1, s1)).find((r) => r.id === id);
331
+ const mine = (whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id);
319
332
  // Two-phase: record a minted id but keep the intent pending (crash-safe pre-write).
320
333
  await host.settlePlatformRequest(t1, s1, mine.id, { status: 'pending', result: { scopeId: 'MINTED' } });
321
- const still = (await host.listPlatformRequests(t1, s1)).find((r) => r.id === id);
334
+ const still = (whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id);
322
335
  expect(still.status).toBe('pending'); // still drainable
323
336
  expect(still.result).toEqual({ scopeId: 'MINTED' });
324
337
  expect(still.attempts).toBe(1);
@@ -332,15 +345,15 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
332
345
  it('reads back settled intents — the journal both the platform and the vertical see (#618)', async () => {
333
346
  const stub = await host.getScope(alice, t1, s1);
334
347
  const id = await stub.invoke('platform/request', { kind: 'connector:test', payload: { doc: 1 } });
335
- const row = (await host.listPlatformRequests(t1, s1)).find((r) => r.id === id);
348
+ const row = (whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id);
336
349
  // The failure this issue is about: a provider's own sentence, journaled and then
337
350
  // unreachable from anywhere a builder would look.
338
351
  const refusal = 'HTTP 409 requires valid personal number field';
339
352
  await host.settlePlatformRequest(t1, s1, row.id, { status: 'failed', lastError: refusal });
340
353
  // Settled, so it is gone from the DRAIN's read…
341
- expect((await host.listPlatformRequests(t1, s1)).some((r) => r.id === id)).toBe(false);
354
+ expect((whole(await host.listPlatformRequests(t1, s1))).some((r) => r.id === id)).toBe(false);
342
355
  // …and present, with the whole error, in the JOURNAL's.
343
- const history = await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' });
356
+ const history = whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' }));
344
357
  const settled = history.find((r) => r.id === id);
345
358
  expect(settled.status).toBe('failed');
346
359
  expect(settled.lastError).toBe(refusal); // verbatim, never truncated
@@ -348,10 +361,10 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
348
361
  expect(settled.settledAt).not.toBeNull();
349
362
  // `kind` is an exact match, so one provider's traffic reads alone.
350
363
  await stub.invoke('platform/request', { kind: 'provision-sibling', payload: {} });
351
- expect((await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' })).every((r) => r.kind === 'connector:test')).toBe(true);
364
+ expect((whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' }))).every((r) => r.kind === 'connector:test')).toBe(true);
352
365
  // …as does `status`, and `limit` bounds the window.
353
- expect((await host.listPlatformRequestHistory(t1, s1, { status: 'failed' })).every((r) => r.status === 'failed')).toBe(true);
354
- expect((await host.listPlatformRequestHistory(t1, s1, { limit: 1 })).length).toBe(1);
366
+ expect((whole(await host.listPlatformRequestHistory(t1, s1, { status: 'failed' }))).every((r) => r.status === 'failed')).toBe(true);
367
+ expect((whole(await host.listPlatformRequestHistory(t1, s1, { limit: 1 }))).length).toBe(1);
355
368
  });
356
369
  /**
357
370
  * #841. `lastError` says WHAT happened; this says WHO refused — and until it existed the
@@ -363,13 +376,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
363
376
  it('journals WHO refused beside what was refused, and survives the round trip (#841)', async () => {
364
377
  const stub = await host.getScope(alice, t1, s1);
365
378
  const id = await stub.invoke('platform/request', { kind: 'connector:test', payload: { doc: 9 } });
366
- const row = (await host.listPlatformRequests(t1, s1)).find((r) => r.id === id);
379
+ const row = (whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id);
367
380
  await host.settlePlatformRequest(t1, s1, row.id, {
368
381
  status: 'failed',
369
382
  lastError: 'permission denied: protocol:read',
370
383
  failure: { origin: 'platform', code: 'permission_denied', permission: 'protocol:read' },
371
384
  });
372
- const settled = (await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' })).find((r) => r.id === id);
385
+ const settled = (whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' }))).find((r) => r.id === id);
373
386
  expect(settled.failure).toEqual({
374
387
  origin: 'platform',
375
388
  code: 'permission_denied',
@@ -385,9 +398,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
385
398
  it('leaves attribution null when the settling caller did not attribute (#841)', async () => {
386
399
  const stub = await host.getScope(alice, t1, s1);
387
400
  const id = await stub.invoke('platform/request', { kind: 'connector:test', payload: { doc: 10 } });
388
- const row = (await host.listPlatformRequests(t1, s1)).find((r) => r.id === id);
401
+ const row = (whole(await host.listPlatformRequests(t1, s1))).find((r) => r.id === id);
389
402
  await host.settlePlatformRequest(t1, s1, row.id, { status: 'failed', lastError: 'something broke' });
390
- const settled = (await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' })).find((r) => r.id === id);
403
+ const settled = (whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'connector:test' }))).find((r) => r.id === id);
391
404
  expect(settled.lastError).toBe('something broke');
392
405
  expect(settled.failure).toBeNull();
393
406
  });
@@ -395,9 +408,9 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
395
408
  const stub = await host.getScope(alice, t1, s1);
396
409
  const first = await stub.invoke('platform/request', { kind: 'order-check', payload: { n: 1 } });
397
410
  const second = await stub.invoke('platform/request', { kind: 'order-check', payload: { n: 2 } });
398
- const rows = await host.listPlatformRequestHistory(t1, s1, { kind: 'order-check' });
411
+ const rows = whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'order-check' }));
399
412
  expect(rows.map((r) => r.id)).toEqual([second, first]);
400
- expect((await host.listPlatformRequestHistory(t1, s1, { kind: 'order-check', limit: 1 }))[0].id).toBe(second);
413
+ expect((whole(await host.listPlatformRequestHistory(t1, s1, { kind: 'order-check', limit: 1 })))[0].id).toBe(second);
401
414
  });
402
415
  it('a vertical reads the outcome of its OWN intents in scope — ctx.platformRequests (#618)', async () => {
403
416
  const stub = await host.getScope(alice, t1, s1);
@@ -425,8 +438,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
425
438
  describe("a copy never runs the source's pending intents (#1686)", () => {
426
439
  let source;
427
440
  let id;
428
- const pendingAt = async (s) => (await host.listPlatformRequests(t1, s)).map((r) => r.id);
429
- const historyAt = async (s) => (await host.listPlatformRequestHistory(t1, s)).find((r) => r.id === id);
441
+ const pendingAt = async (s) => (whole(await host.listPlatformRequests(t1, s))).map((r) => r.id);
442
+ const historyAt = async (s) => (whole(await host.listPlatformRequestHistory(t1, s))).find((r) => r.id === id);
430
443
  /** The copy: the intent is not drainable there, and its journal says why. */
431
444
  const notCarriedAt = async (copy) => {
432
445
  expect(await pendingAt(copy)).not.toContain(id);
@@ -466,7 +479,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
466
479
  });
467
480
  it('a restore into the scope the backup came from keeps it pending, payload and all', async () => {
468
481
  await host.restoreScope(staff, t1, source, await host.admin.exportScope(staff, t1, source));
469
- const back = (await host.listPlatformRequests(t1, source)).find((r) => r.id === id);
482
+ const back = (whole(await host.listPlatformRequests(t1, source))).find((r) => r.id === id);
470
483
  expect(back).toMatchObject({ status: 'pending', payload: { slug: 'copied' }, lastError: null, settledAt: null });
471
484
  });
472
485
  });
@@ -551,7 +564,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
551
564
  expect(count('copy-unattempted')).toBe(1);
552
565
  // The consumer never ran on the copied event.
553
566
  expect(await logOf(fork)).toEqual([]);
554
- expect(await host.listPlatformRequests(t1, fork)).toEqual([]);
567
+ expect(whole(await host.listPlatformRequests(t1, fork))).toEqual([]);
555
568
  // The retry reads settled, saying why, rather than vanishing.
556
569
  const settled = (await host.executorDeadLetters(t1, fork)).find((d) => d.executorId === 'flaky-effector');
557
570
  expect(settled?.error).toMatch(new RegExp(`^not carried: copied from scope ${source}`));
@@ -735,8 +748,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
735
748
  };
736
749
  /** All three reads, so each of the three decode sites is on the hook. */
737
750
  const readAll = async (s) => ({
738
- pending: await host.listPlatformRequests(t1, s),
739
- history: await host.listPlatformRequestHistory(t1, s),
751
+ pending: whole(await host.listPlatformRequests(t1, s)),
752
+ history: whole(await host.listPlatformRequestHistory(t1, s)),
740
753
  inScope: await (await host.getScope(alice, t1, s)).invoke('platform/intents'),
741
754
  });
742
755
  const byId = (rows, id) => rows.find((r) => r.id === id);
@@ -749,6 +762,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
749
762
  for (const row of [...pending, ...history, ...inScope]) {
750
763
  // ABSENT rather than null, so a healthy list is the shape it always was.
751
764
  expect(row).not.toHaveProperty('decodeError');
765
+ expect(row).not.toHaveProperty('undecodable');
752
766
  }
753
767
  expect(byId(history, healthySettled.id)).toMatchObject({
754
768
  payload: { doc: 2 },
@@ -802,14 +816,120 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
802
816
  lastError: refusal,
803
817
  failure: { origin: 'platform', code: 'validation_failed', permission: null },
804
818
  });
805
- expect((await host.listPlatformRequests(t1, s)).map((r) => r.id)).toEqual([healthyPending.id]);
806
- const after = byId(await host.listPlatformRequestHistory(t1, s), brokenPendingId);
819
+ expect((whole(await host.listPlatformRequests(t1, s))).map((r) => r.id)).toEqual([healthyPending.id]);
820
+ const after = byId(whole(await host.listPlatformRequestHistory(t1, s)), brokenPendingId);
807
821
  expect(after.status).toBe('failed');
808
822
  expect(after.lastError).toBe(refusal);
809
823
  expect(after.failure).toEqual({ origin: 'platform', code: 'validation_failed', permission: null });
810
824
  // The stored payload is untouched by the settle, so the row still says it is not whole.
811
825
  expect(after.decodeError).toMatch(/^payload: /);
812
826
  });
827
+ /**
828
+ * #1637: the columns #1588 could not tolerate — id, kind, status, attempts, requested_at —
829
+ * have no value the contract accepts that would not be fabricated, so such a row still
830
+ * threw, and took every read of the journal with it. It now reads as the OTHER half of
831
+ * the entry: those five columns as stored, as text, and why. One column at a time, each
832
+ * beside a healthy twin, because a restored dump can only show a column at a time.
833
+ */
834
+ describe('a row whose identity does not decode (#1637)', () => {
835
+ /** The healthy pending row's identity, as the variant names it: stored, as text. */
836
+ const asStored = {
837
+ undecodable: true,
838
+ id: brokenPendingId,
839
+ kind: 'connector:test',
840
+ status: 'pending',
841
+ attempts: '0',
842
+ requestedAt: at,
843
+ };
844
+ /** One broken column each: what is planted, and the field it reads back as — written out. */
845
+ const broken = [
846
+ { column: 'id', over: { id: 'not-a-ulid' }, reads: { id: 'not-a-ulid' } },
847
+ { column: 'id', over: { id: null }, reads: { id: null } },
848
+ // Oversized: read back capped, the way the decoder caps any value it quotes, so it cannot
849
+ // ride whole into the drain's report, the sweep log on every tick, or the dashboard card.
850
+ { column: 'id', over: { id: 'x'.repeat(5000) }, reads: { id: `${'x'.repeat(200)}…` } },
851
+ { column: 'kind', over: { kind: '' }, reads: { kind: '' } },
852
+ { column: 'status', over: { status: 'queued' }, reads: { status: 'queued' } },
853
+ { column: 'attempts', over: { attempts: -1 }, reads: { attempts: '-1' } },
854
+ { column: 'attempts', over: { attempts: 'abc' }, reads: { attempts: 'abc' } },
855
+ // An epoch, the shape a dump from another world would carry — as text, since a restore
856
+ // binds a JSON number as REAL and TEXT affinity would store it as `1756684800.0`.
857
+ { column: 'requested_at', over: { requested_at: '1756684800' }, reads: { requestedAt: '1756684800' } },
858
+ ];
859
+ /** The raw reads — no `whole`, since meeting the variant is the point. */
860
+ const readEntries = async (s) => ({
861
+ pending: await host.listPlatformRequests(t1, s),
862
+ history: await host.listPlatformRequestHistory(t1, s),
863
+ inScope: await (await host.getScope(alice, t1, s)).invoke('platform/intents'),
864
+ });
865
+ for (const { column, over, reads } of broken) {
866
+ it(`${column} = ${JSON.stringify(over[column])}: named as stored, beside the row that is whole`, async () => {
867
+ const clean = await readAll(await restoredWith([healthyPending]));
868
+ const row = { ...healthyPending, id: brokenPendingId, ...over };
869
+ const { pending, history, inScope } = await readEntries(await restoredWith([row, healthyPending]));
870
+ const expected = {
871
+ ...asStored,
872
+ ...reads,
873
+ decodeError: expect.stringMatching(new RegExp(`^${column}: `)),
874
+ };
875
+ // A stored `queued` is not pending, so the drain's queue never meets that one.
876
+ const queued = column === 'status' ? [] : [expected];
877
+ for (const [read, entries, twin, also] of [
878
+ ['pending', pending, clean.pending, queued],
879
+ ['history', history, clean.history, [expected]],
880
+ ['in-scope', inScope, clean.inScope, [expected]],
881
+ ]) {
882
+ expect(entries, read).toHaveLength(1 + also.length);
883
+ // The healthy row reads EXACTLY as it does with no bad neighbour.
884
+ const healthy = entries.filter((e) => !isUndecodablePlatformRequest(e));
885
+ expect(healthy, read).toEqual(twin);
886
+ expect(entries.filter(isUndecodablePlatformRequest), read).toEqual(also);
887
+ // Every entry satisfies the published union — the variant claims no branded type.
888
+ for (const e of entries)
889
+ expect(() => platformRequestEntry.parse(e), read).not.toThrow();
890
+ }
891
+ });
892
+ }
893
+ it('several broken columns at once are all named, and the JSON beside them too', async () => {
894
+ const row = { ...healthyPending, id: 'not-a-ulid', status: 'queued', payload: 'nope' };
895
+ const { history } = await readEntries(await restoredWith([row, healthyPending]));
896
+ const variant = history.find(isUndecodablePlatformRequest);
897
+ expect(variant.decodeError.split('; ').map((part) => part.split(':')[0])).toEqual(['id', 'status', 'payload']);
898
+ // Named, never quoted: the payload's stored text does not travel in the reason.
899
+ expect(variant.decodeError).not.toContain('nope');
900
+ });
901
+ it('a clean journal carries no variant marker at all (the positive twin)', async () => {
902
+ const { pending, history, inScope } = await readEntries(await restoredWith([healthyPending, healthySettled]));
903
+ for (const e of [...pending, ...history, ...inScope]) {
904
+ expect(e).not.toHaveProperty('undecodable');
905
+ expect(isUndecodablePlatformRequest(e)).toBe(false);
906
+ }
907
+ });
908
+ it('a variant whose stored id is still an id can be refused: settled, out of the queue, still named', async () => {
909
+ // The drain's case (a) — tested there against a fake; what BOTH adapters owe it is that
910
+ // the settle lands on a row the read could not decode.
911
+ const s = await restoredWith([{ ...healthyPending, id: brokenPendingId, kind: '' }, healthyPending]);
912
+ const refusal = 'not executed: the intent row could not be decoded (kind: …)';
913
+ await host.settlePlatformRequest(t1, s, brokenPendingId, {
914
+ status: 'failed',
915
+ lastError: refusal,
916
+ failure: { origin: 'platform', code: 'validation_failed', permission: null },
917
+ });
918
+ const { pending, history } = await readEntries(s);
919
+ expect(pending.map((e) => e.id)).toEqual([healthyPending.id]);
920
+ // Settled — and the settle's `attempts + 1` left a number where text was — but the kind
921
+ // is still not a kind, so the row is still the variant, and says so.
922
+ expect(history.find(isUndecodablePlatformRequest)).toEqual({
923
+ undecodable: true,
924
+ id: brokenPendingId,
925
+ kind: '',
926
+ status: 'failed',
927
+ attempts: '1',
928
+ requestedAt: healthyPending.requested_at,
929
+ decodeError: expect.stringMatching(/^kind: /),
930
+ });
931
+ });
932
+ });
813
933
  });
814
934
  /**
815
935
  * #1636 — #1588's shape everywhere else it lived. One outbox or denial row whose JSON
@@ -2467,7 +2587,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2467
2587
  });
2468
2588
  return id;
2469
2589
  };
2470
- const journal = async (kind) => host.listPlatformRequestHistory(t1, s1, { kind });
2590
+ const journal = async (kind) => whole(await host.listPlatformRequestHistory(t1, s1, { kind }));
2471
2591
  it('redacts the routed copy of the event, and leaves the neighbour intact', async () => {
2472
2592
  // A distinct kind per test: this scope's journal is shared with every other
2473
2593
  // suite that enqueues, and `connector:<provider>` IS a family of kinds.
@@ -2522,7 +2642,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2522
2642
  const kind = `connector:erasure-${ulid()}`;
2523
2643
  const erased = dataSubjectId.parse(ulid());
2524
2644
  const id = await routeIntent(kind, erased, 'Anna Ek');
2525
- expect((await host.listPlatformRequests(t1, s1)).some((r) => r.id === id)).toBe(true);
2645
+ expect((whole(await host.listPlatformRequests(t1, s1))).some((r) => r.id === id)).toBe(true);
2526
2646
  await host.admin.shredSubject(staff, t1, s1, erased);
2527
2647
  // Redacting and leaving it pending would hand the drain a tombstone to execute;
2528
2648
  // leaving it pending and intact would keep the name. `failed` is the truthful
@@ -2531,7 +2651,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2531
2651
  expect(settled.status).toBe('failed');
2532
2652
  expect(settled.settledAt).not.toBeNull();
2533
2653
  expect(settled.lastError).toContain('subject erasure');
2534
- const stillPending = await host.listPlatformRequests(t1, s1);
2654
+ const stillPending = whole(await host.listPlatformRequests(t1, s1));
2535
2655
  expect(stillPending.some((r) => r.id === id)).toBe(false);
2536
2656
  });
2537
2657
  it('redacts what the provider said back, not only what we sent', async () => {
@@ -3041,7 +3161,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3041
3161
  });
3042
3162
  await host.settlePlatformRequest(t1, s1, template, { status: 'done' });
3043
3163
  const queued = async () => {
3044
- const rows = await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND });
3164
+ const rows = whole(await host.listPlatformRequestHistory(t1, s, { kind: SWEEP_RUNS_KIND }));
3045
3165
  return { mine: rows.find((r) => r.id === ids.mine), theirs: rows.find((r) => r.id === ids.theirs) };
3046
3166
  };
3047
3167
  const before = await queued();
@@ -4930,6 +5050,48 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4930
5050
  }
4931
5051
  expect(beforeClaim.map((copy) => copy.moveId).sort()).toEqual([first, next].sort());
4932
5052
  });
5053
+ it('a backfilled copy is retained, once per script, never over a ledgered one, and never under a reap claim (#1722)', async () => {
5054
+ const s = scopeId.parse(ulid());
5055
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s });
5056
+ const [historic, tracked] = [`version-${ulid().toLowerCase()}`, `version-${ulid().toLowerCase()}`];
5057
+ const from = ulid();
5058
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, historic, { fromLogId: from })).toBe('recorded');
5059
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, historic)).toBe('ledgered'); // a re-run writes nothing
5060
+ // Audited once, naming the script and the log row it was read from.
5061
+ expect((await host.admin.auditLog(staff, { scopeId: s, action: 'backfillScopeCopy' })).map((r) => r.after))
5062
+ .toEqual([{ scriptRef: historic, fromLogId: from }]);
5063
+ const [entry] = await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s });
5064
+ expect(entry).toMatchObject({ scriptRef: historic, moveId: BACKFILL_MOVE_ID, state: 'retained', role: null, leaseUntil: null });
5065
+ // Never pending, so no lease sweep ever claims it, and a reap is not held off by it.
5066
+ expect((await host.admin.claimExpiredScopeScriptCopies(staff, {
5067
+ now: new Date(Date.now() + 365 * 86_400_000).toISOString(), leaseUntil: new Date().toISOString(), owner: 'sweep:test', limit: 1000,
5068
+ })).filter((c) => c.scopeId === s)).toEqual([]);
5069
+ // A script the ledger already names for this scope, in ANY state, is not backfilled again.
5070
+ const move = ulid();
5071
+ await host.admin.recordScopeScriptCopy(staff, t1, s, tracked, move, { role: 'source' });
5072
+ await host.admin.settleScopeScriptCopy(staff, t1, s, tracked, move, 'done');
5073
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, tracked)).toBe('ledgered');
5074
+ expect((await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s })).map((c) => c.moveId).sort())
5075
+ .toEqual([BACKFILL_MOVE_ID, move].sort());
5076
+ // A move that later carries the store out of the historic script takes it over: while its
5077
+ // own entry is live the backfilled one stays, and once that entry is done (wiped, or the
5078
+ // route) the backfilled one settles with it, so no reap or erasure is sent there again.
5079
+ const carry = ulid();
5080
+ await host.admin.recordScopeScriptCopy(staff, t1, s, historic, carry, { role: 'source' });
5081
+ await host.admin.settleScopeScriptCopy(staff, t1, s, historic, carry, 'eligible', { loadStamp: 'st', revision: '1' });
5082
+ expect((await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s, state: 'retained' })).map((c) => c.scriptRef))
5083
+ .toEqual([historic]);
5084
+ await host.admin.settleScopeScriptCopy(staff, t1, s, historic, carry, 'done');
5085
+ expect(await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s, state: 'retained' })).toEqual([]);
5086
+ // Scoped by tenant: the same scope id under another tenant is unknown.
5087
+ expect(await host.admin.backfillScopeScriptCopy(staff, t2, s, `version-${ulid().toLowerCase()}`)).toBe('missing');
5088
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, scopeId.parse(ulid()), historic)).toBe('missing');
5089
+ // Under a reap claim nothing new enters the ledger, so the reap's own listing stays whole.
5090
+ await host.admin.beginScopeScriptReap(staff, t1, s);
5091
+ const late = `version-${ulid().toLowerCase()}`;
5092
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, late)).toBe('reaping');
5093
+ expect((await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s })).some((c) => c.scriptRef === late)).toBe(false);
5094
+ });
4933
5095
  it('a pending copy entry is claimed only once its lease ran out, and only once (#1722)', async () => {
4934
5096
  const s = scopeId.parse(ulid());
4935
5097
  await host.provisionScope(staff, { tenantId: t1, scopeId: s });
@@ -4992,6 +5154,38 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4992
5154
  // And it confirms once: the same confirmation again refuses.
4993
5155
  await expectRefusal(host.admin.setScopeServingRef(staff, t1, s, from, { expectedErasureEpoch: 0, confirmMove: { moveId: move, source: 'eligible' } }), 'precondition_failed');
4994
5156
  });
5157
+ it('a backfill and every erasure record the erasure epoch they ran under (#1722)', async () => {
5158
+ const s = scopeId.parse(ulid());
5159
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s });
5160
+ const epochs = async (action) => (await host.admin.auditLog(staff, { scopeId: s, action })).map((r) => r.before);
5161
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, `version-${ulid().toLowerCase()}`)).toBe('recorded');
5162
+ const redacted = [{ events: 0, intents: 0, jobRuns: 0, idempotencyResults: 0, intentIds: [],
5163
+ vertical: { verticalRows: [], hookRows: [], unreachedEntities: [] } }];
5164
+ await host.admin.finalizeSubjectShred(staff, t1, s, ulid(), redacted, { versionId: null, servingRef: null, epoch: 0, copyCount: 1 });
5165
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, `version-${ulid().toLowerCase()}`)).toBe('recorded');
5166
+ expect(await epochs('backfillScopeCopy')).toEqual([{ erasureEpoch: 0 }, { erasureEpoch: 1 }]);
5167
+ expect(await epochs('shredSubject')).toEqual([{ erasureEpoch: 0, path: 'orchestrated' }]);
5168
+ // A direct shred claims nothing, and says so, with the epoch it ran under.
5169
+ await host.admin.shredSubject(staff, t1, s, ulid());
5170
+ expect(await epochs('shredSubject')).toEqual([{ erasureEpoch: 0, path: 'orchestrated' }, { erasureEpoch: 1, path: 'direct' }]);
5171
+ });
5172
+ it('a move that routes onto a backfilled script settles that entry in the confirming write (#1722)', async () => {
5173
+ const s = scopeId.parse(ulid());
5174
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s });
5175
+ const [from, to, other] = [`version-${ulid().toLowerCase()}`, `version-${ulid().toLowerCase()}`, `version-${ulid().toLowerCase()}`];
5176
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, to)).toBe('recorded');
5177
+ expect(await host.admin.backfillScopeScriptCopy(staff, t1, s, other)).toBe('recorded');
5178
+ const move = ulid();
5179
+ await host.admin.recordScopeScriptCopy(staff, t1, s, from, move, { role: 'source' });
5180
+ await host.admin.recordScopeScriptCopy(staff, t1, s, to, move, { role: 'destination', loadStamp: 'restore-stamp' });
5181
+ // Until it confirms, the move's own entry is pending: the backfilled one still stands.
5182
+ expect((await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s, state: 'retained' })).map((c) => c.scriptRef).sort())
5183
+ .toEqual([other, to].sort());
5184
+ await host.admin.setScopeServingRef(staff, t1, s, to, { expectedErasureEpoch: 0, confirmMove: { moveId: move, source: 'retained' } });
5185
+ const byRef = Object.fromEntries((await host.admin.listScopeScriptCopies(staff, { tenantId: t1, scopeId: s }))
5186
+ .filter((c) => c.moveId === BACKFILL_MOVE_ID).map((c) => [c.scriptRef, c.state]));
5187
+ expect(byRef).toEqual({ [to]: 'done', [other]: 'retained' });
5188
+ });
4995
5189
  it('a reap claim refuses a conditional bind until the scope row is deleted (#1722)', async () => {
4996
5190
  const s = scopeId.parse(ulid());
4997
5191
  await host.provisionScope(staff, { tenantId: t1, scopeId: s });
@@ -6346,7 +6540,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
6346
6540
  permissions: [{ key: 'selfw:use', description: 'self' }],
6347
6541
  withdraws: ['selfw/op'],
6348
6542
  }),
6349
- operations: { 'selfw/op': (() => 'x') },
6543
+ ...testOperations({ 'selfw/op': (() => 'x') }),
6350
6544
  })).toThrow(/withdraws its own operation/);
6351
6545
  });
6352
6546
  it('links declared entity relations, idempotently (K-16)', async () => {
@@ -7664,6 +7858,265 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
7664
7858
  await host.admin.revokeEntitlement(staff, mt, 'billed');
7665
7859
  await host.admin.revokeEntitlement(staff, mt, 'aux-sku');
7666
7860
  });
7861
+ // -- the stored storage gauge (#1524) --------------------------------------
7862
+ describe('the stored storage gauge (#1524)', () => {
7863
+ const day = (offsetDays, time) => `${new Date(Date.now() + offsetDays * 86_400_000).toISOString().slice(0, 10)}T${time}.000Z`;
7864
+ const newScope = async (tenant) => {
7865
+ const s = scopeId.parse(ulid());
7866
+ await host.provisionScope(staff, { tenantId: tenant, scopeId: s });
7867
+ await host.admin.activateScope(staff, tenant, s);
7868
+ return s;
7869
+ };
7870
+ const record = (tenant, scope, bytes, readAt) => host.admin.recordScopeStorage(staff, [{ tenantId: tenant, scopeId: scope, bytes, readAt }]);
7871
+ it('keeps one row per scope per UTC day: a same-day reading replaces an older one, the next day adds a row', async () => {
7872
+ const tg = tenantId.parse(ulid());
7873
+ await host.admin.createTenant(staff, { id: tg, slug: 'gauge-days', name: 'Gauge Days' });
7874
+ const s = await newScope(tg);
7875
+ expect(await record(tg, s, 100, day(-1, '10:00:00'))).toEqual({ recorded: 1 });
7876
+ expect(await record(tg, s, 200, day(-1, '11:00:00'))).toEqual({ recorded: 1 });
7877
+ // An OLDER reading arriving late never overwrites a newer one of the same day.
7878
+ expect(await record(tg, s, 50, day(-1, '09:00:00'))).toEqual({ recorded: 0 });
7879
+ expect(await record(tg, s, 300, day(0, '00:00:00'))).toEqual({ recorded: 1 });
7880
+ const history = await host.admin.listScopeStorage(staff, { scopeId: s });
7881
+ expect(history.map((r) => [r.day, r.bytes])).toEqual([
7882
+ [day(-1, '00:00:00').slice(0, 10), 200],
7883
+ [day(0, '00:00:00').slice(0, 10), 300],
7884
+ ]);
7885
+ expect(await host.admin.listScopeStorage(staff, { scopeId: s, latest: true })).toEqual([
7886
+ { tenantId: tg, scopeId: s, day: day(0, '00:00:00').slice(0, 10), bytes: 300, readAt: day(0, '00:00:00') },
7887
+ ]);
7888
+ // History is bounded and windowed by day.
7889
+ expect(await host.admin.listScopeStorage(staff, { scopeId: s, limit: 1 })).toHaveLength(1);
7890
+ expect((await host.admin.listScopeStorage(staff, { scopeId: s, since: day(0, '00:00:00').slice(0, 10) })).map((r) => r.bytes)).toEqual([300]);
7891
+ // K-24: the read is attributable.
7892
+ const reads = await host.admin.accessLog(staff, { method: 'listScopeStorage' });
7893
+ expect(reads.some((r) => r.scopeId === s)).toBe(true);
7894
+ });
7895
+ it('records every attempt; a failed one keeps the last sample, and the attempt is what the pass reads next', async () => {
7896
+ const tf = tenantId.parse(ulid());
7897
+ await host.admin.createTenant(staff, { id: tf, slug: 'gauge-tries', name: 'Gauge Tries' });
7898
+ const [s, declined] = [await newScope(tf), await newScope(tf)];
7899
+ await record(tf, s, 700, day(-1, '05:00:00'));
7900
+ expect(await host.admin.recordScopeStorage(staff, [
7901
+ { tenantId: tf, scopeId: s, bytes: null, error: 'vertical answered 501', readAt: day(0, '05:00:00') },
7902
+ { tenantId: tf, scopeId: declined, bytes: null, readAt: day(0, '05:00:00') },
7903
+ // Under the wrong tenant: no attempt row either.
7904
+ { tenantId: tenantId.parse(ulid()), scopeId: s, bytes: null, error: 'x', readAt: day(0, '06:00:00') },
7905
+ ])).toEqual({ recorded: 0 });
7906
+ // The last good sample stands.
7907
+ expect((await host.admin.listScopeStorage(staff, { scopeId: s, latest: true })).map((r) => r.bytes)).toEqual([700]);
7908
+ const attempts = (await host.admin.listScopeStorageAttempts(staff)).filter((a) => a.tenantId === tf);
7909
+ expect(attempts.map((a) => [a.scopeId, a.attemptedAt, a.error]).sort()).toEqual([
7910
+ [s, day(0, '05:00:00'), 'vertical answered 501'],
7911
+ [declined, day(0, '05:00:00'), null],
7912
+ ].sort());
7913
+ // /meters names the failing scope rather than letting it go missing; its last good
7914
+ // reading is still in the sum. A declined scope is not failing.
7915
+ expect((await host.admin.readMeters(staff, { tenantId: tf })).perTenant[0].storage).toMatchObject({
7916
+ bytes: 700,
7917
+ sampled: 1,
7918
+ total: 2,
7919
+ failing: 1,
7920
+ lastFailedAt: day(0, '05:00:00'),
7921
+ });
7922
+ // A successful read clears the error and moves the attempt on.
7923
+ await record(tf, s, 900, day(0, '07:00:00'));
7924
+ expect((await host.admin.readMeters(staff, { tenantId: tf })).perTenant[0].storage).toMatchObject({
7925
+ bytes: 900,
7926
+ failing: 0,
7927
+ lastFailedAt: null,
7928
+ });
7929
+ expect((await host.admin.listScopeStorageAttempts(staff)).find((a) => a.scopeId === s)).toMatchObject({
7930
+ attemptedAt: day(0, '07:00:00'),
7931
+ error: null,
7932
+ });
7933
+ });
7934
+ it("writes a reading only under the scope's own tenant, and folds each tenant's figure from its own scopes", async () => {
7935
+ const [ta, tb] = [tenantId.parse(ulid()), tenantId.parse(ulid())];
7936
+ await host.admin.createTenant(staff, { id: ta, slug: 'gauge-a', name: 'Gauge A' });
7937
+ await host.admin.createTenant(staff, { id: tb, slug: 'gauge-b', name: 'Gauge B' });
7938
+ const [a1, a2, b1] = [await newScope(ta), await newScope(ta), await newScope(tb)];
7939
+ // Named under the wrong tenant: refused by the directory, not merely filtered on read.
7940
+ expect(await record(tb, a1, 1_000_000, day(0, '01:00:00'))).toEqual({ recorded: 0 });
7941
+ expect(await host.admin.listScopeStorage(staff, { tenantId: tb })).toEqual([]);
7942
+ await host.admin.recordScopeStorage(staff, [
7943
+ { tenantId: ta, scopeId: a1, bytes: 4096, readAt: day(-1, '06:00:00') },
7944
+ { tenantId: ta, scopeId: a2, bytes: 8192, readAt: day(0, '06:00:00') },
7945
+ { tenantId: tb, scopeId: b1, bytes: 10, readAt: day(0, '07:00:00') },
7946
+ ]);
7947
+ const a = await host.admin.readMeters(staff, { tenantId: ta });
7948
+ expect(a.perTenant[0].storage).toEqual({
7949
+ basis: 'scope-databases',
7950
+ excluded: ['attachments', 'tenant-stores', 'lake'],
7951
+ bytes: 4096 + 8192,
7952
+ sampled: 2,
7953
+ total: 2,
7954
+ oldestReadAt: day(-1, '06:00:00'),
7955
+ newestReadAt: day(0, '06:00:00'),
7956
+ failing: 0,
7957
+ lastFailedAt: null,
7958
+ });
7959
+ expect(a.storage).toEqual(a.perTenant[0].storage);
7960
+ const b = await host.admin.readMeters(staff, { tenantId: tb });
7961
+ expect(b.perTenant[0].storage).toMatchObject({ bytes: 10, sampled: 1, total: 1 });
7962
+ // A new scope with no reading yet: the figure says so rather than calling itself whole.
7963
+ await newScope(tb);
7964
+ expect((await host.admin.readMeters(staff, { tenantId: tb })).perTenant[0].storage).toMatchObject({
7965
+ bytes: 10,
7966
+ sampled: 1,
7967
+ total: 2,
7968
+ });
7969
+ // The fleet figure is the per-tenant figures summed.
7970
+ const fleet = await host.admin.readMeters(staff);
7971
+ expect(fleet.storage.bytes).toBe(fleet.perTenant.reduce((n, r) => n + (r.storage?.bytes ?? 0), 0));
7972
+ expect(fleet.storage.sampled).toBe(fleet.perTenant.reduce((n, r) => n + (r.storage?.sampled ?? 0), 0));
7973
+ });
7974
+ it("forgets a reaped scope's samples, and never writes one for it again", async () => {
7975
+ const tr = tenantId.parse(ulid());
7976
+ await host.admin.createTenant(staff, { id: tr, slug: 'gauge-reap', name: 'Gauge Reap' });
7977
+ const [kept, reaped] = [await newScope(tr), await newScope(tr)];
7978
+ await record(tr, kept, 1, day(0, '02:00:00'));
7979
+ await record(tr, reaped, 2, day(-1, '02:00:00'));
7980
+ await record(tr, reaped, 3, day(0, '02:00:00'));
7981
+ // A sample past retention, so the prune below (which reads the raw table, with no join
7982
+ // to scope status) can tell whether reap deleted the rows or only the join hides them.
7983
+ while ((await host.admin.pruneScopeStorage(staff, 500)) > 0)
7984
+ ;
7985
+ const old = new Date();
7986
+ old.setUTCMonth(old.getUTCMonth() - 14);
7987
+ expect(await record(tr, reaped, 5, old.toISOString())).toEqual({ recorded: 1 });
7988
+ await host.admin.archiveScope(staff, tr, reaped);
7989
+ await host.admin.reapScope(staff, tr, reaped);
7990
+ expect(await host.admin.pruneScopeStorage(staff, 500)).toBe(0);
7991
+ expect((await host.admin.listScopeStorage(staff, { tenantId: tr })).map((r) => r.scopeId)).toEqual([kept]);
7992
+ expect(await record(tr, reaped, 4, day(0, '03:00:00'))).toEqual({ recorded: 0 });
7993
+ expect((await host.admin.readMeters(staff, { tenantId: tr })).perTenant[0].storage).toMatchObject({
7994
+ bytes: 1,
7995
+ sampled: 1,
7996
+ total: 1, // the tombstone is not in the denominator
7997
+ });
7998
+ });
7999
+ it('keeps an archived scope in the figure and refreshes it, so the tenant is not stuck stale', async () => {
8000
+ const ta = tenantId.parse(ulid());
8001
+ await host.admin.createTenant(staff, { id: ta, slug: 'gauge-archived', name: 'Gauge Archived' });
8002
+ const s = await newScope(ta);
8003
+ const threeDaysAgo = new Date(Date.now() - 3 * 86_400_000).toISOString();
8004
+ await record(ta, s, 4096, threeDaysAgo);
8005
+ await host.admin.archiveScope(staff, ta, s);
8006
+ // Archived storage is still storage until reap: counted, and stale until re-read.
8007
+ expect((await host.admin.readMeters(staff, { tenantId: ta })).perTenant[0].storage).toMatchObject({
8008
+ sampled: 1,
8009
+ total: 1,
8010
+ oldestReadAt: threeDaysAgo,
8011
+ });
8012
+ // No drain reaches an archived scope; the storage phase reads it anyway, once a day.
8013
+ const before = new Date().toISOString();
8014
+ const report = await runPlatformSweep(host, {
8015
+ actor: staff,
8016
+ fetch: connectorTestFetch,
8017
+ sweepers: {},
8018
+ drainRetries: false,
8019
+ storageGauge: { read: (scope) => host.admin.scopeDatabaseSize(staff, scope.tenantId, scope.id), batch: 10_000 },
8020
+ });
8021
+ expect(report.errors.filter((e) => e.kind === 'storage' && e.id === s)).toEqual([]);
8022
+ const after = (await host.admin.readMeters(staff, { tenantId: ta })).perTenant[0].storage;
8023
+ expect(after).toMatchObject({ sampled: 1, total: 1, failing: 0 });
8024
+ expect(after.oldestReadAt >= before).toBe(true);
8025
+ expect(after.bytes).toBeGreaterThan(0);
8026
+ });
8027
+ it('leaves a provisioning scope, which has no store yet, out of the figure entirely', async () => {
8028
+ const tp = tenantId.parse(ulid());
8029
+ await host.admin.createTenant(staff, { id: tp, slug: 'gauge-provisioning', name: 'Gauge Provisioning' });
8030
+ const s = await newScope(tp);
8031
+ const pending = scopeId.parse(ulid());
8032
+ await host.provisionScope(staff, { tenantId: tp, scopeId: pending }); // not activated
8033
+ expect((await host.admin.getScopeRecord(staff, tp, pending)).status).toBe('provisioning');
8034
+ await record(tp, s, 10, day(0, '08:00:00'));
8035
+ // A reading for it is refused too, so it can never enter the sum.
8036
+ expect(await record(tp, pending, 99, day(0, '08:00:00'))).toEqual({ recorded: 0 });
8037
+ expect((await host.admin.readMeters(staff, { tenantId: tp })).perTenant[0].storage).toMatchObject({
8038
+ bytes: 10,
8039
+ sampled: 1,
8040
+ total: 1, // not "1 of 2": the tenant's figure is whole
8041
+ });
8042
+ });
8043
+ it('neither counts nor accepts a reading for a scope archived before it held data; one archived from active still counts', async () => {
8044
+ const tv = tenantId.parse(ulid());
8045
+ await host.admin.createTenant(staff, { id: tv, slug: 'gauge-abandoned', name: 'Gauge Abandoned' });
8046
+ const kept = await newScope(tv);
8047
+ await record(tv, kept, 50, day(0, '09:00:00'));
8048
+ // A failed provision is archived straight from `provisioning` (it never held data).
8049
+ const abandoned = scopeId.parse(ulid());
8050
+ await host.provisionScope(staff, { tenantId: tv, scopeId: abandoned });
8051
+ await host.admin.archiveScope(staff, tv, abandoned);
8052
+ expect((await host.admin.getScopeRecord(staff, tv, abandoned)).archivedFromStatus).toBe('provisioning');
8053
+ // An app deleted after it served is archived from `active`.
8054
+ const deleted = await newScope(tv);
8055
+ await record(tv, deleted, 70, day(-2, '09:00:00')); // due again by the time the pass runs
8056
+ await host.admin.archiveScope(staff, tv, deleted);
8057
+ expect((await host.admin.getScopeRecord(staff, tv, deleted)).archivedFromStatus).toBe('active');
8058
+ expect(await record(tv, abandoned, 99, day(0, '10:00:00'))).toEqual({ recorded: 0 });
8059
+ expect((await host.admin.readMeters(staff, { tenantId: tv })).perTenant[0].storage).toMatchObject({
8060
+ bytes: 120,
8061
+ sampled: 2,
8062
+ total: 2, // kept + deleted; never the abandoned one
8063
+ });
8064
+ // The storage phase never wakes it.
8065
+ const asked = [];
8066
+ await runPlatformSweep(host, {
8067
+ actor: staff,
8068
+ fetch: connectorTestFetch,
8069
+ sweepers: {},
8070
+ drainRetries: false,
8071
+ storageGauge: {
8072
+ read: async (scope) => {
8073
+ asked.push(scope.id);
8074
+ return host.admin.scopeDatabaseSize(staff, scope.tenantId, scope.id);
8075
+ },
8076
+ batch: 10_000,
8077
+ },
8078
+ });
8079
+ expect(asked).not.toContain(abandoned);
8080
+ expect(asked).toContain(deleted);
8081
+ // Unarchiving clears it, as it clears archivedAt.
8082
+ await host.admin.unarchiveScope(staff, tv, deleted);
8083
+ expect((await host.admin.getScopeRecord(staff, tv, deleted)).archivedFromStatus ?? null).toBeNull();
8084
+ });
8085
+ it("deletes a fork's samples when the fork is deleted", async () => {
8086
+ const tk = tenantId.parse(ulid());
8087
+ await host.admin.createTenant(staff, { id: tk, slug: 'gauge-fork', name: 'Gauge Fork' });
8088
+ const source = await newScope(tk);
8089
+ const fork = await host.snapshotScope(staff, tk, source);
8090
+ // A sample past retention: the prune reads the raw table, with no join to the scope
8091
+ // row, so it sees a leftover that every other read would hide once the row is gone.
8092
+ while ((await host.admin.pruneScopeStorage(staff, 500)) > 0)
8093
+ ;
8094
+ const old = new Date();
8095
+ old.setUTCMonth(old.getUTCMonth() - 14);
8096
+ expect(await record(tk, fork, 9, old.toISOString())).toEqual({ recorded: 1 });
8097
+ await host.deleteSnapshot(staff, tk, fork);
8098
+ expect(await host.admin.pruneScopeStorage(staff, 500)).toBe(0);
8099
+ });
8100
+ it('prunes samples older than thirteen months, at most the limit per call', async () => {
8101
+ const tp = tenantId.parse(ulid());
8102
+ await host.admin.createTenant(staff, { id: tp, slug: 'gauge-prune', name: 'Gauge Prune' });
8103
+ const s = await newScope(tp);
8104
+ const monthsAgo = (m) => {
8105
+ const d = new Date();
8106
+ d.setUTCMonth(d.getUTCMonth() - m);
8107
+ return d.toISOString();
8108
+ };
8109
+ for (const m of [14, 15, 16])
8110
+ await record(tp, s, m, monthsAgo(m));
8111
+ await record(tp, s, 12, monthsAgo(12)); // inside retention: kept
8112
+ await record(tp, s, 0, day(0, '04:00:00'));
8113
+ expect(await host.admin.pruneScopeStorage(staff, 2)).toBe(2);
8114
+ expect(await host.admin.pruneScopeStorage(staff, 2)).toBe(1);
8115
+ expect(await host.admin.pruneScopeStorage(staff, 2)).toBe(0);
8116
+ expect((await host.admin.listScopeStorage(staff, { scopeId: s })).map((r) => r.bytes)).toEqual([12, 0]);
8117
+ await expect(host.admin.pruneScopeStorage(staff, -1)).rejects.toThrow(/limit/);
8118
+ });
8119
+ });
7667
8120
  it('leaves bare (module-less) operations ungated', async () => {
7668
8121
  // test/read-counter was registered via defineOperation, no manifest — it
7669
8122
  // must resolve regardless of entitlements.