@substrat-run/contract-tests 0.135.0 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { connectorCalls, connectorTestFetch, resetConnectorCalls } from './connector-fixture.js';
3
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
- import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, testModManifest, victimModManifest, } from './modules.js';
4
+ import { INERT_SCOPE_REASON, isSearchIndexTable, platformIntentFailureMessage, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, createUlid, ulid, } from '@substrat-run/kernel';
5
+ import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, 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
8
8
  * (#113 phase 5).
@@ -376,6 +376,253 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
376
376
  expect(settled.lastError).toMatch(/personal number field/);
377
377
  expect(settled.payload).toEqual({ contract: 'c1' });
378
378
  });
379
+ /**
380
+ * #1686. The platform's drain walks every active scope, and a fork, a snapshot or a preview
381
+ * is one, so an intent copied while still pending would be executed a second time from the
382
+ * copy: an email sent twice, a connector delivery repeated. `listPlatformRequests` is the
383
+ * drain's one read, so what it returns on the copy is exactly what a drain would run.
384
+ */
385
+ describe("a copy never runs the source's pending intents (#1686)", () => {
386
+ let source;
387
+ let id;
388
+ const pendingAt = async (s) => (await host.listPlatformRequests(t1, s)).map((r) => r.id);
389
+ const historyAt = async (s) => (await host.listPlatformRequestHistory(t1, s)).find((r) => r.id === id);
390
+ /** The copy: the intent is not drainable there, and its journal says why. */
391
+ const notCarriedAt = async (copy) => {
392
+ expect(await pendingAt(copy)).not.toContain(id);
393
+ expect(await historyAt(copy)).toMatchObject({
394
+ status: 'failed',
395
+ failure: { origin: 'platform', code: 'precondition_failed', permission: null },
396
+ lastError: expect.stringMatching(new RegExp(`^not carried: copied from scope ${source}`)),
397
+ settledAt: expect.any(String),
398
+ });
399
+ // The twin: the source still holds it pending, for its own drain.
400
+ expect(await pendingAt(source)).toContain(id);
401
+ };
402
+ beforeAll(async () => {
403
+ source = scopeId.parse(ulid());
404
+ await host.provisionScope(staff, { tenantId: t1, scopeId: source, jurisdiction: 'eu', vertical: 'connector-vertical' });
405
+ await host.admin.activateScope(staff, t1, source);
406
+ id = await (await host.getScope(alice, t1, source)).invoke('platform/request', {
407
+ kind: 'provision-sibling',
408
+ payload: { slug: 'copied' },
409
+ });
410
+ expect(await pendingAt(source)).toContain(id);
411
+ });
412
+ it('a fork (importScope) settles it not-carried; the source keeps it pending', async () => {
413
+ const fork = scopeId.parse(ulid());
414
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, await host.admin.exportScope(staff, t1, source));
415
+ await notCarriedAt(fork);
416
+ });
417
+ it('a snapshot (snapshotScope): the same', async () => {
418
+ await notCarriedAt(await host.snapshotScope(staff, t1, source, { kind: 'preview' }));
419
+ });
420
+ it("a restore of the source's backup onto another scope: the same", async () => {
421
+ const other = scopeId.parse(ulid());
422
+ await host.provisionScope(staff, { tenantId: t1, scopeId: other, jurisdiction: 'eu', vertical: 'connector-vertical' });
423
+ await host.admin.activateScope(staff, t1, other);
424
+ await host.restoreScope(staff, t1, other, await host.admin.exportScope(staff, t1, source));
425
+ await notCarriedAt(other);
426
+ });
427
+ it('a restore into the scope the backup came from keeps it pending, payload and all', async () => {
428
+ await host.restoreScope(staff, t1, source, await host.admin.exportScope(staff, t1, source));
429
+ const back = (await host.listPlatformRequests(t1, source)).find((r) => r.id === id);
430
+ expect(back).toMatchObject({ status: 'pending', payload: { slug: 'copied' }, lastError: null, settledAt: null });
431
+ });
432
+ });
433
+ /**
434
+ * #1686, the rest of the queue. Delivery is "no delivery row yet", so an event the source
435
+ * emitted and nobody reached is owed work in the copy too, and an executor retry is due work.
436
+ * The dump below is the source's own export with the journal set back: the consumer's row
437
+ * for one event removed (never delivered), one executor delivery made due for a retry, and
438
+ * another removed (never attempted).
439
+ */
440
+ describe('a copy never runs work the source queued (#1686)', () => {
441
+ let source;
442
+ let queued;
443
+ let step1;
444
+ const count = (tag) => effected.filter((e) => e === tag).length;
445
+ const logOf = async (s) => (await (await host.getScope(alice, t1, s)).invoke('flow/log')).map((r) => r.event_id);
446
+ /**
447
+ * The events whose executor delivery a copy journaled INERT (#2005): its own work, which
448
+ * reached dispatch — so `emittedHere` let it through — and was then held, because a copy
449
+ * causes no outbound effects. The source's work never reaches dispatch at all.
450
+ */
451
+ const inertIn = async (s) => (await host.executorDeadLetters(t1, s)).filter((d) => d.error === INERT_SCOPE_REASON).map((d) => d.eventId);
452
+ const eventIdOf = async (s, entityId) => {
453
+ const outbox = (await host.admin.exportScope(staff, t1, s)).tables.find((t) => t.name === '_substrat_outbox');
454
+ return String(outbox.rows.find((r) => r[outbox.columns.indexOf('entity_id')] === entityId)[outbox.columns.indexOf('id')]);
455
+ };
456
+ const activeScope = async () => {
457
+ const s = scopeId.parse(ulid());
458
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, jurisdiction: 'eu', vertical: 'connector-vertical' });
459
+ await host.admin.activateScope(staff, t1, s);
460
+ return s;
461
+ };
462
+ beforeAll(async () => {
463
+ source = await activeScope();
464
+ const stub = await host.getScope(alice, t1, source);
465
+ await stub.invoke('flow/produce');
466
+ await stub.invoke('connector/request-effect', { tag: 'copy-retry' });
467
+ await stub.invoke('connector/request-effect', { tag: 'copy-unattempted' });
468
+ // The source ran all three: the consumer logged step1, the executor effected both.
469
+ expect(count('copy-retry')).toBe(1);
470
+ expect(count('copy-unattempted')).toBe(1);
471
+ const dump = await host.admin.exportScope(staff, t1, source);
472
+ const outbox = dump.tables.find((t) => t.name === '_substrat_outbox');
473
+ const col = (name) => outbox.columns.indexOf(name);
474
+ const idOf = (type, entityId) => String(outbox.rows.find((r) => r[col('type')] === type && r[col('entity_id')] === entityId)[col('id')]);
475
+ step1 = idOf('flow.step1', 'f1');
476
+ const retry = idOf('effect.requested', 'copy-retry');
477
+ const unattempted = idOf('effect.requested', 'copy-unattempted');
478
+ expect(await logOf(source)).toContain(step1);
479
+ queued = {
480
+ ...dump,
481
+ tables: dump.tables.map((t) => {
482
+ // The consumer's own record of the delivery goes with the delivery row.
483
+ if (t.name === 'flow_log')
484
+ return { ...t, rows: [] };
485
+ if (t.name !== '_substrat_deliveries')
486
+ return t;
487
+ const at = (name) => t.columns.indexOf(name);
488
+ const rows = t.rows
489
+ .filter((r) => !(r[at('event_id')] === step1 && r[at('consumer_module')] === '@test/flow'))
490
+ .filter((r) => r[at('event_id')] !== unattempted)
491
+ .map((r) => {
492
+ if (r[at('event_id')] !== retry)
493
+ return r;
494
+ const due = [...r];
495
+ due[at('error')] = 'boom';
496
+ due[at('attempts')] = 1;
497
+ due[at('next_attempt_at')] = '2000-01-01T00:00:00.000Z';
498
+ return due;
499
+ });
500
+ return { ...t, rows };
501
+ }),
502
+ };
503
+ });
504
+ it('a fork: no consumer reaches the copied event and no executor runs, on dispatch or on drainDue', async () => {
505
+ const fork = scopeId.parse(ulid());
506
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
507
+ const stub = await host.getScope(alice, t1, fork);
508
+ await stub.invoke('flow/log'); // an invocation in the copy dispatches
509
+ await host.drainDue(t1, fork);
510
+ expect(count('copy-retry')).toBe(1);
511
+ expect(count('copy-unattempted')).toBe(1);
512
+ // The consumer never ran on the copied event.
513
+ expect(await logOf(fork)).toEqual([]);
514
+ expect(await host.listPlatformRequests(t1, fork)).toEqual([]);
515
+ // The retry reads settled, saying why, rather than vanishing.
516
+ const settled = (await host.executorDeadLetters(t1, fork)).find((d) => d.executorId === 'flaky-effector');
517
+ expect(settled?.error).toMatch(new RegExp(`^not carried: copied from scope ${source}`));
518
+ // Nor does it ship the source's events to Tier 2 again: the drain's read is empty here,
519
+ // while the source's own read still holds them.
520
+ expect(await host.admin.readUndrainedEvents(staff, t1, fork, 200)).toHaveLength(0);
521
+ expect((await host.admin.readUndrainedEvents(staff, t1, source, 200)).map((e) => e.id)).toContain(step1);
522
+ // The twin: work the copy creates itself flows as it always did, to Tier 2 included.
523
+ await stub.invoke('flow/produce');
524
+ expect((await host.admin.readUndrainedEvents(staff, t1, fork, 200)).map((e) => e.type)).toContain('flow.step1');
525
+ const own = await logOf(fork);
526
+ expect(own).toHaveLength(2); // its own step1, and the step2 that consumer emitted
527
+ expect(own).not.toContain(step1);
528
+ // Its own executor work reaches dispatch too, and is held there: a fork is inert (#2005).
529
+ await stub.invoke('connector/request-effect', { tag: 'fork-own' });
530
+ expect(count('fork-own')).toBe(0);
531
+ expect(await inertIn(fork)).toEqual([await eventIdOf(fork, 'fork-own')]);
532
+ });
533
+ it("the copy's own events still sort above a copied event stamped by a clock that ran ahead", async () => {
534
+ // A copied event from a source whose clock was ahead: without the loader re-seeding the
535
+ // copy's id floor from the outbox it brought, the copy's next event would sort below the
536
+ // mark and be withheld as if it were the source's.
537
+ const ahead = createUlid()(Date.parse('2099-01-01T00:00:00.000Z'));
538
+ const withAhead = {
539
+ ...queued,
540
+ tables: queued.tables.map((t) => {
541
+ if (t.name !== '_substrat_outbox')
542
+ return t;
543
+ const at = (name) => t.columns.indexOf(name);
544
+ const template = t.rows.find((r) => r[at('type')] === 'effect.requested');
545
+ const future = [...template];
546
+ future[at('id')] = ahead;
547
+ future[at('entity_id')] = 'copy-ahead';
548
+ future[at('payload')] = JSON.stringify({ tag: 'copy-ahead' });
549
+ return { ...t, rows: [...t.rows, future] };
550
+ }),
551
+ };
552
+ const fork = scopeId.parse(ulid());
553
+ await host.importScope(staff, { tenantId: t1, scopeId: fork, jurisdiction: 'eu', vertical: 'connector-vertical' }, withAhead);
554
+ await (await host.getScope(alice, t1, fork)).invoke('connector/request-effect', { tag: 'fork-after-ahead' });
555
+ // The copy's own event reached dispatch (held inert there, #2005); the copied one did not.
556
+ expect(await inertIn(fork)).toEqual([await eventIdOf(fork, 'fork-after-ahead')]);
557
+ expect(count('fork-after-ahead')).toBe(0);
558
+ expect(count('copy-ahead')).toBe(0);
559
+ });
560
+ it('a snapshot: the same', async () => {
561
+ const snap = scopeId.parse(ulid());
562
+ await host.importScope(staff, { tenantId: t1, scopeId: snap, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
563
+ await host.drainDue(t1, snap);
564
+ await (await host.getScope(alice, t1, snap)).invoke('flow/log');
565
+ expect(count('copy-retry')).toBe(1);
566
+ expect(count('copy-unattempted')).toBe(1);
567
+ expect(await logOf(snap)).toEqual([]);
568
+ });
569
+ /** A scope's export with one executor delivery removed, so that event is owed again. */
570
+ const owing = async (s, entityId) => {
571
+ const dump = await host.admin.exportScope(staff, t1, s);
572
+ const outbox = dump.tables.find((t) => t.name === '_substrat_outbox');
573
+ const id = outbox.rows.find((r) => r[outbox.columns.indexOf('entity_id')] === entityId)[outbox.columns.indexOf('id')];
574
+ return {
575
+ ...dump,
576
+ tables: dump.tables.map((t) => t.name === '_substrat_deliveries' ? { ...t, rows: t.rows.filter((r) => r[t.columns.indexOf('event_id')] !== id) } : t),
577
+ };
578
+ };
579
+ it("a copy of a copy runs neither source's work; the copy's own backup, returned, runs only its own", async () => {
580
+ const first = scopeId.parse(ulid());
581
+ await host.importScope(staff, { tenantId: t1, scopeId: first, jurisdiction: 'eu', vertical: 'connector-vertical' }, queued);
582
+ await (await host.getScope(alice, t1, first)).invoke('connector/request-effect', { tag: 'first-own' });
583
+ // A copy's own work reaches dispatch and is held inert there (#2005), never effected.
584
+ const firstOwn = await eventIdOf(first, 'first-own');
585
+ expect(await inertIn(first)).toEqual([firstOwn]);
586
+ const firstsBackup = await owing(first, 'first-own');
587
+ // copy → copy: the second copy's mark is above the first copy's own events too.
588
+ const second = scopeId.parse(ulid());
589
+ await host.importScope(staff, { tenantId: t1, scopeId: second, jurisdiction: 'eu', vertical: 'connector-vertical' }, firstsBackup);
590
+ await (await host.getScope(alice, t1, second)).invoke('flow/log');
591
+ await host.drainDue(t1, second);
592
+ expect(count('copy-retry')).toBe(1);
593
+ expect(count('copy-unattempted')).toBe(1);
594
+ // The first copy's own event is the second's INHERITED work: it never reaches dispatch.
595
+ expect(await inertIn(second)).toEqual([]);
596
+ await (await host.getScope(alice, t1, second)).invoke('connector/request-effect', { tag: 'second-own' });
597
+ expect(await inertIn(second)).toEqual([await eventIdOf(second, 'second-own')]);
598
+ // The first copy's backup returned to it: its own owed event runs, and the source's
599
+ // still do not, because the copy's origin came back with its backup.
600
+ await host.restoreScope(staff, t1, first, firstsBackup);
601
+ expect(await inertIn(first)).toEqual([]); // the backup had its delivery row removed
602
+ await (await host.getScope(alice, t1, first)).invoke('flow/log');
603
+ await host.drainDue(t1, first);
604
+ // Owed again and the first copy's own, so it reaches dispatch again: held inert (#2005).
605
+ expect(await inertIn(first)).toEqual([firstOwn]);
606
+ expect(count('first-own')).toBe(0);
607
+ expect(count('copy-retry')).toBe(1);
608
+ expect(count('copy-unattempted')).toBe(1);
609
+ expect(await logOf(first)).toEqual([]);
610
+ });
611
+ it('a return (the same dump restored into its own scope) runs all of it — from a dump that predates the origin table too', async () => {
612
+ // A backup taken before `_substrat_copy_origin` existed: the loader builds the table from
613
+ // the kernel's DDL, as every wake does on an existing scope, and no row means nothing here
614
+ // was copied.
615
+ const legacy = { ...queued, tables: queued.tables.filter((t) => t.name !== '_substrat_copy_origin') };
616
+ expect(legacy.tables.length).toBe(queued.tables.length - 1);
617
+ await host.restoreScope(staff, t1, source, legacy);
618
+ expect((await host.admin.listScopeTables(staff, t1, source)).map((t) => t.name)).toContain('_substrat_copy_origin');
619
+ await (await host.getScope(alice, t1, source)).invoke('flow/log');
620
+ await host.drainDue(t1, source);
621
+ expect(count('copy-retry')).toBe(2);
622
+ expect(count('copy-unattempted')).toBe(2);
623
+ expect(await logOf(source)).toContain(step1);
624
+ });
625
+ });
379
626
  /**
380
627
  * #1588. Every read of the intent journal returns a LIST, and the row decode behind all
381
628
  * three was strict — so ONE row whose JSON would not parse threw out of the map and took
@@ -1568,15 +1815,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1568
1815
  await stub.invoke('test/move', { entityId: 'l1' });
1569
1816
  const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
1570
1817
  entityType: 'test-lifecycle',
1571
- lifecycle: {
1572
- field: 'state',
1573
- initial: 'draft',
1574
- states: {
1575
- draft: { on: { 'test/move': 'live' } },
1576
- live: { on: { 'test/move': 'done' } },
1577
- done: { terminal: true },
1578
- },
1579
- },
1818
+ lifecycle: TEST_LIFECYCLE,
1580
1819
  since,
1581
1820
  until: new Date(Date.now() + 60_000).toISOString(),
1582
1821
  });
@@ -1586,6 +1825,84 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1586
1825
  expect(flow.observation).toMatchObject({ entities: 1, events: 3, inferred: 1, complete: true });
1587
1826
  expect(flow.totals.finished).toBe(1);
1588
1827
  });
1828
+ describe('lifecycleFlow on both adapters (#1744)', () => {
1829
+ let stub;
1830
+ beforeAll(async () => {
1831
+ stub = await host.getScope(alice, t1, s1);
1832
+ });
1833
+ /** One move, as its own call, returning the instant the outbox stamped on it. */
1834
+ const move = async (entityId, state) => {
1835
+ const invocationId = ulid();
1836
+ await stub.invoke('test/move', { entityId, state }, { invocationId });
1837
+ const { events } = await host.admin.invocationEvents(staff, t1, s1, { invocationId });
1838
+ expect(events).toHaveLength(1);
1839
+ const at = events[0].occurredAt;
1840
+ // The boundaries below are exact instants, so no two moves may share one.
1841
+ while (Date.now() <= Date.parse(at))
1842
+ await new Promise((r) => setTimeout(r, 2));
1843
+ return at;
1844
+ };
1845
+ const edge = (flow, from, to) => flow.edges.find((e) => e.from === from && e.to === to && e.declared);
1846
+ const state = (flow, name) => flow.states.find((s) => s.state === name);
1847
+ it('counts an event at `since` and not one at `until`: the window is half-open', async () => {
1848
+ const a = await move('w1', 'draft');
1849
+ const b = await move('w1', 'live');
1850
+ const c = await move('w1', 'done');
1851
+ expect(a < b && b < c).toBe(true);
1852
+ const read = (since, until) => host.admin.lifecycleFlow(staff, t1, s1, { entityType: 'test-lifecycle', lifecycle: TEST_LIFECYCLE, since, until });
1853
+ // [b, c): the move AT `since` is in; the move AT `until` is not replayed at all,
1854
+ // so "now" is still `live` — and the declared edge it would have taken reads 0.
1855
+ const mid = await read(b, c);
1856
+ expect(edge(mid, 'draft', 'live').count).toBe(1);
1857
+ expect(edge(mid, 'live', 'done').count).toBe(0);
1858
+ expect(state(mid, 'live').current).toBe(1);
1859
+ // [a, b): history before `since` is replayed, never counted, and `b` is outside.
1860
+ const before = await read(a, b);
1861
+ expect(edge(before, 'draft', 'live').count).toBe(0);
1862
+ expect(state(before, 'draft').current).toBeGreaterThanOrEqual(1);
1863
+ // The twin: one millisecond past `c` and the same move is counted.
1864
+ const after = await read(b, new Date(Date.parse(c) + 1).toISOString());
1865
+ expect(edge(after, 'draft', 'live').count).toBe(1);
1866
+ expect(edge(after, 'live', 'done').count).toBe(1);
1867
+ expect(state(after, 'live').current).toBe(0);
1868
+ });
1869
+ it('lists a move the declaration does not have apart from the declared edges', async () => {
1870
+ const since = await move('u1', 'draft');
1871
+ await move('u1', 'done'); // `test/move` from draft is declared to lead to `live`
1872
+ await move('u2', 'draft');
1873
+ await move('u2', 'limbo'); // a state the declaration does not have at all
1874
+ const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
1875
+ entityType: 'test-lifecycle',
1876
+ lifecycle: TEST_LIFECYCLE,
1877
+ since,
1878
+ until: new Date(Date.now() + 60_000).toISOString(),
1879
+ });
1880
+ const undeclared = flow.edges.filter((e) => !e.declared);
1881
+ expect(undeclared).toHaveLength(2);
1882
+ expect(undeclared).toEqual(expect.arrayContaining([
1883
+ expect.objectContaining({ from: 'draft', to: 'done', operation: 'test/move', count: 1, seenLate: false }),
1884
+ expect.objectContaining({ from: 'draft', to: 'limbo', operation: 'test/move', count: 1, seenLate: false }),
1885
+ ]));
1886
+ // Not folded into the declared edge out of the same state under the same operation.
1887
+ expect(edge(flow, 'draft', 'live').count).toBe(0);
1888
+ expect(state(flow, 'limbo')).toMatchObject({ declared: false, current: 1 });
1889
+ });
1890
+ it("answers only for the tenant's own scope: a mismatched pair is refused, another tenant's scope sees none of it", async () => {
1891
+ const since = await move('i1', 'draft');
1892
+ const window = { entityType: 'test-lifecycle', lifecycle: TEST_LIFECYCLE, since, until: new Date(Date.now() + 60_000).toISOString() };
1893
+ // The positive twin: t1's own pair sees the instance just started.
1894
+ expect((await host.admin.lifecycleFlow(staff, t1, s1, window)).totals.started).toBe(1);
1895
+ // K-3: never another tenant's outbox, and never an empty answer for one either.
1896
+ await expect(host.admin.lifecycleFlow(staff, t2, s1, window)).rejects.toThrow();
1897
+ await expect(host.admin.lifecycleFlow(staff, t1, s2, window)).rejects.toThrow();
1898
+ // t2's own scope answers, from its own outbox — in which t1 emitted nothing.
1899
+ const own = await host.admin.lifecycleFlow(staff, t2, s2, window);
1900
+ expect(own.observation).toMatchObject({ entities: 0, events: 0, complete: true });
1901
+ expect(own.edges.every((e) => e.count === 0)).toBe(true);
1902
+ // K-24: the read is recorded against the actor.
1903
+ expect((await host.admin.accessLog(staff, { tenantId: t1, method: 'lifecycleFlow' })).length).toBeGreaterThan(0);
1904
+ });
1905
+ });
1589
1906
  it('records a refused transition after the rollback, and the replay counts it (#1745)', async () => {
1590
1907
  // The operation throws the platform's conflict, so its transaction rolls back — the
1591
1908
  // refusal is the one write that must survive it, on both adapters.
@@ -1595,11 +1912,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1595
1912
  await expect(stub.invoke('test/refuse', { entityId: 'r1', from: 'done' })).rejects.toThrow(/invalid transition/);
1596
1913
  const flow = await host.admin.lifecycleFlow(staff, t1, s1, {
1597
1914
  entityType: 'test-lifecycle',
1598
- lifecycle: {
1599
- field: 'state',
1600
- initial: 'draft',
1601
- states: { draft: { on: { 'test/move': 'live' } }, live: { on: { 'test/move': 'done' } }, done: { terminal: true } },
1602
- },
1915
+ lifecycle: TEST_LIFECYCLE,
1603
1916
  since,
1604
1917
  until: new Date(Date.now() + 60_000).toISOString(),
1605
1918
  });
@@ -1631,6 +1944,11 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1631
1944
  drainedAt: null,
1632
1945
  });
