@substrat-run/contract-tests 0.160.0 → 0.161.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.
@@ -0,0 +1,114 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { errorCodeOf, eventId, platformActorId, scopeId as scopeIdSchema, tenantId, } from '@substrat-run/contracts';
3
+ import { scopeNotHeldRefusal, ulid } from '@substrat-run/kernel';
4
+ import { TEST_LIFECYCLE } from './modules.js';
5
+ /** A host's introspection reads, bound, for a fixture to hand the suite. */
6
+ export const introspectionReadsOf = (host) => ({
7
+ facetEventsLocal: (s, i) => host.facetEventsLocal(s, i),
8
+ eventEffectsLocal: (s, i) => host.eventEffectsLocal(s, i),
9
+ invocationEventsLocal: (s, i) => host.invocationEventsLocal(s, i),
10
+ deadLettersLocal: (s, i) => host.deadLettersLocal(s, i),
11
+ lifecycleFlowLocal: (s, i) => host.lifecycleFlowLocal(s, i),
12
+ operationSeriesLocal: (s, i) => host.operationSeriesLocal(s, i),
13
+ eventCauseLocal: (s, i) => host.eventCauseLocal(s, i),
14
+ entityHistoryLocal: (s, i) => host.entityHistoryLocal(s, i),
15
+ appliedMigrationsLocal: (s) => host.appliedMigrationsLocal(s),
16
+ migrationBookmarksLocal: (s) => host.migrationBookmarksLocal(s),
17
+ introspectScopeTables: (s) => host.introspectScopeTables(s),
18
+ introspectScopeTable: (s, i) => host.introspectScopeTable(s, i),
19
+ introspectScopeQuery: (s, i) => host.introspectScopeQuery(s, i),
20
+ undrainedEventsLocal: (s, n) => host.undrainedEventsLocal(s, n),
21
+ redrainCountLocal: (s, b) => host.redrainCountLocal(s, b),
22
+ });
23
+ /** One valid call of each read, for a suite that asks every one of them the same question. */
24
+ export function introspectionReadCalls(r) {
25
+ const window = () => {
26
+ const since = Math.floor(Date.now() / 1000) * 1000;
27
+ return { since: new Date(since - 60_000).toISOString(), until: new Date(since + 60_000).toISOString() };
28
+ };
29
+ return {
30
+ facetEventsLocal: (s) => r.facetEventsLocal(s, { groupBy: { kind: 'type' } }),
31
+ eventEffectsLocal: (s) => r.eventEffectsLocal(s, { eventId: eventId.parse(ulid()) }),
32
+ invocationEventsLocal: (s) => r.invocationEventsLocal(s, { invocationId: ulid() }),
33
+ deadLettersLocal: (s) => r.deadLettersLocal(s, {}),
34
+ lifecycleFlowLocal: (s) => r.lifecycleFlowLocal(s, { entityType: 'test-lifecycle', lifecycle: TEST_LIFECYCLE, ...window() }),
35
+ operationSeriesLocal: (s) => r.operationSeriesLocal(s, {
36
+ moves: [{ entityType: 'test-lifecycle', operation: 'test/move' }],
37
+ bucketMinutes: 1,
38
+ ...window(),
39
+ }),
40
+ eventCauseLocal: (s) => r.eventCauseLocal(s, { eventId: eventId.parse(ulid()) }),
41
+ entityHistoryLocal: (s) => r.entityHistoryLocal(s, { entityType: 'test-thing', entityId: 'x1' }),
42
+ appliedMigrationsLocal: (s) => r.appliedMigrationsLocal(s),
43
+ migrationBookmarksLocal: (s) => r.migrationBookmarksLocal(s),
44
+ introspectScopeTables: (s) => r.introspectScopeTables(s),
45
+ introspectScopeTable: (s) => r.introspectScopeTable(s, { table: '_substrat_outbox', limit: 10, offset: 0 }),
46
+ introspectScopeQuery: (s) => r.introspectScopeQuery(s, { sql: 'SELECT 1 AS one' }),
47
+ undrainedEventsLocal: (s) => r.undrainedEventsLocal(s, 10),
48
+ redrainCountLocal: (s) => r.redrainCountLocal(s, new Date().toISOString()),
49
+ };
50
+ }
51
+ /**
52
+ * The introspection reads that name only a scope id hold the scope before they read it (#2544), as
53
+ * the size reads do (#2541): a scope this deployment does not hold (reaped, or never provisioned
54
+ * here) is refused `conflict` with one sentence, never answered as an empty store, and a refusal
55
+ * creates no store. A held scope still reads. One case per read, on both adapters.
56
+ */
57
+ export function introspectionReadsContractSuite(adapterName, makeFixture) {
58
+ describe(`introspection reads refuse a scope the deployment does not hold (#2544): ${adapterName}`, () => {
59
+ let fixture;
60
+ const staff = platformActorId.parse(ulid());
61
+ const t = tenantId.parse(ulid());
62
+ beforeAll(async () => {
63
+ fixture = await makeFixture();
64
+ await fixture.host.admin.createTenant(staff, { id: t, slug: `introspect-${t.slice(-8).toLowerCase()}`, name: 'Introspection' });
65
+ });
66
+ afterAll(async () => fixture?.cleanup());
67
+ const newScope = async () => {
68
+ const s = scopeIdSchema.parse(ulid());
69
+ await fixture.host.provisionScope(staff, { tenantId: t, scopeId: s, vertical: 'introspect' });
70
+ await fixture.host.admin.activateScope(staff, t, s);
71
+ return s;
72
+ };
73
+ const reap = async (s) => {
74
+ await fixture.host.admin.archiveScope(staff, t, s);
75
+ await fixture.host.admin.reapScope(staff, t, s, { force: true });
76
+ };
77
+ const refusal = (p) => p.then(() => undefined, (e) => ({ code: errorCodeOf(e), message: e.message }));
78
+ const refusedAs = (s) => ({ code: 'conflict', message: scopeNotHeldRefusal(s) });
79
+ // The calls are built before the fixture is (they name the cases), so each one reaches it late.
80
+ const lazy = new Proxy({}, {
81
+ get: (_target, name) => (...args) => fixture[name](...args),
82
+ });
83
+ const reads = introspectionReadCalls(lazy);
84
+ describe.each(Object.entries(reads))('%s', (_name, read) => {
85
+ it('reads a held scope, and answers something', async () => {
86
+ const s = await newScope();
87
+ expect(await read(s)).not.toBeNull();
88
+ expect(await read(s)).toBeDefined();
89
+ });
90
+ it('refuses a reaped scope, in the shared words, and leaves nothing stored for it', async () => {
91
+ const s = await newScope();
92
+ await reap(s);
93
+ expect(await refusal(read(s))).toEqual(refusedAs(s));
94
+ // Asked again, it is still refused: the first refusal did not make the scope held.
95
+ expect(await refusal(read(s))).toEqual(refusedAs(s));
96
+ expect(await fixture.storeHoldsNothing(t, s)).toBe(true);
97
+ });
98
+ it('refuses an id nothing provisioned here, and leaves nothing stored for it', async () => {
99
+ const s = scopeIdSchema.parse(ulid());
100
+ expect(await refusal(read(s))).toEqual(refusedAs(s));
101
+ expect(await fixture.storeHoldsNothing(t, s)).toBe(true);
102
+ });
103
+ });
104
+ it('a scope listed as active and reaped before it is read is refused, while the held one beside it reads', async () => {
105
+ const [gone, kept] = [await newScope(), await newScope()];
106
+ const due = (await fixture.host.admin.listScopes(staff, { tenantId: t, status: 'active' })).map((x) => x.id);
107
+ expect(due).toEqual(expect.arrayContaining([gone, kept]));
108
+ await reap(gone);
109
+ expect(await refusal(fixture.undrainedEventsLocal(gone, 10))).toEqual(refusedAs(gone));
110
+ expect(await refusal(fixture.undrainedEventsLocal(kept, 10))).toBeUndefined();
111
+ });
112
+ });
113
+ }
114
+ //# sourceMappingURL=introspection-reads-suite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"introspection-reads-suite.js","sourceRoot":"","sources":["../src/introspection-reads-suite.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ import { type ScopeId, type TenantId } from '@substrat-run/contracts';
2
+ import { type RoleState } from '@substrat-run/kernel';
3
+ import type { ScopeHostFixture } from './scope-host-suite.js';
4
+ /**
5
+ * #2555: a staff restore or import on a host the control plane runs (`restoreScope`, `importScope`)
6
+ * judges the tenant's roles as the directory stores them NOW against the dump's seats and role rows
7
+ * (`dumpRoleRefusal`), before anything is replaced. A dump holding a seat in a role the directory
8
+ * has since made system-held is refused, with nothing loaded and no scope created; the twin without
9
+ * the seat is admitted. A change the rule admits leaves the dump's seats as they were: nothing is
10
+ * revoked. The refusal is one fixed sentence naming the role key only.
11
+ */
12
+ export declare function restoreRoleContractSuite(adapterName: string, makeFixture: () => Promise<ScopeHostFixture & {
13
+ roleState(tenantId: TenantId, scopeId: ScopeId): Promise<RoleState>;
14
+ /** Whether this host's dump stores the tenant's role rows (a scope that reads its roles locally). */
15
+ dumpCarriesRoles: boolean;
16
+ }>): void;
17
+ //# sourceMappingURL=restore-role-suite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restore-role-suite.d.ts","sourceRoot":"","sources":["../src/restore-role-suite.ts"],"names":[],"mappings":"AACA,OAAO,EAAuE,KAAK,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAC3I,OAAO,EAAQ,KAAK,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CACtC,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CACxB,gBAAgB,GAAG;IACjB,SAAS,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,CAAC;IACpE,qGAAqG;IACrG,gBAAgB,EAAE,OAAO,CAAC;CAC3B,CACF,GACA,IAAI,CAuGN"}
@@ -0,0 +1,107 @@
1
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2
+ import { platformActorId, principalId, scopeId, tenantId } from '@substrat-run/contracts';
3
+ import { ulid } from '@substrat-run/kernel';
4
+ /**
5
+ * #2555: a staff restore or import on a host the control plane runs (`restoreScope`, `importScope`)
6
+ * judges the tenant's roles as the directory stores them NOW against the dump's seats and role rows
7
+ * (`dumpRoleRefusal`), before anything is replaced. A dump holding a seat in a role the directory
8
+ * has since made system-held is refused, with nothing loaded and no scope created; the twin without
9
+ * the seat is admitted. A change the rule admits leaves the dump's seats as they were: nothing is
10
+ * revoked. The refusal is one fixed sentence naming the role key only.
11
+ */
12
+ export function restoreRoleContractSuite(adapterName, makeFixture) {
13
+ describe(`a restore judges the directory's roles against the dump's seats (#2555): ${adapterName}`, () => {
14
+ let fixture;
15
+ const staff = platformActorId.parse(ulid());
16
+ const t = tenantId.parse(ulid());
17
+ const alice = principalId.parse(ulid());
18
+ const USE = 'rrs:use';
19
+ const RUN = 'rrs:run';
20
+ const undefinedInDump = (key) => `restore refused: the dump seats role '${key}' but carries no definition of it, so its seats cannot be told from a person's, ` +
21
+ `and the tenant now defines the role for a system principal (K-55); nothing was loaded`;
22
+ /** The refusal of a seat in a role since made system-held: which sentence depends on whether the dump stores the role. */
23
+ const refusalFor = (key) => fixture.dumpCarriesRoles
24
+ ? `restore refused: role '${key}', which the dump seats or defines, cannot be applied as the tenant defines it now (K-55); nothing was loaded`
25
+ : undefinedInDump(key);
26
+ const scope = async () => {
27
+ const id = scopeId.parse(ulid());
28
+ await fixture.host.provisionScope(staff, { tenantId: t, scopeId: id, vertical: 'restore-roles' });
29
+ await fixture.host.admin.activateScope(staff, t, id);
30
+ return id;
31
+ };
32
+ const seat = (roleKey, at) => ({ principalId: alice, roleKey, node: { tenantId: t, scopeId: at } });
33
+ const refusalOf = (p) => p.then(() => undefined, (e) => e);
34
+ beforeAll(async () => {
35
+ fixture = await makeFixture();
36
+ await fixture.host.admin.createTenant(staff, { id: t, slug: `restore-roles-${t.slice(-8).toLowerCase()}`, name: 'Restore roles' });
37
+ });
38
+ afterAll(async () => fixture?.cleanup());
39
+ it('a dump seated in a role since made system-held is refused: nothing loaded, no scope created', async () => {
40
+ const s = await scope();
41
+ await fixture.host.admin.defineRole(staff, t, { key: 'member', permissions: [USE], source: 'vertical' });
42
+ await fixture.host.admin.assignRole(staff, seat('member', s));
43
+ const dump = await fixture.host.admin.exportScope(staff, t, s);
44
+ // The seat is taken back, so the directory may now make the role system-held.
45
+ await fixture.host.admin.unassignRole(staff, seat('member', s));
46
+ await fixture.host.admin.defineRole(staff, t, { key: 'member', permissions: [RUN], source: 'vertical', holder: 'system' });
47
+ const restored = await refusalOf(fixture.host.restoreScope(staff, t, s, dump));
48
+ expect(restored?.message).toBe(refusalFor('member'));
49
+ // The scope is as it was: the seat the dump holds did not come back.
50
+ expect((await fixture.roleState(t, s)).seated).toEqual([]);
51
+ const fresh = scopeId.parse(ulid());
52
+ const imported = await refusalOf(fixture.host.importScope(staff, { tenantId: t, scopeId: fresh, vertical: 'restore-roles' }, dump));
53
+ expect(imported?.message).toBe(refusalFor('member'));
54
+ expect((await fixture.host.admin.getScopeRecord(staff, t, fresh)) ?? null).toBeNull();
55
+ });
56
+ it('the refusal names the role key only: no principal, no tenant', async () => {
57
+ const s = await scope();
58
+ await fixture.host.admin.defineRole(staff, t, { key: 'named', permissions: [USE], source: 'vertical' });
59
+ await fixture.host.admin.assignRole(staff, seat('named', s));
60
+ const dump = await fixture.host.admin.exportScope(staff, t, s);
61
+ await fixture.host.admin.unassignRole(staff, seat('named', s));
62
+ await fixture.host.admin.defineRole(staff, t, { key: 'named', permissions: [RUN], source: 'vertical', holder: 'system' });
63
+ const message = (await refusalOf(fixture.host.restoreScope(staff, t, s, dump)))?.message ?? '';
64
+ expect(message).toBe(refusalFor('named'));
65
+ for (const id of [alice, t, s])
66
+ expect(message).not.toContain(id);
67
+ });
68
+ it('twin: the same change with no seat in the dump is admitted, by restore and by import', async () => {
69
+ const s = await scope();
70
+ await fixture.host.admin.defineRole(staff, t, { key: 'idle', permissions: [USE], source: 'vertical' });
71
+ const dump = await fixture.host.admin.exportScope(staff, t, s);
72
+ await fixture.host.admin.defineRole(staff, t, { key: 'idle', permissions: [RUN], source: 'vertical', holder: 'system' });
73
+ await fixture.host.restoreScope(staff, t, s, dump);
74
+ const fresh = scopeId.parse(ulid());
75
+ await fixture.host.importScope(staff, { tenantId: t, scopeId: fresh, vertical: 'restore-roles' }, dump);
76
+ expect((await fixture.host.admin.getScopeRecord(staff, t, fresh))?.status).toBe('active');
77
+ });
78
+ it('a person’s role the directory changed since is admitted, and the dump’s seat in it is kept', async () => {
79
+ const s = await scope();
80
+ await fixture.host.admin.defineRole(staff, t, { key: 'editor', permissions: [USE], source: 'vertical' });
81
+ await fixture.host.admin.assignRole(staff, seat('editor', s));
82
+ const dump = await fixture.host.admin.exportScope(staff, t, s);
83
+ await fixture.host.admin.unassignRole(staff, seat('editor', s));
84
+ await fixture.host.admin.defineRole(staff, t, { key: 'editor', permissions: [USE, 'rrs:edit'], source: 'vertical' });
85
+ await fixture.host.restoreScope(staff, t, s, dump);
86
+ expect((await fixture.roleState(t, s)).seated).toEqual(['editor']);
87
+ });
88
+ // Where the dump carries no role rows, this is a scope that cannot be restored from its own export (K-55).
89
+ it('a seat in a role stored system-held when the dump was taken: admitted where the dump stores the role, else judged a person’s', async () => {
90
+ const s = await scope();
91
+ await fixture.host.admin.defineRole(staff, t, { key: 'runner', permissions: [RUN], source: 'vertical', holder: 'system' });
92
+ // The platform's door seats a system-held role made only of keys this host does not reserve.
93
+ await fixture.host.admin.assignRole(staff, seat('runner', s));
94
+ const dump = await fixture.host.admin.exportScope(staff, t, s);
95
+ const restored = await refusalOf(fixture.host.restoreScope(staff, t, s, dump));
96
+ if (fixture.dumpCarriesRoles) {
97
+ expect(restored).toBeUndefined();
98
+ }
99
+ else {
100
+ // A dump with no role rows says nothing about the definition its seat was made under.
101
+ expect(restored?.message).toBe(undefinedInDump('runner'));
102
+ }
103
+ expect((await fixture.roleState(t, s)).seated).toEqual(['runner']);
104
+ });
105
+ });
106
+ }
107
+ //# sourceMappingURL=restore-role-suite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"restore-role-suite.js","sourceRoot":"","sources":["../src/restore-role-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,EAuBL,KAAK,SAAS,EAuBf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAoBL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAe9B,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;AAED,oGAAoG;AACpG,wBAAgB,+BAA+B,CAC7C,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG;IAC5C,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CAChF,CAAC,GACD,IAAI,CA+GN;AAuCD;;;;;;;;;;;;;;;;;;;;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,CAyjYN"}
1
+ {"version":3,"file":"scope-host-suite.d.ts","sourceRoot":"","sources":["../src/scope-host-suite.ts"],"names":[],"mappings":"AAEA,OAAO,EAuBL,KAAK,SAAS,EAuBf,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAqBL,KAAK,SAAS,EACf,MAAM,sBAAsB,CAAC;AAe9B,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;AAED,oGAAoG;AACpG,wBAAgB,+BAA+B,CAC7C,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,OAAO,CAAC,gBAAgB,GAAG;IAC5C,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC;CAChF,CAAC,GACD,IAAI,CA+GN;AAuCD;;;;;;;;;;;;;;;;;;;;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,CAsuYN"}
@@ -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, moduleId, SCOPE_GATE_REASONS, eventId, instant, moduleManifest, orgId, AUTO_ADMISSION_NOTE, permissionKey, platformActorId, platformRequestId, principalId, scopeId, tenantId, SCOPE_QUERY_ROW_MAX, LIST_PAGE_MAX, KERNEL_AUTHORED_EVENT_TYPES, EMAIL_SENT, PLATFORM_REQUEST_ENTITY_TYPE, SEND_EMAIL_KIND, isUndecodablePlatformRequest, platformRequestEntry, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, unheldMigrationDigest, withUpstreamReference, } from '@substrat-run/contracts';
4
- import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, withheldOpsFailure, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, promoteReviewDigest, promoteReviewRefusalOf, rebindReviewRefusalOf, createUlid, ulid, WIRE_AUTH_FILTER_TEXT_MAX, } from '@substrat-run/kernel';
4
+ import { INERT_SCOPE_REASON, BACKFILL_MOVE_ID, SCOPE_COPY_LEASE_MS, isSearchIndexTable, platformIntentFailureMessage, withheldOpsFailure, withheldStaffFailure, REDACTED_DELIVERY_NOTE, REDACTED_FAILURE_NOTE, REDACTED_INTENT_MARKER, runPlatformSweep, startPlatformSweeper, promoteReviewDigest, promoteReviewRefusalOf, rebindReviewRefusalOf, createUlid, ulid, WIRE_AUTH_FILTER_TEXT_MAX, } from '@substrat-run/kernel';
5
5
  import { billedMod, contractTestBareOps, contractTestInitialModules, gateModManifest, lateMod, TEST_LIFECYCLE, testModManifest, victimModManifest, } from './modules.js';
