@substrat-run/contract-tests 0.134.0 → 0.136.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.
- package/dist/async-log-suite.d.ts +10 -0
- package/dist/async-log-suite.d.ts.map +1 -0
- package/dist/async-log-suite.js +226 -0
- package/dist/async-log-suite.js.map +1 -0
- package/dist/capability-suite.d.ts.map +1 -1
- package/dist/capability-suite.js +328 -0
- package/dist/capability-suite.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/inert-scope-suite.d.ts +14 -0
- package/dist/inert-scope-suite.d.ts.map +1 -0
- package/dist/inert-scope-suite.js +159 -0
- package/dist/inert-scope-suite.js.map +1 -0
- package/dist/job-run-suite.d.ts.map +1 -1
- package/dist/job-run-suite.js +27 -0
- package/dist/job-run-suite.js.map +1 -1
- package/dist/list-suite.d.ts.map +1 -1
- package/dist/list-suite.js +89 -3
- package/dist/list-suite.js.map +1 -1
- package/dist/modules.d.ts +422 -1
- package/dist/modules.d.ts.map +1 -1
- package/dist/modules.js +174 -3
- package/dist/modules.js.map +1 -1
- package/dist/schedule-entitlement-suite.d.ts +23 -0
- package/dist/schedule-entitlement-suite.d.ts.map +1 -0
- package/dist/schedule-entitlement-suite.js +98 -0
- package/dist/schedule-entitlement-suite.js.map +1 -0
- package/dist/scope-host-suite.d.ts.map +1 -1
- package/dist/scope-host-suite.js +356 -4
- package/dist/scope-host-suite.js.map +1 -1
- package/dist/timeline-suite.d.ts.map +1 -1
- package/dist/timeline-suite.js +119 -1
- package/dist/timeline-suite.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule-entitlement-suite.d.ts","sourceRoot":"","sources":["../src/schedule-entitlement-suite.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAM9D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,gCAAgC,CAC9C,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,GAC3C,IAAI,CAqFN"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { moduleId, permissionKey, platformActorId, principalId, scopeId, tenantId, } from '@substrat-run/contracts';
|
|
3
|
+
import { ulid } from '@substrat-run/kernel';
|
|
4
|
+
import { composedEngineMod, composerMod } from './modules.js';
|
|
5
|
+
const ENGINE = moduleId.parse('@test/composed-engine');
|
|
6
|
+
const COMPOSER = moduleId.parse('@test/composer');
|
|
7
|
+
/**
|
|
8
|
+
* Contract suite for #1654: a COMPOSED engine's own declared schedule, on a tenant that
|
|
9
|
+
* holds the composing vertical's SKU and not the engine's.
|
|
10
|
+
*
|
|
11
|
+
* The §4.3 rule is "a module loads for a tenant only if the tenant holds its SKU flag".
|
|
12
|
+
* Its one exception is a module's own declared schedule fired through the system door,
|
|
13
|
+
* where the `system:<moduleId>` grant is the switch (#383) — the kernel's
|
|
14
|
+
* `requiredEntitlementFor`. Both adapters must agree on the exception AND on everything
|
|
15
|
+
* around it, so this suite pins the four edges together:
|
|
16
|
+
*
|
|
17
|
+
* 1. the engine's schedule fires without the engine's SKU;
|
|
18
|
+
* 2. a PRINCIPAL invoking the same operation is still refused — even holding the
|
|
19
|
+
* permission — so the invoke surface does not widen;
|
|
20
|
+
* 3. the engine's system door is refused for an operation it does NOT declare as a
|
|
21
|
+
* schedule, though its system principal would pass that operation's `ctx.check`;
|
|
22
|
+
* 4. another module's system door is refused for the engine's scheduled operation.
|
|
23
|
+
*
|
|
24
|
+
* `runDueSchedules` is called directly, never a sweep, so registering these modules
|
|
25
|
+
* changes no other suite's fired/skipped counts.
|
|
26
|
+
*/
|
|
27
|
+
export function scheduleEntitlementContractSuite(adapterName, makeFixture) {
|
|
28
|
+
describe(`composed-engine schedule entitlement (#1654): ${adapterName}`, () => {
|
|
29
|
+
let fixture;
|
|
30
|
+
let host;
|
|
31
|
+
const t = tenantId.parse(ulid());
|
|
32
|
+
const s = scopeId.parse(ulid());
|
|
33
|
+
const staff = platformActorId.parse(ulid());
|
|
34
|
+
// Holds the engine's permission through a role, so a refusal of this principal can
|
|
35
|
+
// only be the SKU gate — a permission refusal would prove nothing about the surface.
|
|
36
|
+
const user = principalId.parse(ulid());
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
fixture = await makeFixture();
|
|
39
|
+
host = fixture.host;
|
|
40
|
+
host.registerModule(composedEngineMod);
|
|
41
|
+
host.registerModule(composerMod);
|
|
42
|
+
await host.admin.createTenant(staff, { id: t, slug: 'composer-co', name: 'Composer Co' });
|
|
43
|
+
// A standard install: the vertical's key only. Never the engine's.
|
|
44
|
+
await host.admin.grantEntitlement(staff, t, 'composer');
|
|
45
|
+
await host.provisionScope(staff, { tenantId: t, scopeId: s, vertical: 'composer-vertical' });
|
|
46
|
+
await host.admin.activateScope(staff, t, s);
|
|
47
|
+
await host.admin.defineRole(staff, t, {
|
|
48
|
+
key: 'sweeper',
|
|
49
|
+
permissions: [permissionKey.parse('composed-engine:sweep')],
|
|
50
|
+
source: 'vertical',
|
|
51
|
+
});
|
|
52
|
+
await host.admin.assignRole(staff, {
|
|
53
|
+
principalId: user,
|
|
54
|
+
roleKey: 'sweeper',
|
|
55
|
+
node: { tenantId: t, scopeId: null },
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
await fixture.cleanup();
|
|
60
|
+
});
|
|
61
|
+
const sweeps = async () => (await host.getScope(user, t, s)).invoke('composer/sweeps');
|
|
62
|
+
it("fires the engine's own schedule on a tenant holding only the vertical's key", async () => {
|
|
63
|
+
const report = await host.runDueSchedules(ENGINE, t, s);
|
|
64
|
+
expect(report.errors).toEqual([]);
|
|
65
|
+
expect(report.fired).toBe(1);
|
|
66
|
+
expect(report.failed).toBe(0);
|
|
67
|
+
expect(await sweeps()).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
it("still refuses the engine's operation to a principal — the invoke surface does not widen", async () => {
|
|
70
|
+
const stub = await host.getScope(user, t, s);
|
|
71
|
+
const err = await stub.invoke('composed-engine/sweep').then(() => null, (e) => e.message);
|
|
72
|
+
expect(err).toMatch(/operation not entitled: composed-engine\/sweep/);
|
|
73
|
+
expect(err).toMatch(/does not hold 'composed-engine'/);
|
|
74
|
+
expect(await sweeps()).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
it('still gates an operation the engine does not declare as a schedule, through its own system door', async () => {
|
|
77
|
+
// The system principal holds `composed-engine:sweep`, which is all `manual` checks:
|
|
78
|
+
// only the SKU gate stands between this call and a write.
|
|
79
|
+
const sys = await host.getSystemScope(ENGINE, t, s);
|
|
80
|
+
await expect(sys.invoke('composed-engine/manual')).rejects.toThrow(/operation not entitled: composed-engine\/manual/);
|
|
81
|
+
expect(await sweeps()).toBe(1);
|
|
82
|
+
});
|
|
83
|
+
it("never lends the exception to another module's system door", async () => {
|
|
84
|
+
const sys = await host.getSystemScope(COMPOSER, t, s);
|
|
85
|
+
await expect(sys.invoke('composed-engine/sweep')).rejects.toThrow(/operation not entitled: composed-engine\/sweep/);
|
|
86
|
+
expect(await sweeps()).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
it("opens the principal's door once the tenant holds the engine's own key — the refusal above was the SKU", async () => {
|
|
89
|
+
await host.admin.grantEntitlement(staff, t, 'composed-engine');
|
|
90
|
+
const stub = await host.getScope(user, t, s);
|
|
91
|
+
await stub.invoke('composed-engine/sweep');
|
|
92
|
+
expect(await sweeps()).toBe(2);
|
|
93
|
+
await host.admin.revokeEntitlement(staff, t, 'composed-engine');
|
|
94
|
+
await expect(stub.invoke('composed-engine/sweep')).rejects.toThrow(/not entitled/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=schedule-entitlement-suite.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schedule-entitlement-suite.js","sourceRoot":"","sources":["../src/schedule-entitlement-suite.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAkBL,KAAK,SAAS,EAYf,MAAM,yBAAyB,CAAC;AACjC,OAAO,
|
|
1
|
+
{"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAkBL,KAAK,SAAS,EAYf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAaL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAW9B,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AA2BD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,aAAa,MACrB,OAAO,CAAC,OAAO,CAAC,QACb,SAAS,YACL,MAAM,KACf,OAAO,CAAC,IAAI,CAOd,CAAC;AAEF,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC;;;;;;OAMG;IACH,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,CAAC,EAC5C,IAAI,GAAE,qBAA0B,GAC/B,IAAI,CA0hRN"}
|
package/dist/scope-host-suite.js
CHANGED
|
@@ -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
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';
|
|
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
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
|
|
@@ -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
|
|
@@ -1606,6 +1853,47 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
1606
1853
|
// `test/move` leads to two states where it is legal, so the attempt is not one of them.
|
|
1607
1854
|
expect(flow.refused).toEqual([{ from: 'done', attempted: null, operation: 'test/move', count: 2, actors: { principal: 2 } }]);
|
|
1608
1855
|
});
|
|
1856
|
+
it('reads a refused transition back as a row — record, states, actor, call — like a denial (#1745)', async () => {
|
|
1857
|
+
// A record of its own and a call id of its own, so the case above does not leak in.
|
|
1858
|
+
const stub = await host.getScope(alice, t1, s1);
|
|
1859
|
+
const call = ulid();
|
|
1860
|
+
await expect(stub.invoke('test/refuse', { entityId: 'r2', from: 'done' }, { invocationId: call })).rejects.toThrow(/invalid transition/);
|
|
1861
|
+
const rows = await host.admin.listRefusals(staff, t1, s1, { entityType: 'test-lifecycle', entityId: 'r2' });
|
|
1862
|
+
expect(rows).toHaveLength(1);
|
|
1863
|
+
expect(rows[0]).toMatchObject({
|
|
1864
|
+
kind: 'transition',
|
|
1865
|
+
reason: 'invalid_transition',
|
|
1866
|
+
actor: alice,
|
|
1867
|
+
actorKind: 'principal',
|
|
1868
|
+
tenantId: t1,
|
|
1869
|
+
scopeId: s1,
|
|
1870
|
+
entityType: 'test-lifecycle',
|
|
1871
|
+
entityId: 'r2',
|
|
1872
|
+
fromState: 'done',
|
|
1873
|
+
attemptedState: null,
|
|
1874
|
+
operation: 'test/move',
|
|
1875
|
+
invokedOperation: 'test/refuse',
|
|
1876
|
+
impersonation: null,
|
|
1877
|
+
invocationId: call,
|
|
1878
|
+
drainedAt: null,
|
|
1879
|
+
});
|
|
1880
|
+
expect(rows[0].decodeError).toBeUndefined();
|
|
1881
|
+
// The row survived the operation's rollback — and the operation itself left nothing
|
|
1882
|
+
// behind in the outbox for that call.
|
|
1883
|
+
expect((await host.admin.invocationEvents(staff, t1, s1, { invocationId: call })).events).toEqual([]);
|
|
1884
|
+
// Narrowed like the denial log: by call, by actor, by window; newest first, bounded.
|
|
1885
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: call })).toHaveLength(1);
|
|
1886
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { invocationId: ulid() })).toEqual([]);
|
|
1887
|
+
const all = await host.admin.listRefusals(staff, t1, s1, { actor: alice });
|
|
1888
|
+
expect(all.length).toBeGreaterThanOrEqual(3); // r1 twice (the case above) + r2
|
|
1889
|
+
expect(all[0].entityId).toBe('r2');
|
|
1890
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { until: all[all.length - 1].at })).toEqual([]);
|
|
1891
|
+
expect(await host.admin.listRefusals(staff, t1, s1, { limit: 1 })).toHaveLength(1);
|
|
1892
|
+
// K-24: the read is recorded; K-3: a mismatched pair is refused, never another tenant's log.
|
|
1893
|
+
const logged = await host.admin.accessLog(staff, { tenantId: t1, method: 'listRefusals' });
|
|
1894
|
+
expect(logged.length).toBeGreaterThan(0);
|
|
1895
|
+
await expect(host.admin.listRefusals(staff, t2, s1)).rejects.toThrow();
|
|
1896
|
+
});
|
|
1609
1897
|
// -- scope data introspection: the §5.4 admin-query RPC --------------------
|
|
1610
1898
|
//
|
|
1611
1899
|
// A read-only window into a scope's OWN database (the console/dashboard Data
|
|
@@ -2643,9 +2931,12 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
2643
2931
|
const dump = await host.admin.exportScope(staff, t1, s1);
|
|
2644
2932
|
const copy = scopeId.parse(ulid());
|
|
2645
2933
|
await host.importScope(staff, { tenantId: t1, scopeId: copy, jurisdiction: 'eu', vertical: 'connector-vertical' }, dump);
|
|
2646
|
-
// Same tables and row counts as the source (the spine came across too)
|
|
2647
|
-
|
|
2648
|
-
|
|
2934
|
+
// Same tables and row counts as the source (the spine came across too), but for the one
|
|
2935
|
+
// row every copy adds: where it came from, and the mark (#1686, and #2005: an empty copy
|
|
2936
|
+
// holds it too, which is how a host with no directory knows a copy).
|
|
2937
|
+
const srcTables = await host.admin.listScopeTables(staff, t1, s1);
|
|
2938
|
+
const src = srcTables
|
|
2939
|
+
.map((t) => (t.name === '_substrat_copy_origin' ? `${t.name}:1` : `${t.name}:${t.rowCount}`))
|
|
2649
2940
|
.sort();
|
|
2650
2941
|
const dst = (await host.admin.listScopeTables(staff, t1, copy))
|
|
2651
2942
|
.map((t) => `${t.name}:${t.rowCount}`)
|
|
@@ -4413,6 +4704,37 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4413
4704
|
await host.admin.bindScopeVersion(staff, t1, s, v1); // legacy unconditional caller
|
|
4414
4705
|
expect((await host.admin.getScopeRecord(staff, t1, s)).verticalVersionId).toBe(v1);
|
|
4415
4706
|
});
|
|
4707
|
+
// #1722 (Codex #2008 r7): the admin-log record of a staff resolution of a kept copy. The
|
|
4708
|
+
// resolution itself runs in the vertical's store; the directory only records it, in full.
|
|
4709
|
+
it('records a kept-copy resolution in the admin log, before and after, and refuses an unknown scope', async () => {
|
|
4710
|
+
const s = scopeId.parse(ulid());
|
|
4711
|
+
await host.provisionScope(staff, { tenantId: t1, scopeId: s, vertical: 'demo' });
|
|
4712
|
+
await host.admin.recordKeptCopyResolution(staff, t1, s, {
|
|
4713
|
+
action: 'restore-forward',
|
|
4714
|
+
script: 'demo-v1',
|
|
4715
|
+
liveScript: 'demo-v2',
|
|
4716
|
+
keptAt: '2026-10-03T12:00:00.000Z',
|
|
4717
|
+
revisionBefore: '41',
|
|
4718
|
+
revisionAfter: '7',
|
|
4719
|
+
});
|
|
4720
|
+
const [row] = await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: s });
|
|
4721
|
+
expect(row).toMatchObject({
|
|
4722
|
+
actor: staff,
|
|
4723
|
+
action: 'resolveKeptCopy',
|
|
4724
|
+
before: { script: 'demo-v1', keptAt: '2026-10-03T12:00:00.000Z', revision: '41' },
|
|
4725
|
+
after: { action: 'restore-forward', liveScript: 'demo-v2', revision: '7' },
|
|
4726
|
+
});
|
|
4727
|
+
const other = scopeId.parse(ulid());
|
|
4728
|
+
await expect(host.admin.recordKeptCopyResolution(staff, t1, other, {
|
|
4729
|
+
action: 'discard',
|
|
4730
|
+
script: 'demo-v1',
|
|
4731
|
+
liveScript: null,
|
|
4732
|
+
keptAt: '2026-10-03T12:00:00.000Z',
|
|
4733
|
+
revisionBefore: null,
|
|
4734
|
+
revisionAfter: null,
|
|
4735
|
+
})).rejects.toThrow(/unknown scope/);
|
|
4736
|
+
expect(await host.admin.auditLog(staff, { action: 'resolveKeptCopy', scopeId: other })).toEqual([]);
|
|
4737
|
+
});
|
|
4416
4738
|
it('publishes a version as pending, and refuses to bind it until admitted', async () => {
|
|
4417
4739
|
const versionId = ulid();
|
|
4418
4740
|
await host.admin.registerVertical(staff, {
|
|
@@ -4977,6 +5299,25 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
4977
5299
|
const rejected = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
4978
5300
|
expect(rejected?.admissionNote).toContain('widened a role');
|
|
4979
5301
|
});
|
|
5302
|
+
it('refuses to reject a version that is already admitted, and leaves it admitted', async () => {
|
|
5303
|
+
// `conflict` (#113 phase 6): the version may already be bound, so admission is one-way
|
|
5304
|
+
// from here. Rejecting an admitted version is the one refusal `rejectVersion` makes.
|
|
5305
|
+
const versionId = ulid();
|
|
5306
|
+
await host.admin.publishVersion(staff, {
|
|
5307
|
+
id: versionId,
|
|
5308
|
+
verticalSlug: 'callout',
|
|
5309
|
+
version: '1.1.0-vouched',
|
|
5310
|
+
manifestDigest: 'm3',
|
|
5311
|
+
permissionDigest: 'p3',
|
|
5312
|
+
migrationDigest: 'g3',
|
|
5313
|
+
deploymentRef: null,
|
|
5314
|
+
});
|
|
5315
|
+
await host.admin.admitVersion(staff, versionId);
|
|
5316
|
+
await expectRefusal(host.admin.rejectVersion(staff, versionId, 'changed my mind'), 'conflict');
|
|
5317
|
+
const after = (await host.admin.listVersions(staff, 'callout')).find((v) => v.id === versionId);
|
|
5318
|
+
expect(after?.admission).toBe('admitted');
|
|
5319
|
+
expect(after?.admissionNote).toBeNull();
|
|
5320
|
+
});
|
|
4980
5321
|
it('carries the digests promotion compares', async () => {
|
|
4981
5322
|
// "Has the permission surface changed between what is in prod and what I am
|
|
4982
5323
|
// promoting?" is a string comparison here. Today it is a person remembering
|
|
@@ -5069,6 +5410,17 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
|
|
|
5069
5410
|
});
|
|
5070
5411
|
await expectRefusal(host.admin.promoteVersion(staff, 'callout', 'prod', pending), 'conflict');
|
|
5071
5412
|
});
|
|
5413
|
+
it('refuses to promote a version through a vertical that does not own it', async () => {
|
|
5414
|
+
// `conflict` (#113 phase 6). The version is real and admitted — it is the pairing that
|
|
5415
|
+
// is wrong — so this is neither `not_found` (the version exists) nor a state to wait out.
|
|
5416
|
+
const v = await publish('2.9.0', { perm: 'pZ', mig: 'gZ' });
|
|
5417
|
+
await host.admin.registerVertical(staff, { slug: 'elsewhere', name: 'Elsewhere', source: 'builtin' });
|
|
5418
|
+
await expectRefusal(host.admin.promoteVersion(staff, 'elsewhere', 'prod', v), 'conflict');
|
|
5419
|
+
// Positive twin: through its own vertical the same version promotes (the earlier tests
|
|
5420
|
+
// moved prod, so the digest checkpoints are acknowledged — they are not under test here).
|
|
5421
|
+
await host.admin.promoteVersion(staff, 'callout', 'prod', v, { permissionChange: true, migrationChange: true });
|
|
5422
|
+
expect((await host.admin.listChannels(staff, 'callout')).find((c) => c.channel === 'prod')?.versionId).toBe(v);
|
|
5423
|
+
});
|
|
5072
5424
|
// -- private verticals: self-serve prod (builder-plane.md §4-revised) ----
|
|
5073
5425
|
// A private vertical's blast radius is its own tenant — dev/staging already
|
|
5074
5426
|
// run the same bundle in the same sandbox — so its versions self-admit on
|