1633
1946
  expect(rows[0].decodeError).toBeUndefined();
1947
+ expect(rows[0].guard).toBeNull();
1948
+ // A move the lifecycle allows records nothing.
1949
+ const allowed = ulid();
1950
+ await stub.invoke('test/move', { entityId: 'r2-ok', state: 'draft' }, { invocationId: allowed });
1951
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: allowed })).toEqual([]);
1634
1952
  // The row survived the operation's rollback — and the operation itself left nothing
1635
1953
  // behind in the outbox for that call.
1636
1954
  expect((await host.admin.invocationEvents(staff, t1, s1, { invocationId: call })).events).toEqual([]);
@@ -1646,6 +1964,13 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
1646
1964
  const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listRefusals' });
1647
1965
  expect(logged.length).toBeGreaterThan(0);
1648
1966
  await expect(host.admin.listRefusals(staff, t2, s1)).rejects.toThrow();
1967
+ // A record whose status column holds arbitrary text: the refusal is recorded, with the
1968
+ // state as the marker — the lifecycle never vouched for that text, so it is not kept.
1969
+ const undeclared = ulid();
1970
+ await expect(stub.invoke('test/refuse', { entityId: 'r2-odd', from: 'call ada@example.com' }, { invocationId: undeclared })).rejects.toThrow(/invalid transition/);
1971
+ const [oddRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: undeclared });
1972
+ expect(oddRow).toMatchObject({ kind: 'transition', fromState: 'undeclared', entityId: 'r2-odd', operation: 'test/move' });
1973
+ expect(JSON.stringify(oddRow)).not.toMatch(/ada@example\.com|call ada/);
1649
1974
  });