6
6
  import { testOperations } from './test-operations.js';
7
7
  import { derivedDigestOf, heldMigrations } from './held-migrations.js';
@@ -2368,6 +2368,21 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
2368
2368
  // The spine: present and flagged system, so the UI can group it apart.
2369
2369
  expect(byName.get('_substrat_outbox')?.system).toBe(true);
2370
2370
  });
2371
+ it('counts what is in force apart from what a table keeps as tombstones (#2531)', async () => {
2372
+ const key = `introspect-${ulid().toLowerCase()}`;
2373
+ await host.admin.defineRole(staff, t1, { key, permissions: [permissionKey.parse('thing:read')], source: 'vertical' });
2374
+ const seat = { principalId: principalId.parse(ulid()), roleKey: key, node: { tenantId: t1, scopeId: s1 } };
2375
+ const tuples = async () => (await host.admin.listScopeTables(staff, t1, s1)).find((t) => t.name === '_substrat_tuples');
2376
+ const before = await tuples();
2377
+ expect(before.liveRowCount).toBeDefined();
2378
+ await host.admin.assignRole(staff, seat);
2379
+ expect(await tuples()).toMatchObject({ rowCount: before.rowCount + 1, liveRowCount: before.liveRowCount + 1 });
2380
+ // A revoke tombstones the seat: the row is kept, and it is no longer in force.
2381
+ await host.admin.unassignRole(staff, seat);
2382
+ expect(await tuples()).toMatchObject({ rowCount: before.rowCount + 1, liveRowCount: before.liveRowCount });
2383
+ // A table that keeps no tombstones answers no live count.
2384
+ expect((await host.admin.listScopeTables(staff, t1, s1)).find((t) => t.name === '_substrat_outbox')).not.toHaveProperty('liveRowCount');
2385
+ });
2371
2386
  it('reads a bounded page of a table', async () => {
2372
2387
  const stub = await host.getScope(alice, t1, s1);
2373
2388
  await stub.invoke('test/write-marker', { v: 'page-row' });
@@ -3126,6 +3141,29 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
3126
3141
  expect(await rowsOf(ops.elsewhere)).toEqual(before.elsewhere);
3127
3142
  await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
3128
3143
  });