1650
1975
  // -- scope data introspection: the §5.4 admin-query RPC --------------------
1651
1976
  //
@@ -2684,9 +3009,12 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2684
3009
  const dump = await host.admin.exportScope(staff, t1, s1);
2685
3010
  const copy = scopeId.parse(ulid());
2686
3011
  await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
2687
- // Same tables and row counts as the source (the spine came across too).
2688
- const src = (await host.admin.listScopeTables(staff, t1, s1))
2689
- .map((t) => `${t.name}:${t.rowCount}`)
3012
+ // Same tables and row counts as the source (the spine came across too), but for the one
3013
+ // row every copy adds: where it came from, and the mark (#1686, and #2005: an empty copy
3014
+ // holds it too, which is how a host with no directory knows a copy).
3015
+ const srcTables = await host.admin.listScopeTables(staff, t1, s1);
3016
+ const src = srcTables
3017
+ .map((t) => (t.name === '_substrat_copy_origin' ? `${t.name}:1` : `${t.name}:${t.rowCount}`))
2690
3018
  .sort();
2691
3019
  const dst = (await host.admin.listScopeTables(staff, t1, copy))
2692
3020
  .map((t) => `${t.name}:${t.rowCount}`)
@@ -4454,6 +4782,37 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
4454
4782
  await host.admin.bindScopeVersion(staff, t1, s, v1); // legacy unconditional caller