3144
+ it("#2556: staff's row alone, for a withheld pass that left a tombstoned intent pending, is redacted too", async () => {
3145
+ const kind = `connector:erasure-${ulid()}`;
3146
+ const erased = dataSubjectId.parse(ulid());
3147
+ const spared = dataSubjectId.parse(ulid());
3148
+ const linked = await routeIntent(kind, erased, 'Anna Ek');
3149
+ const unlinked = await routeIntent(kind, spared, 'Bo Lund');
3150
+ // As the drain files it (`withheldStaffFailure`): no tenant, the drained scope, marker first.
3151
+ const transient = (operation, id, error) => host.admin.recordOpsFailure({
3152
+ actor: staff,
3153
+ ...withheldStaffFailure({ operation, stage: 'transient', scopeId: s1 }, `${error}; reference = up${ulid().toLowerCase()}`, ulid(), platformIntentFailureMessage(id, 'pending (the call did not complete at its deployment): ')),
3154
+ });
3155
+ const ops = { linked: op('transient-linked'), unlinked: op('transient-unlinked') };
3156
+ await transient(ops.linked, linked, 'HTTP 500: Anna Ek is not on file');
3157
+ await transient(ops.unlinked, unlinked, 'HTTP 500: Bo Lund is not on file');
3158
+ const rowsOf = async (operation) => (await host.admin.listOpsFailures(staff, { operation })).map((r) => [r.tenantId, r.message]);
3159
+ const before = await rowsOf(ops.unlinked);
3160
+ expect((await rowsOf(ops.linked))[0][1]).toMatch(/^\[upstream reference = up[0-9a-z]+\] platform intent .* pending \(the call did not complete at its deployment\): HTTP 500: /);
3161
+ await host.admin.shredSubject(staff, t1, s1, erased);
3162
+ expect(await rowsOf(ops.linked)).toEqual([[null, REDACTED_FAILURE_NOTE]]);
3163
+ expect((await issueOf(ops.linked)).lastMessage).toBe(REDACTED_FAILURE_NOTE);
3164
+ expect(await rowsOf(ops.unlinked)).toEqual(before);
3165
+ await host.settlePlatformRequest(t1, s1, unlinked, { status: 'done' });
3166
+ });
3129
3167
  /** Whose an exemplar is, as the directory stores it — not on the issue read shape. */
3130
3168
  const exemplarOwnerOf = async (operation) => {
3131
3169
  const dump = await host.admin.exportDirectory(staff);
@@ -7440,7 +7478,8 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
7440
7478
  await host.admin.setVerticalListed(staff, slug, true);
7441
7479
  await host.admin.promoteVersion(staff, slug, 'prod', first);
7442
7480
  expect((await host.admin.getScopeRecord(staff, t2, install))?.verticalVersionId).toBe(next);
7443
- });
7481
+ // Seven scopes, a snapshot and three promotions: about 3 s alone, past the 5 s default under CI load (#2576).
7482
+ }, 30_000);
7444
7483
  it('publish refuses an auto-admitted prod version until a staff admit vouches for it', async () => {
7445
7484
  // Listing is the moment OTHER tenants start trusting this code, so the version
7446
7485
  // they would install needs a recorded human decision — the auto-admission note
@@ -9709,8 +9748,22 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
9709
9748
  const at = (time) => `2026-10-24T${time}.000Z`;
9710
9749
  /** The whole fleet, one full page: every read names its reach and its limit. */
9711
9750
  const fleet = { reach: { fleet: true }, limit: LIST_PAGE_MAX };
9712
- /** Record `rec` for the script `<vertical>-<script>`. */
9713
- const put = (vertical, script, versionId, rec, time) => host.admin.recordWireAuth(staff, { deploymentRef: `${vertical}-${script}`, verticalSlug: vertical, versionId, record: rec, recordedAt: at(time) });
9751
+ /** The verticals this block registered: a record is written only beside its vertical's registration. */
9752
+ const registered = new Set();
9753
+ const register = async (slug, ownerTenant) => {
9754
+ await host.admin.registerVertical(staff, { slug, name: slug, source: 'cli', ...(ownerTenant ? { ownerTenant } : {}) });
9755
+ registered.add(slug);
9756
+ };
9757
+ const forget = async (slug) => {
9758
+ await host.admin.deleteVertical(staff, slug);
9759
+ registered.delete(slug);
9760
+ };
9761
+ /** Record `rec` for the script `<vertical>-<script>`, registering the vertical (platform-owned) first when no test did. */
9762
+ const put = async (vertical, script, versionId, rec, time) => {
9763
+ if (!registered.has(vertical))
9764
+ await register(vertical);
9765
+ await host.admin.recordWireAuth(staff, { deploymentRef: `${vertical}-${script}`, verticalSlug: vertical, versionId, record: rec, recordedAt: at(time) });
9766
+ };
9714
9767
  it('keeps one record per deployment, the last upload’s, and lists one vertical’s by deployment', async () => {
9715
9768
  const [a, b] = [slugOf('wire-a'), slugOf('wire-b')];
9716
9769
  await put(a, 'serving', 'v1', record({ audience: `${a}-serving` }), '10:00:00');
@@ -9825,26 +9878,25 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
9825
9878
  const [owner, other] = [tenantId.parse(ulid()), tenantId.parse(ulid())];
9826
9879
  await host.admin.createTenant(staff, { id: owner, slug: `wire-own-${owner.slice(-6).toLowerCase()}`, name: 'Wire Owner' });
9827
9880
  await host.admin.createTenant(staff, { id: other, slug: `wire-oth-${other.slice(-6).toLowerCase()}`, name: 'Wire Other' });
9828
- const [mine, theirs, platform, unregistered] = [slugOf('wire-mine'), slugOf('wire-theirs'), slugOf('wire-plat'), slugOf('wire-unreg')];
9829
- await host.admin.registerVertical(staff, { slug: mine, name: 'Mine', source: 'cli', ownerTenant: owner });
9830
- await host.admin.registerVertical(staff, { slug: theirs, name: 'Theirs', source: 'cli', ownerTenant: other });
9831
- await host.admin.registerVertical(staff, { slug: platform, name: 'Platform', source: 'cli' });
9832
- for (const [i, slug] of [mine, theirs, platform, unregistered].entries()) {
9881
+ const [mine, theirs, platform] = [slugOf('wire-mine'), slugOf('wire-theirs'), slugOf('wire-plat')];
9882
+ await register(mine, owner);
9883
+ await register(theirs, other);
9884
+ await register(platform);
9885
+ for (const [i, slug] of [mine, theirs, platform].entries()) {
9833
9886
  await put(slug, 'serving', null, record(), `16:00:0${i}`);
9834
9887
  await put(slug, 'archive', 'v1', record(), `16:00:1${i}`);
9835
9888
  }
9836
9889
  const refs = async (reach, verticalSlug) => (await host.admin.listWireAuth(staff, { reach, limit: LIST_PAGE_MAX, ...(verticalSlug ? { verticalSlug } : {}) }))
9837
9890
  .map((r) => r.deploymentRef)
9838
- .filter((ref) => [mine, theirs, platform, unregistered].some((slug) => ref.startsWith(`${slug}-`)));
9891
+ .filter((ref) => [mine, theirs, platform].some((slug) => ref.startsWith(`${slug}-`)));
9839
9892
  expect(await refs({ tenantId: owner })).toEqual([`${mine}-archive`, `${mine}-serving`]);
9840
9893
  expect(await refs({ tenantId: other })).toEqual([`${theirs}-archive`, `${theirs}-serving`]);
9841
9894
  // Naming another tenant's vertical inside one tenant's reach reaches nothing, not that vertical.
9842
9895
  expect(await refs({ tenantId: owner }, theirs)).toEqual([]);
9843
9896
  expect(await refs({ tenantId: owner }, mine)).toEqual([`${mine}-archive`, `${mine}-serving`]);
9844
- // A tenant that owns nothing reaches nothing; a vertical no tenant owns, or the registry does not
9845
- // know, is reached by the fleet alone.
9897
+ // A tenant that owns nothing reaches nothing; a vertical no tenant owns is reached by the fleet alone.
9846
9898
  expect(await refs({ tenantId: tenantId.parse(ulid()) })).toEqual([]);
9847
- for (const slug of [mine, theirs, platform, unregistered]) {
9899
+ for (const slug of [mine, theirs, platform]) {
9848
9900
  expect(await refs({ fleet: true }, slug)).toEqual([`${slug}-archive`, `${slug}-serving`]);
9849
9901
  }
9850
9902
  // A page inside the reach pages over the tenant's records only.
@@ -9891,7 +9943,7 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
9891
9943
  const owner = tenantId.parse(ulid());
9892
9944
  await host.admin.createTenant(staff, { id: owner, slug: `wire-log-${owner.slice(-6).toLowerCase()}`, name: 'Wire Log' });
9893
9945
  const mine = slugOf('wire-logged');
9894
- await host.admin.registerVertical(staff, { slug: mine, name: 'Logged', source: 'cli', ownerTenant: owner });
9946
+ await register(mine, owner);
9895
9947
  await put(mine, 'serving', null, record(), '17:00:00');
9896
9948
  // The reach given last, as a caller may spell it: the log still names it first.
9897
9949
  await host.admin.listWireAuth(staff, { limit: 5, verticalSlug: mine, reach: { tenantId: owner } });
@@ -9919,30 +9971,119 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
9919
9971
  await host.admin.createTenant(staff, { id: second, slug: `wire-snd-${second.slice(-6).toLowerCase()}`, name: 'Wire Second' });
9920
9972
  const slug = slugOf('wire-owned');
9921
9973
  const refsOf = async (owner) => (await host.admin.listWireAuth(staff, { reach: { tenantId: owner }, limit: LIST_PAGE_MAX, verticalSlug: slug })).map((r) => r.deploymentRef);
9922
- // A record written while no tenant owned the slug is read by whoever owns it when the read runs.
9974
+ // The owner reads what was recorded while it held the slug.
9975
+ await register(slug, first);
9923
9976
  await put(slug, 'archive', 'v0', record(), '18:00:00');
9924
- expect(await refsOf(first)).toEqual([]);
9925
- await host.admin.registerVertical(staff, { slug, name: 'Owned', source: 'cli', ownerTenant: first });
9926
9977
  expect(await refsOf(first)).toEqual([`${slug}-archive`]);
9927
9978
  await put(slug, 'serving', 'v1', record(), '18:00:01');
9928
9979
  expect(await refsOf(first)).toEqual([`${slug}-archive`, `${slug}-serving`]);
9929
9980
  // An owner changes by deleting the vertical, which takes its records, and registering it again:
9930
9981
  // the tenant that held it reads nothing after, the new owner reads only what was recorded since.
9931
- await host.admin.deleteVertical(staff, slug);
9932
- await host.admin.registerVertical(staff, { slug, name: 'Owned', source: 'cli', ownerTenant: second });
9982
+ await forget(slug);
9983
+ await register(slug, second);
9933
9984
  expect(await refsOf(first)).toEqual([]);
9934
9985
  expect(await refsOf(second)).toEqual([]);
9935
9986
  await put(slug, 'serving', 'v2', record(), '18:00:02');
9936
9987
  expect(await refsOf(first)).toEqual([]);
9937
9988
  expect(await refsOf(second)).toEqual([`${slug}-serving`]);
9938
9989
  });
9990
+ it('writes no record, and no admin row, for a vertical the registry does not hold', async () => {
9991
+ const never = slugOf('wire-unreg');
9992
+ const gone = slugOf('wire-gone');
9993
+ await put(gone, 'serving', 'v1', record(), '20:00:00');
9994
+ await forget(gone);
9995
+ for (const slug of [never, gone]) {
9996
+ await expectRefusal(host.admin.recordWireAuth(staff, { deploymentRef: `${slug}-serving`, verticalSlug: slug, versionId: 'v2', record: record(), recordedAt: at('20:00:01') }), 'not_found');
9997
+ expect(await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: slug })).toEqual([]);
9998
+ }
9999
+ const rows = (await host.admin.auditLog(staff, { action: 'recordWireAuth' })).filter((r) => r.vertical === never || r.vertical === gone);
10000
+ // Only the one write made while `gone` was registered.
10001
+ expect(rows.map((r) => r.after.versionId)).toEqual(['v1']);
10002
+ // Registered again, the slug's records start empty: nothing written while unregistered survives.
10003
+ await register(gone);
10004
+ expect(await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: gone })).toEqual([]);
10005
+ });
10006
+ it('keeps a walk’s hold on a record, by binding name, and refuses a hold it cannot read', async () => {
10007
+ const a = slugOf('wire-hold');
10008
+ const hold = { reason: 'verify', bindings: ['SCOPE', 'SUBSTRAT_WIRE_STATE'], at: at('22:00:00') };
10009
+ await put(a, 'archive', 'v1', { ...record(), walkHold: hold }, '22:00:00');
10010
+ expect((await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a }))[0].record.walkHold).toEqual(hold);
10011
+ // The next upload of the script writes a record without it.
10012
+ await put(a, 'archive', 'v1', record(), '22:00:01');
10013
+ expect((await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a }))[0].record.walkHold).toBeUndefined();
10014
+ const good = { deploymentRef: `${a}-archive`, verticalSlug: a, versionId: 'v1', recordedAt: at('22:00:02') };
10015
+ for (const walkHold of [
10016
+ { ...hold, reason: 'because' },
10017
+ { ...hold, at: 'yesterday' },
10018
+ { ...hold, bindings: [''] },
10019
+ { ...hold, bindings: Array.from({ length: 65 }, (_, i) => `B${i}`) },
10020
+ { ...hold, value: 'not a field' },
10021
+ // The count of names past the cap is a positive whole number when given.
10022
+ { ...hold, more: 0 },
10023
+ { ...hold, more: 1.5 },
10024
+ ]) {
10025
+ await expectRefusal(host.admin.recordWireAuth(staff, { ...good, record: { ...record(), walkHold } }), 'validation_failed');
10026
+ }
10027
+ expect((await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a }))[0].record.walkHold).toBeUndefined();
10028
+ // A hold of more names than it keeps: the first ones, and how many more.
10029
+ const many = { ...hold, bindings: Array.from({ length: 64 }, (_, i) => `B${i}`), more: 6 };
10030
+ await put(a, 'archive', 'v1', { ...record(), walkHold: many }, '22:00:02');
10031
+ expect((await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a }))[0].record.walkHold).toEqual(many);
10032
+ await put(a, 'archive', 'v1', record(), '22:00:02');
10033
+ // `unverified`, the walk's record of a script it could not confirm: lists claimed by none, and
10034
+ // only beside a hold. Without the hold, or beside lists, it is refused.
10035
+ const unverified = withheld('unverified');
10036
+ await expectRefusal(host.admin.recordWireAuth(staff, { ...good, record: unverified }), 'validation_failed');
10037
+ await expectRefusal(host.admin.recordWireAuth(staff, { ...good, record: { ...record(), withheld: 'unverified', walkHold: hold } }), 'validation_failed');
10038
+ await put(a, 'archive', 'v1', { ...unverified, walkHold: hold }, '22:00:03');
10039
+ expect((await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a }))[0].record).toMatchObject({ withheld: 'unverified', state: 'legacy-only', routerKids: [], walkHold: hold });
10040
+ });
10041
+ it('audits a pass of the fleet walk as walkWireAuth, naming scripts and reasons, never a value', async () => {
10042
+ const entry = {
10043
+ apply: true,
10044
+ confirm: false,
10045
+ from: null,
10046
+ next: 'acme/crm|01HZZZZZZZZZZZZZZZZZZZZZZZ',
10047
+ walked: 3,
10048
+ updated: ['acme-crm'],
10049
+ held: ['acme-crm-01hzzzzzzzzzzzzzzzzzzzzzzz'],
10050
+ released: [],
10051
+ skipped: [{ deploymentRef: 'acme-crm-01hyyyyyyyyyyyyyyyyyyyyyyy', reason: 'no-entry' }],
10052
+ failed: [{ deploymentRef: 'acme-crm-01hzzzzzzzzzzzzzzzzzzzzzzz', reason: 'verify' }],
10053
+ };
10054
+ await host.admin.recordWireWalk(staff, entry);
10055
+ const rows = (await host.admin.auditLog(staff, { action: 'walkWireAuth' })).filter((r) => r.after.next === entry.next);
10056
+ expect(rows).toHaveLength(1);
10057
+ expect(rows[0].actor).toBe(staff);
10058
+ expect(rows[0].after).toEqual(entry);
10059
+ });
10060
+ it('restores the owners and the records together, from one dump', async () => {
10061
+ const [first, second] = [tenantId.parse(ulid()), tenantId.parse(ulid())];
10062
+ await host.admin.createTenant(staff, { id: first, slug: `wire-rst-${first.slice(-6).toLowerCase()}`, name: 'Wire Restore' });
10063
+ const slug = slugOf('wire-restore');
10064
+ await register(slug, first);
10065
+ await put(slug, 'serving', 'v1', record(), '21:00:00');
10066
+ const backup = await host.admin.exportDirectory(staff);
10067
+ // Diverge past the copy: the vertical deleted (its records with it) and taken by another tenant.
10068
+ await host.admin.createTenant(staff, { id: second, slug: `wire-rs2-${second.slice(-6).toLowerCase()}`, name: 'Wire Restore 2' });
10069
+ await forget(slug);
10070
+ await register(slug, second);
10071
+ await put(slug, 'archive', 'v2', record(), '21:00:01');
10072
+ await host.admin.restoreDirectory(staff, backup);
10073
+ registered.add(slug);
10074
+ // The copy's owner reads the copy's records, and nothing of the divergence came back with it.
10075
+ const reads = async (reach) => (await host.admin.listWireAuth(staff, { reach, limit: LIST_PAGE_MAX, verticalSlug: slug })).map((r) => [r.deploymentRef, r.versionId]);
10076
+ expect(await reads({ tenantId: first })).toEqual([[`${slug}-serving`, 'v1']]);
10077
+ expect(await reads({ tenantId: second })).toEqual([]);
10078
+ expect(await reads({ fleet: true })).toEqual([[`${slug}-serving`, 'v1']]);
10079
+ });
9939
10080
  it('forgets a vertical’s records with the vertical', async () => {
9940
10081
  const a = slugOf('wire-del');
9941
- await host.admin.registerVertical(staff, { slug: a, name: 'Wire record', source: 'cli' });
10082
+ await register(a);
9942
10083
  await put(a, 'serving', null, record(), '13:00:00');
9943
10084
  const kept = slugOf('wire-kept');
9944
10085
  await put(kept, 'serving', null, record(), '13:00:01');
9945
- await host.admin.deleteVertical(staff, a);
10086
+ await forget(a);
9946
10087
  expect(await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: a })).toEqual([]);