4455
4783
  expect((await host.admin.getScopeRecord(staff, t1, s)).verticalVersionId).toBe(v1);
4456
4784
  });
4785
+ // #1722 (Codex #2008 r7): the admin-log record of a staff resolution of a kept copy. The
4786
+ // resolution itself runs in the vertical's store; the directory only records it, in full.
4787
+ it('records a kept-copy resolution in the admin log, before and after, and refuses an unknown scope', async () => {
4788
+ const s = scopeId.parse(ulid());
4789
+ await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: 'demo' });
4790
+ await host.admin.recordKeptCopyResolution(staff, t1, s, {
4791
+ action: 'restore-forward',
4792
+ script: 'demo-v1',
4793
+ liveScript: 'demo-v2',
4794
+ keptAt: '2026-10-03T12:00:00.000Z',
4795
+ revisionBefore: '41',
4796
+ revisionAfter: '7',
4797
+ });
4798
+ const [row] = await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: s });
4799
+ expect(row).toMatchObject({
4800
+ actor: staff,
4801
+ action: 'resolveKeptCopy',
4802
+ before: { script: 'demo-v1', keptAt: '2026-10-03T12:00:00.000Z', revision: '41' },
4803
+ after: { action: 'restore-forward', liveScript: 'demo-v2', revision: '7' },
4804
+ });
4805
+ const other = scopeId.parse(ulid());
4806
+ await expect(host.admin.recordKeptCopyResolution(staff, t1, other, {
4807
+ action: 'discard',
4808
+ script: 'demo-v1',
4809
+ liveScript: null,
4810
+ keptAt: '2026-10-03T12:00:00.000Z',
4811
+ revisionBefore: null,
4812
+ revisionAfter: null,
4813
+ })).rejects.toThrow(/unknown scope/);
4814
+ expect(await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: other })).toEqual([]);
4815
+ });
4457
4816
  it('publishes a version as pending, and refuses to bind it until admitted', async () => {
4458
4817
  const versionId = ulid();
4459
4818
  await host.admin.registerVertical(staff, {
@@ -5636,6 +5995,77 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
5636
5995
  const stub = await host.getScope(alice, t1, s1);
5637
5996
  await expect(stub.invoke('guarded/rows')).resolves.toEqual(['go']);
5638
5997
  });
5998
+ it('records a guard refusal once, after the rollback, with no message or input on the row (#1745)', async () => {
5999
+ const stub = await host.getScope(alice, t1, s1);
6000
+ const rowsBefore = await stub.invoke('guarded/rows');
6001
+ const call = ulid();
6002
+ const note = 'call me at ada@example.com';
6003
+ await expect(stub.invoke('guarded/open', { id: 'g-refused', note }, { invocationId: call })).rejects.toThrow(/gate closed/);
6004
+ // The caller's answer is the predicate's own 409, unchanged by being recorded.
6005
+ await expect(stub.invoke('guarded/open', { id: 'g-refused', note })).rejects.toMatchObject({
6006
+ code: 'conflict',
6007
+ });
6008
+ const rows = await host.admin.listRefusals(staff, t1, s1, { invocationId: call });
6009
+ expect(rows).toHaveLength(1);
6010
+ expect(rows[0]).toMatchObject({
6011
+ kind: 'guard',
6012
+ guard: 'gate/must-be-open',
6013
+ reason: 'gate_closed',
6014
+ actor: alice,
6015
+ actorKind: 'principal',
6016
+ tenantId: t1,
6017
+ scopeId: s1,
6018
+ entityType: 'guarded-thing',
6019
+ entityId: 'g-refused',
6020
+ // A guard stands before an operation; the kernel does not know the record's state.
6021
+ fromState: null,
6022
+ attemptedState: null,
6023
+ operation: 'guarded/open',
6024
+ invokedOperation: 'guarded/open',
6025
+ impersonation: null,
6026
+ invocationId: call,
6027
+ drainedAt: null,
6028
+ });
6029
+ expect(rows[0].decodeError).toBeUndefined();
6030
+ // Keys and vocabulary only: neither the input nor the message the caller saw.
6031
+ expect(JSON.stringify(rows[0])).not.toMatch(/ada@example\.com|gate closed|call me/);
6032
+ // Both attempts recorded, each once, and as guards.
6033
+ expect(await host.admin.listRefusals(staff, t1, s1, { kind: 'guard', entityId: 'g-refused' })).toHaveLength(2);
6034
+ expect(await host.admin.listRefusals(staff, t1, s1, { kind: 'transition', entityId: 'g-refused' })).toEqual([]);
6035
+ // The wire takes any non-empty string as a conflict's reason, so a predicate can put
6036
+ // request text there; the row keeps a code or the fixed marker, never the text.
6037
+ const leaked = ulid();
6038
+ await expect(stub.invoke('guarded/open', { id: 'g-leak', reason: 'call ada@example.com' }, { invocationId: leaked })).rejects.toThrow(/gate closed/);
6039
+ const [leakRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: leaked });
6040
+ expect(leakRow).toMatchObject({ kind: 'guard', reason: 'unrecognized', entityId: 'g-leak' });
6041
+ expect(JSON.stringify(leakRow)).not.toMatch(/ada@example\.com|call ada/);
6042
+ // The same for the record's type, which a predicate names from whatever it was handed:
6043
+ // kept only when spelled as an entity type. The id stays — it is the record's key.
6044
+ const typed = ulid();
6045
+ await expect(stub.invoke('guarded/open', { id: 'g-typed', entityType: 'ada@example.com' }, { invocationId: typed })).rejects.toThrow(/gate closed/);
6046
+ const [typedRow] = await host.admin.listRefusals(staff, t1, s1, { invocationId: typed });
6047
+ expect(typedRow).toMatchObject({ kind: 'guard', entityType: 'undeclared', entityId: 'g-typed' });
6048
+ expect(JSON.stringify(typedRow)).not.toMatch(/ada@example\.com/);
6049
+ // The rollback took the predicate's own write with it, and the handler never ran —
6050
+ // the refusal row is the one write that survived.
6051
+ expect(await stub.invoke('guarded/rows')).toEqual(rowsBefore);
6052
+ });
6053
+ it('records nothing for a guard that passes, or one that throws something other than a refusal (#1745)', async () => {
6054
+ const stub = await host.getScope(alice, t1, s1);
6055
+ const rowsBefore = await stub.invoke('guarded/rows');
6056
+ const passed = ulid();
6057
+ await stub.invoke('guarded/open', { id: 'g-open', open: true }, { invocationId: passed });
6058
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: passed })).toEqual([]);
6059
+ expect(await stub.invoke('guarded/rows')).toEqual([...rowsBefore, 'gate saw g-open', 'opened g-open']);
6060
+ // A bare Error is a guard failing, not a guard refusing: blocked, never recorded.
6061
+ const failed = ulid();
6062
+ await expect(stub.invoke('guarded/act', { flag: 'stop' }, { invocationId: failed })).rejects.toThrow(/expected flag/);
6063
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: failed })).toEqual([]);
6064
+ // And a guard whose predicate is missing fails closed, also unrecorded.
6065
+ const orphan = ulid();
6066
+ await expect(stub.invoke('guarded/orphan', {}, { invocationId: orphan })).rejects.toThrow(/unknown guard predicate/);
6067
+ expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: orphan })).toEqual([]);
6068
+ });
5639
6069
  // -- operation withdrawal (K-17) -----------------------------------------
5640
6070
  it('withdraws a default binding regardless of registration order (K-17)', async () => {
5641
6071
  const stub = await host.getScope(alice, t1, s1);