9947
10088
  // Another vertical's record is not touched.
9948
10089
  expect(await host.admin.listWireAuth(staff, { ...fleet, verticalSlug: kept })).toHaveLength(1);
@@ -10638,6 +10779,16 @@ export function scopeHostContractSuite(adapterName, makeFixture, opts = {}) {
10638
10779
  name: 'Impostor Co',
10639
10780
  }), 'conflict', /already taken/);
10640
10781
  });
10782
+ it('words the tenant slug refusal without the tenant that holds the slug (#2556)', async () => {
10783
+ const holder = tenantId.parse(ulid());
10784
+ const slug = `held-${holder.toLowerCase()}`;
10785
+ await host.admin.createTenant(staff, { id: holder, slug, name: 'Holder Co' });
10786
+ const taken = await host.admin
10787
+ .createTenant(staff, { id: tenantId.parse(ulid()), slug, name: 'Impostor Co' })
10788
+ .then(() => null, (e) => e);
10789
+ expect(taken?.message).toBe(`tenant slug '${slug}' is already taken (slugs are unique)`);
10790
+ expect(taken?.message).not.toContain(holder);
10791
+ });
10641
10792
  it('enumerates the scopes under a tenant, and filters by status (§4.5)', async () => {
10642
10793
  const all = await host.admin.listScopes(staff, { tenantId: t5 });
10643
10794
  expect(all.length).toBeGreaterThanOrEqual(2);