@stigmer/server 3.38.3 → 3.39.1-dev.20260928194002

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/domain/agent/controller.d.ts +2 -2
  2. package/dist/domain/agent/controller.d.ts.map +1 -1
  3. package/dist/domain/agent/controller.js +7 -3
  4. package/dist/domain/agent/controller.js.map +1 -1
  5. package/dist/domain/agentchannel/controller.d.ts +2 -2
  6. package/dist/domain/agentchannel/controller.d.ts.map +1 -1
  7. package/dist/domain/agentchannel/controller.js +10 -5
  8. package/dist/domain/agentchannel/controller.js.map +1 -1
  9. package/dist/domain/agentinstance/controller.d.ts +2 -2
  10. package/dist/domain/agentinstance/controller.d.ts.map +1 -1
  11. package/dist/domain/agentinstance/controller.js +7 -3
  12. package/dist/domain/agentinstance/controller.js.map +1 -1
  13. package/dist/domain/agentinstance/steps.d.ts +3 -1
  14. package/dist/domain/agentinstance/steps.d.ts.map +1 -1
  15. package/dist/domain/agentinstance/steps.js +12 -6
  16. package/dist/domain/agentinstance/steps.js.map +1 -1
  17. package/dist/domain/agentshare/controller.d.ts +2 -2
  18. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  19. package/dist/domain/agentshare/controller.js +7 -3
  20. package/dist/domain/agentshare/controller.js.map +1 -1
  21. package/dist/domain/organization/controller.d.ts.map +1 -1
  22. package/dist/domain/organization/controller.js +33 -14
  23. package/dist/domain/organization/controller.js.map +1 -1
  24. package/dist/domain/organization/slug-ledger.d.ts +86 -0
  25. package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
  26. package/dist/domain/organization/slug-ledger.js +128 -0
  27. package/dist/domain/organization/slug-ledger.js.map +1 -0
  28. package/dist/domain/organization/steps.d.ts +12 -3
  29. package/dist/domain/organization/steps.d.ts.map +1 -1
  30. package/dist/domain/organization/steps.js +28 -6
  31. package/dist/domain/organization/steps.js.map +1 -1
  32. package/dist/domain/plugin/controller.d.ts.map +1 -1
  33. package/dist/domain/plugin/controller.js +10 -5
  34. package/dist/domain/plugin/controller.js.map +1 -1
  35. package/dist/domain/skill/controller.d.ts.map +1 -1
  36. package/dist/domain/skill/controller.js +7 -3
  37. package/dist/domain/skill/controller.js.map +1 -1
  38. package/dist/domain/workflow/controller.js +6 -3
  39. package/dist/domain/workflow/controller.js.map +1 -1
  40. package/dist/domain/workflowinstance/controller.d.ts +2 -2
  41. package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
  42. package/dist/domain/workflowinstance/controller.js +10 -5
  43. package/dist/domain/workflowinstance/controller.js.map +1 -1
  44. package/dist/domain/workflowinstance/steps.d.ts +5 -3
  45. package/dist/domain/workflowinstance/steps.d.ts.map +1 -1
  46. package/dist/domain/workflowinstance/steps.js +14 -9
  47. package/dist/domain/workflowinstance/steps.js.map +1 -1
  48. package/dist/extensions/gate-slots.d.ts +7 -5
  49. package/dist/extensions/gate-slots.d.ts.map +1 -1
  50. package/dist/extensions/gate-slots.js.map +1 -1
  51. package/dist/index.d.ts +3 -1
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +2 -1
  54. package/dist/index.js.map +1 -1
  55. package/dist/pipeline/errors.d.ts +8 -0
  56. package/dist/pipeline/errors.d.ts.map +1 -1
  57. package/dist/pipeline/errors.js +19 -0
  58. package/dist/pipeline/errors.js.map +1 -1
  59. package/dist/store/interface.d.ts +67 -0
  60. package/dist/store/interface.d.ts.map +1 -1
  61. package/dist/store/interface.js.map +1 -1
  62. package/dist/store/organization-slug-history.d.ts +71 -0
  63. package/dist/store/organization-slug-history.d.ts.map +1 -0
  64. package/dist/store/organization-slug-history.js +88 -0
  65. package/dist/store/organization-slug-history.js.map +1 -0
  66. package/dist/store/postgres/migrations.d.ts +3 -1
  67. package/dist/store/postgres/migrations.d.ts.map +1 -1
  68. package/dist/store/postgres/migrations.js +69 -1
  69. package/dist/store/postgres/migrations.js.map +1 -1
  70. package/dist/store/postgres/store.d.ts +2 -1
  71. package/dist/store/postgres/store.d.ts.map +1 -1
  72. package/dist/store/postgres/store.js +49 -0
  73. package/dist/store/postgres/store.js.map +1 -1
  74. package/dist/store/sqlite/migrations.d.ts +3 -1
  75. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  76. package/dist/store/sqlite/migrations.js +54 -1
  77. package/dist/store/sqlite/migrations.js.map +1 -1
  78. package/dist/store/sqlite/store.d.ts +2 -1
  79. package/dist/store/sqlite/store.d.ts.map +1 -1
  80. package/dist/store/sqlite/store.js +55 -0
  81. package/dist/store/sqlite/store.js.map +1 -1
  82. package/package.json +6 -6
  83. package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
  84. package/src/domain/agent/controller.ts +9 -5
  85. package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
  86. package/src/domain/agentchannel/controller.ts +12 -7
  87. package/src/domain/agentinstance/__tests__/store-faults.test.ts +161 -0
  88. package/src/domain/agentinstance/controller.ts +9 -5
  89. package/src/domain/agentinstance/steps.ts +12 -6
  90. package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
  91. package/src/domain/agentshare/controller.ts +9 -5
  92. package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
  93. package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
  94. package/src/domain/organization/controller.ts +36 -14
  95. package/src/domain/organization/slug-ledger.ts +209 -0
  96. package/src/domain/organization/steps.ts +28 -6
  97. package/src/domain/plugin/__tests__/store-faults.test.ts +104 -0
  98. package/src/domain/plugin/controller.ts +10 -5
  99. package/src/domain/skill/__tests__/store-faults.test.ts +103 -0
  100. package/src/domain/skill/controller.ts +7 -3
  101. package/src/domain/workflow/__tests__/store-faults.test.ts +107 -0
  102. package/src/domain/workflow/controller.ts +6 -3
  103. package/src/domain/workflowinstance/__tests__/store-faults.test.ts +172 -0
  104. package/src/domain/workflowinstance/controller.ts +12 -7
  105. package/src/domain/workflowinstance/steps.ts +14 -10
  106. package/src/extensions/gate-slots.ts +7 -5
  107. package/src/index.ts +11 -0
  108. package/src/pipeline/__tests__/blind-not-found.test.ts +4 -24
  109. package/src/pipeline/errors.ts +23 -0
  110. package/src/store/__tests__/organization-slug-history.test.ts +109 -0
  111. package/src/store/__tests__/store-contract.ts +86 -1
  112. package/src/store/interface.ts +70 -0
  113. package/src/store/organization-slug-history.ts +159 -0
  114. package/src/store/postgres/__tests__/migrations.test.ts +138 -2
  115. package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
  116. package/src/store/postgres/migrations.ts +84 -1
  117. package/src/store/postgres/store.ts +84 -0
  118. package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
  119. package/src/store/sqlite/migrations.ts +69 -1
  120. package/src/store/sqlite/store.ts +84 -0
@@ -0,0 +1,386 @@
1
+ /**
2
+ * Pins the create side of "an organization's slug is its for good"
3
+ * (slug-ledger.ts) through a composed server in the trusted-local posture:
4
+ *
5
+ * - of two concurrent creates of one slug exactly one succeeds, and the
6
+ * other is refused as a duplicate with one owner row between them;
7
+ * - a create racing another create's claim, before that create's row
8
+ * exists, is refused as a duplicate, never as a deleted organization's;
9
+ * - a pre-side-effect gate's refusal leaves no claim, so the create
10
+ * succeeds once the gate lets it;
11
+ * - a create that fails after its row is stored keeps its slug;
12
+ * - an organization no ledger entry records (one an older binary created)
13
+ * is still refused as a duplicate by its row, and its delete retires
14
+ * the slug.
15
+ *
16
+ * And, over doubles, the release a failed create runs: it frees the claim
17
+ * only when the organization was never stored, and every fault it meets
18
+ * leaves the slug claimed.
19
+ *
20
+ * The delete side (the retire, its order and its faults) is
21
+ * organization-delete.test.ts's.
22
+ */
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import path from "node:path";
26
+
27
+ import { create } from "@bufbuild/protobuf";
28
+ import type { DescMessage } from "@bufbuild/protobuf";
29
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
30
+ import type { Client } from "@connectrpc/connect";
31
+ import { createGrpcTransport } from "@connectrpc/connect-node";
32
+ import {
33
+ afterAll,
34
+ beforeAll,
35
+ beforeEach,
36
+ describe,
37
+ expect,
38
+ it,
39
+ vi,
40
+ } from "vitest";
41
+
42
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
43
+ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
44
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
45
+ import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
46
+ import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
47
+
48
+ import { loadConfig } from "../../../boot/config.js";
49
+ import { composeServer } from "../../../boot/compose.js";
50
+ import type { ComposedServer } from "../../../boot/compose.js";
51
+ import { createLogger } from "../../../boot/logger.js";
52
+ import type { Logger } from "../../../boot/logger.js";
53
+ import type { GateSlotName } from "../../../extensions/gate-slots.js";
54
+ import type { ServerExtension } from "../../../extensions/registry.js";
55
+ import type { PipelineStep } from "../../../pipeline/pipeline.js";
56
+ import { testCallerIdentity } from "../../../pipeline/__tests__/support.js";
57
+ import { RequestContext } from "../../../pipeline/request-context.js";
58
+ import { metadataOf } from "../../../pipeline/steps/shapes.js";
59
+ import {
60
+ resetOperatorIdentityForTests,
61
+ setOperatorIdentity,
62
+ } from "../../../pipeline/steps/defaults.js";
63
+ import { ResourceNotFoundError } from "../../../store/interface.js";
64
+ import type { OrganizationSlugEntry, Store } from "../../../store/interface.js";
65
+ import { fakeIamPolicyStore } from "../../iampolicy/__tests__/support.js";
66
+ import {
67
+ newClaimOrganizationSlugStep,
68
+ releaseSlugClaimAfterFailure,
69
+ } from "../slug-ledger.js";
70
+
71
+ const OPERATOR_EMAIL = "operator@example.com";
72
+ const DUPLICATE_COPY = (slug: string) =>
73
+ `Organization already exists: slug '${slug}'`;
74
+
75
+ async function grpcError(run: () => Promise<unknown>): Promise<ConnectError> {
76
+ try {
77
+ await run();
78
+ } catch (error) {
79
+ if (error instanceof ConnectError) {
80
+ return error;
81
+ }
82
+ throw error;
83
+ }
84
+ throw new Error("expected the call to fail");
85
+ }
86
+
87
+ function organizationInput(slug: string) {
88
+ return {
89
+ apiVersion: "tenancy.stigmer.ai/v1",
90
+ kind: "Organization",
91
+ metadata: { name: slug, slug, org: "" },
92
+ spec: { description: "created by the organization slug test" },
93
+ };
94
+ }
95
+
96
+ describe("organization slugs on create (composed server, trusted-local posture)", () => {
97
+ const policies = fakeIamPolicyStore();
98
+ /** Slugs the pre-side-effect gate refuses while listed. */
99
+ const gateRefuses = new Set<string>();
100
+ /** Slugs whose post-persist step fails, after the row is stored. */
101
+ const postPersistFails = new Set<string>();
102
+
103
+ const slugOf = (organization: Organization) =>
104
+ organization.metadata?.slug ?? "";
105
+ const gateStep: PipelineStep<DescMessage> = {
106
+ name: "FakeOrgLimitGate",
107
+ execute: (ctx) => {
108
+ const slug = slugOf(ctx.newState as Organization);
109
+ if (gateRefuses.has(slug)) {
110
+ throw new ConnectError(
111
+ "fake limit on organizations",
112
+ Code.FailedPrecondition,
113
+ );
114
+ }
115
+ },
116
+ };
117
+ const postPersistStep: PipelineStep<DescMessage> = {
118
+ name: "FakeOrgPostPersist",
119
+ execute: (ctx) => {
120
+ const slug = slugOf(ctx.newState as Organization);
121
+ if (postPersistFails.has(slug)) {
122
+ throw new ConnectError("fake companion failed", Code.Unavailable);
123
+ }
124
+ },
125
+ };
126
+ const unit: ServerExtension = {
127
+ name: "fake-org-create",
128
+ gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
129
+ ["org-create:pre-side-effect-gate", [gateStep]],
130
+ ["org-create:post-persist", [postPersistStep]],
131
+ ]),
132
+ drivers: { iamPolicyStore: policies },
133
+ };
134
+
135
+ let dir: string;
136
+ let server: ComposedServer;
137
+ let organizations: Client<typeof OrganizationCommandController>;
138
+ let organizationQuery: Client<typeof OrganizationQueryController>;
139
+
140
+ function ownersOf(org: string): number {
141
+ return [...policies.rows.values()].filter(
142
+ (policy) =>
143
+ policy.spec?.resource?.kind === "organization" &&
144
+ policy.spec.resource.id === org &&
145
+ policy.spec.relation === "owner",
146
+ ).length;
147
+ }
148
+
149
+ beforeAll(async () => {
150
+ dir = mkdtempSync(path.join(tmpdir(), "organization-slugs-test-"));
151
+ setOperatorIdentity(OPERATOR_EMAIL, "The Operator");
152
+ server = await composeServer({
153
+ config: loadConfig({
154
+ STIGMER_MODEL_REGISTRY_REFRESH: "off",
155
+ TEMPORAL_HOST_PORT: "127.0.0.1:1",
156
+ DB_PATH: path.join(dir, "stigmer.db"),
157
+ STORAGE_PATH: path.join(dir, "storage"),
158
+ ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
159
+ STIGMER_OPERATOR_EMAIL: OPERATOR_EMAIL,
160
+ }),
161
+ logger: createLogger({ level: "error", pretty: false, write: () => {} }),
162
+ extensions: [unit],
163
+ portOverride: 0,
164
+ host: "127.0.0.1",
165
+ });
166
+ const port = await server.start();
167
+ const transport = createGrpcTransport({
168
+ baseUrl: `http://127.0.0.1:${port}`,
169
+ });
170
+ organizations = createClient(OrganizationCommandController, transport);
171
+ organizationQuery = createClient(OrganizationQueryController, transport);
172
+ });
173
+
174
+ afterAll(async () => {
175
+ await server.shutdown();
176
+ resetOperatorIdentityForTests();
177
+ rmSync(dir, { recursive: true, force: true });
178
+ });
179
+
180
+ beforeEach(() => {
181
+ gateRefuses.clear();
182
+ postPersistFails.clear();
183
+ });
184
+
185
+ it("of two concurrent creates of one slug, exactly one succeeds and the other is a duplicate", async () => {
186
+ const results = await Promise.allSettled([
187
+ organizations.create(organizationInput("contended")),
188
+ organizations.create(organizationInput("contended")),
189
+ ]);
190
+
191
+ expect(
192
+ results.filter((result) => result.status === "fulfilled"),
193
+ ).toHaveLength(1);
194
+ const [rejected] = results.filter(
195
+ (result): result is PromiseRejectedResult => result.status === "rejected",
196
+ );
197
+ const refusal = ConnectError.from(rejected?.reason);
198
+ expect(refusal.code).toBe(Code.AlreadyExists);
199
+ expect(refusal.rawMessage).toBe(DUPLICATE_COPY("contended"));
200
+ expect(refusal.findDetails(ErrorInfoSchema)).toEqual([]);
201
+ expect(ownersOf("contended")).toBe(1);
202
+ });
203
+
204
+ it("a create racing another create's claim, before its row exists, is a duplicate and never a deleted organization's", async () => {
205
+ const inFlight = await server.store.organizationSlugs.claim("in-flight");
206
+ expect(inFlight.claimed).toBe(true);
207
+
208
+ const refusal = await grpcError(() =>
209
+ organizations.create(organizationInput("in-flight")),
210
+ );
211
+ expect(refusal.code).toBe(Code.AlreadyExists);
212
+ expect(refusal.rawMessage).toBe(DUPLICATE_COPY("in-flight"));
213
+ expect(refusal.findDetails(ErrorInfoSchema)).toEqual([]);
214
+ });
215
+
216
+ it("a pre-side-effect gate's refusal leaves no claim, so the create succeeds once the gate lets it", async () => {
217
+ gateRefuses.add("gated");
218
+ const refusal = await grpcError(() =>
219
+ organizations.create(organizationInput("gated")),
220
+ );
221
+ expect(refusal.code).toBe(Code.FailedPrecondition);
222
+ expect(await server.store.organizationSlugs.find("gated")).toBeUndefined();
223
+
224
+ gateRefuses.delete("gated");
225
+ const created = await organizations.create(organizationInput("gated"));
226
+ expect(created.metadata?.id).toBe("gated");
227
+ });
228
+
229
+ it("a create that fails after its row is stored keeps its slug", async () => {
230
+ postPersistFails.add("half-made");
231
+ const refusal = await grpcError(() =>
232
+ organizations.create(organizationInput("half-made")),
233
+ );
234
+ expect(refusal.code).toBe(Code.Unavailable);
235
+
236
+ const entry = await server.store.organizationSlugs.find("half-made");
237
+ expect(entry?.retiredAt).toBe("");
238
+ expect(
239
+ (await organizationQuery.get({ value: "half-made" })).metadata?.id,
240
+ ).toBe("half-made");
241
+
242
+ postPersistFails.delete("half-made");
243
+ const retry = await grpcError(() =>
244
+ organizations.create(organizationInput("half-made")),
245
+ );
246
+ expect(retry.code).toBe(Code.AlreadyExists);
247
+ expect(retry.rawMessage).toBe(DUPLICATE_COPY("half-made"));
248
+ });
249
+
250
+ it("an organization no ledger entry records is refused as a duplicate by its row, and its delete retires the slug", async () => {
251
+ await organizations.create(organizationInput("older"));
252
+ // What an older binary leaves: the row, and no ledger entry.
253
+ const entry = await server.store.organizationSlugs.find("older");
254
+ await server.store.organizationSlugs.release(entry!);
255
+ expect(await server.store.organizationSlugs.find("older")).toBeUndefined();
256
+
257
+ const duplicate = await grpcError(() =>
258
+ organizations.create(organizationInput("older")),
259
+ );
260
+ expect(duplicate.code).toBe(Code.AlreadyExists);
261
+ expect(duplicate.rawMessage).toBe(DUPLICATE_COPY("older"));
262
+
263
+ await organizations.delete({ value: "older" });
264
+ const reserved = await grpcError(() =>
265
+ organizations.create(organizationInput("older")),
266
+ );
267
+ expect(reserved.code).toBe(Code.AlreadyExists);
268
+ expect(reserved.findDetails(ErrorInfoSchema)[0]?.reason).toBe(
269
+ "ORGANIZATION_SLUG_RESERVED",
270
+ );
271
+ });
272
+ });
273
+
274
+ describe("releaseSlugClaimAfterFailure", () => {
275
+ const entry: OrganizationSlugEntry = {
276
+ slug: "acme",
277
+ claimedAt: "2026-09-28T00:00:00.000Z",
278
+ retiredAt: "",
279
+ };
280
+
281
+ function logger(): Logger & { errors: string[] } {
282
+ const errors: string[] = [];
283
+ return {
284
+ errors,
285
+ error: (message: string) => errors.push(message),
286
+ warn: () => {},
287
+ info: () => {},
288
+ debug: () => {},
289
+ } as unknown as Logger & { errors: string[] };
290
+ }
291
+
292
+ /** A store double: the organization row's read and the ledger's release. */
293
+ function storeWith(
294
+ rowRead: () => Promise<unknown>,
295
+ release: (claim: OrganizationSlugEntry) => Promise<void>,
296
+ ): Store {
297
+ return {
298
+ getResource: rowRead,
299
+ organizationSlugs: {
300
+ claim: async () => ({ claimed: true, entry }),
301
+ retire: async () => {},
302
+ release,
303
+ find: async () => entry,
304
+ },
305
+ } as unknown as Store;
306
+ }
307
+
308
+ /** A request whose ClaimOrganizationSlug won `entry`. */
309
+ async function claimedRequest(): Promise<
310
+ RequestContext<typeof OrganizationSchema>
311
+ > {
312
+ const ctx = new RequestContext(
313
+ OrganizationSchema,
314
+ create(OrganizationSchema, { metadata: { slug: "acme" } }),
315
+ testCallerIdentity(),
316
+ );
317
+ const claiming = {
318
+ organizationSlugs: { claim: async () => ({ claimed: true, entry }) },
319
+ } as unknown as Store;
320
+ await newClaimOrganizationSlugStep(claiming).execute(ctx);
321
+ expect(metadataOf(ctx.newState)?.slug).toBe("acme");
322
+ return ctx;
323
+ }
324
+
325
+ it("frees the claim when the organization was never stored", async () => {
326
+ const release = vi.fn(async () => {});
327
+ const store = storeWith(async () => {
328
+ throw new ResourceNotFoundError("organization acme");
329
+ }, release);
330
+
331
+ await releaseSlugClaimAfterFailure(store, logger(), await claimedRequest());
332
+ expect(release).toHaveBeenCalledWith(entry);
333
+ });
334
+
335
+ it("keeps the claim when the organization was stored", async () => {
336
+ const release = vi.fn(async () => {});
337
+ const store = storeWith(async () => ({}), release);
338
+
339
+ await releaseSlugClaimAfterFailure(store, logger(), await claimedRequest());
340
+ expect(release).not.toHaveBeenCalled();
341
+ });
342
+
343
+ it("keeps the claim, and logs, when the row cannot be read or the release faults", async () => {
344
+ const release = vi.fn(async () => {});
345
+ const unreadable = logger();
346
+ await releaseSlugClaimAfterFailure(
347
+ storeWith(async () => {
348
+ throw new Error("database locked");
349
+ }, release),
350
+ unreadable,
351
+ await claimedRequest(),
352
+ );
353
+ expect(release).not.toHaveBeenCalled();
354
+ expect(unreadable.errors).toHaveLength(1);
355
+
356
+ const faulting = logger();
357
+ await releaseSlugClaimAfterFailure(
358
+ storeWith(
359
+ async () => {
360
+ throw new ResourceNotFoundError("organization acme");
361
+ },
362
+ async () => {
363
+ throw new Error("database locked");
364
+ },
365
+ ),
366
+ faulting,
367
+ await claimedRequest(),
368
+ );
369
+ expect(faulting.errors).toHaveLength(1);
370
+ });
371
+
372
+ it("does nothing for a request that never claimed", async () => {
373
+ const release = vi.fn(async () => {});
374
+ const store = storeWith(async () => {
375
+ throw new ResourceNotFoundError("organization acme");
376
+ }, release);
377
+ const unclaimed = new RequestContext(
378
+ OrganizationSchema,
379
+ create(OrganizationSchema, { metadata: { slug: "acme" } }),
380
+ testCallerIdentity(),
381
+ );
382
+
383
+ await releaseSlugClaimAfterFailure(store, logger(), unclaimed);
384
+ expect(release).not.toHaveBeenCalled();
385
+ });
386
+ });
@@ -106,6 +106,11 @@ import { newValidateVisibilityStep } from "../../pipeline/steps/validate-visibil
106
106
  import { ResourceNotFoundError } from "../../store/interface.js";
107
107
  import type { Store } from "../../store/interface.js";
108
108
  import { organizationSearchExtractor } from "./search-extractor.js";
109
+ import {
110
+ newClaimOrganizationSlugStep,
111
+ newRetireOrganizationSlugStep,
112
+ releaseSlugClaimAfterFailure,
113
+ } from "./slug-ledger.js";
109
114
  import {
110
115
  newCheckOrgDuplicateStep,
111
116
  newCopySlugToIdStep,
@@ -175,6 +180,13 @@ function kindOf(ctx: HandlerContext): ApiResourceKind {
175
180
  * refusal there leaves no organization behind. It is where a limit on
176
181
  * which organizations may exist is enforced.
177
182
  *
183
+ * ClaimOrganizationSlug follows the slot, immediately before Persist: the
184
+ * slug is claimed in the ledger atomically, so of two concurrent creates of
185
+ * one slug exactly one proceeds, and a slug any organization ever held is
186
+ * refused (slug-ledger.ts). When the chain fails after the claim and the
187
+ * organization was never stored, the claim is released so a retry can take
188
+ * the slug.
189
+ *
178
190
  * The post-persist gate slot splices after Persist, before IndexSearch —
179
191
  * the verified Java OrganizationCreateHandler ordering (FGA tuple
180
192
  * seeding, billing account getOrCreate: synchronous, a failure fails the
@@ -218,6 +230,7 @@ async function createOrganization(
218
230
  builder.addStep(step);
219
231
  }
220
232
  builder
233
+ .addStep(newClaimOrganizationSlugStep(deps.store))
221
234
  .addStep(newPersistStep(deps.store))
222
235
  // The C2 tuple step runs BEFORE the post-persist slot — the verified
223
236
  // Java order (createAuthorizationTuples → linkManagedOrgToIdentityProvider
@@ -236,12 +249,17 @@ async function createOrganization(
236
249
  )) {
237
250
  builder.addStep(step);
238
251
  }
239
- await builder
252
+ const pipeline = builder
240
253
  .addStep(
241
254
  newIndexSearchStep(deps.store, organizationSearchExtractor, deps.logger),
242
255
  )
243
- .build()
244
- .execute(reqCtx);
256
+ .build();
257
+ try {
258
+ await pipeline.execute(reqCtx);
259
+ } catch (error) {
260
+ await releaseSlugClaimAfterFailure(deps.store, deps.logger, reqCtx);
261
+ throw error;
262
+ }
245
263
  return reqCtx.newState;
246
264
  }
247
265
 
@@ -329,21 +347,24 @@ async function apply(
329
347
  /**
330
348
  * Delete — returns the deleted organization (gRPC audit-trail convention).
331
349
  *
332
- * Everything that names the organization goes before its row, and a fault
333
- * stops the delete with the organization intact: an organization's id is
334
- * its slug, the delete frees the slug for anyone, and whatever outlived
335
- * the row would belong to the slug's next holder. So, after the load:
350
+ * An organization's id is its slug, and a slug is never taken again
351
+ * (slug-ledger.ts), so whatever outlives the row can never pass to a new
352
+ * holder of the slug. Everything that grants on the organization still
353
+ * goes before its row, and a fault stops the delete with the organization
354
+ * intact, so nothing it granted outlives it. After the load:
336
355
  *
337
- * 1. the `org-delete:pre-delete` slot, where an edition refuses or
356
+ * 1. RetireOrganizationSlug, the slug marked retired in the ledger, so it
357
+ * is retired before the row can go;
358
+ * 2. the `org-delete:pre-delete` slot, where an edition refuses or
338
359
  * removes the rows it keeps for the organization (empty in OSS);
339
- * 2. RevokeOrganizationPolicies, every policy row naming the
360
+ * 3. RevokeOrganizationPolicies, every policy row naming the
340
361
  * organization, through the grant path, never caught;
341
- * 3. the row;
342
- * 4. CleanupIamPolicies, the lifecycle's post-delete event: best-effort
362
+ * 4. the row;
363
+ * 5. CleanupIamPolicies, the lifecycle's post-delete event: best-effort
343
364
  * like every delete chain's, it revokes whatever a concurrent write
344
- * named the organization with after step 2, and runs a composed
365
+ * named the organization with after step 3, and runs a composed
345
366
  * driver's post-delete companions;
346
- * 5. the search entry.
367
+ * 6. the search entry.
347
368
  */
348
369
  async function deleteOrganization(
349
370
  deps: OrganizationControllerDeps,
@@ -366,7 +387,8 @@ async function deleteOrganization(
366
387
  )
367
388
  .addStep(newValidateProtoStep())
368
389
  .addStep(newExtractResourceIdStep())
369
- .addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema));
390
+ .addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema))
391
+ .addStep(newRetireOrganizationSlugStep<DeleteInput>(deps.store));
370
392
  // The pre-delete gate slot (see the doc comment above). Empty in OSS.
371
393
  for (const step of stepsForSlot<DeleteInput>(
372
394
  deps.gateSteps,
@@ -0,0 +1,209 @@
1
+ /**
2
+ * The organization domain's use of the slug ledger (the Store's
3
+ * `organizationSlugs`; store/interface.ts states its guarantees): an
4
+ * organization's slug is its for good.
5
+ *
6
+ * An organization's id is its slug, and the delete removes the row. Rows
7
+ * that name the organization can outlive it (every organization-scoped
8
+ * resource names it in `metadata.org`, and an edition may keep its own rows
9
+ * by the id), so a slug taken again would hand them all to the new holder.
10
+ * The ledger closes that: the create claims the slug atomically, the delete
11
+ * retires it, and nothing ever frees a retired slug.
12
+ *
13
+ * The create touches the ledger twice. CheckDuplicate reads it first, so a
14
+ * taken slug is refused before any gate runs (steps.ts). ClaimOrganizationSlug
15
+ * then claims it immediately before Persist, after the pre-side-effect gate
16
+ * slot, so a gate's refusal still leaves nothing written; of two concurrent
17
+ * creates of one slug, exactly one claim wins, which the row alone cannot
18
+ * give because `saveResource` upserts. A create that fails after its claim
19
+ * and before its row is stored releases the claim, so the caller's retry
20
+ * can take the slug again; a create that fails after the row keeps it,
21
+ * because the organization exists.
22
+ *
23
+ * The refusal says why. A retired slug answers AlreadyExists carrying the
24
+ * ORGANIZATION_SLUG_RESERVED reason (the organization create's contract
25
+ * documents it), so the CLI and the console can tell a person the slug
26
+ * belonged to a deleted organization. A held slug keeps the existing copy
27
+ * with no reason: it covers a live organization and also a create between
28
+ * its claim and its row, which must never read as "deleted". Both keep the
29
+ * AlreadyExists code, which the personal-organization retry keys on.
30
+ *
31
+ * Proven by __tests__/organization-slugs.test.ts and the organization
32
+ * conformance suite.
33
+ */
34
+ import type { DescMessage } from "@bufbuild/protobuf";
35
+ import type { ConnectError } from "@connectrpc/connect";
36
+
37
+ import type { Logger } from "../../boot/logger.js";
38
+ import { ResourceNotFoundError } from "../../store/interface.js";
39
+ import type { OrganizationSlugEntry, Store } from "../../store/interface.js";
40
+ import {
41
+ alreadyExistsError,
42
+ alreadyExistsWithReasonError,
43
+ internalError,
44
+ } from "../../pipeline/errors.js";
45
+ import type { PipelineStep } from "../../pipeline/pipeline.js";
46
+ import type { RequestContext } from "../../pipeline/request-context.js";
47
+ import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
48
+ import { metadataOf } from "../../pipeline/steps/shapes.js";
49
+
50
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
51
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
52
+ import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
53
+
54
+ /**
55
+ * The ErrorInfo reason a create of a retired slug carries: a deleted
56
+ * organization held the slug, and a slug is never reused. Wire contract,
57
+ * documented on OrganizationCommandController.create; metadata `slug`.
58
+ */
59
+ export const ORGANIZATION_SLUG_RESERVED = "ORGANIZATION_SLUG_RESERVED";
60
+
61
+ /** Where ClaimOrganizationSlug leaves the entry it won, for the release after a failure. */
62
+ const CLAIMED_SLUG_KEY = "organizationSlugClaim";
63
+
64
+ /** The copy of a retired slug's refusal. */
65
+ export function organizationSlugReservedMessage(slug: string): string {
66
+ return `Organization slug '${slug}' belonged to an organization that was deleted, and a slug is never reused`;
67
+ }
68
+
69
+ /**
70
+ * The refusal for a slug an entry already holds: the reserved refusal for a
71
+ * retired entry, the existing duplicate copy for one still held.
72
+ */
73
+ export function refusalForHeldSlug(entry: OrganizationSlugEntry): ConnectError {
74
+ if (entry.retiredAt !== "") {
75
+ return alreadyExistsWithReasonError(
76
+ organizationSlugReservedMessage(entry.slug),
77
+ { reason: ORGANIZATION_SLUG_RESERVED, metadata: { slug: entry.slug } },
78
+ );
79
+ }
80
+ return alreadyExistsError("Organization", `slug '${entry.slug}'`);
81
+ }
82
+
83
+ /**
84
+ * Claims the new organization's slug, immediately before Persist. A lost
85
+ * claim is refused by the entry that holds the slug; a won claim is left in
86
+ * the request for releaseSlugClaimAfterFailure.
87
+ */
88
+ export function newClaimOrganizationSlugStep(
89
+ store: Store,
90
+ ): PipelineStep<typeof OrganizationSchema> {
91
+ return {
92
+ name: "ClaimOrganizationSlug",
93
+ async execute(
94
+ ctx: RequestContext<typeof OrganizationSchema>,
95
+ ): Promise<void> {
96
+ const slug = metadataOf(ctx.newState)?.slug ?? "";
97
+ // ResolveSlug and CheckDuplicate run first, so an empty slug here is
98
+ // a server-side ordering bug, not bad client input.
99
+ if (slug === "") {
100
+ throw internalError(
101
+ new Error("organization slug is empty"),
102
+ "failed to claim the organization slug",
103
+ );
104
+ }
105
+ let claim;
106
+ try {
107
+ claim = await store.organizationSlugs.claim(slug);
108
+ } catch (error) {
109
+ throw internalError(error, "failed to claim the organization slug");
110
+ }
111
+ if (!claim.claimed) {
112
+ throw refusalForHeldSlug(claim.entry);
113
+ }
114
+ ctx.set(CLAIMED_SLUG_KEY, claim.entry);
115
+ },
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Frees the slug a failed create claimed, when its organization was never
121
+ * stored, so the caller's retry can take the slug again. Called by the
122
+ * create around its chain, as send-signal.ts releases a dedupe claim.
123
+ *
124
+ * Whether the row exists decides, rather than which step failed: a Persist
125
+ * that stored the row and then failed keeps the claim, as it must, because
126
+ * the organization exists. Any fault here is logged and leaves the slug
127
+ * claimed with no organization, which nothing can then take; that is a
128
+ * hand's to resolve, and far rarer than the failure it follows.
129
+ */
130
+ export async function releaseSlugClaimAfterFailure(
131
+ store: Store,
132
+ logger: Logger,
133
+ ctx: RequestContext<typeof OrganizationSchema>,
134
+ ): Promise<void> {
135
+ const entry = ctx.get(CLAIMED_SLUG_KEY) as OrganizationSlugEntry | undefined;
136
+ if (entry === undefined) {
137
+ return;
138
+ }
139
+ try {
140
+ await store.getResource(
141
+ ApiResourceKind.organization,
142
+ entry.slug,
143
+ OrganizationSchema,
144
+ );
145
+ return; // stored: the organization exists and keeps its slug
146
+ } catch (error) {
147
+ if (!(error instanceof ResourceNotFoundError)) {
148
+ logger.error(
149
+ "organization create failed and its slug claim could not be checked; the slug stays claimed",
150
+ {
151
+ slug: entry.slug,
152
+ error: error instanceof Error ? error.message : String(error),
153
+ },
154
+ );
155
+ return;
156
+ }
157
+ }
158
+ try {
159
+ await store.organizationSlugs.release(entry);
160
+ } catch (error) {
161
+ logger.error(
162
+ "organization create failed and its slug claim could not be released; the slug stays claimed",
163
+ {
164
+ slug: entry.slug,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ },
167
+ );
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Retires the organization's slug before anything else the delete does,
173
+ * right after the organization is loaded. Its entry may not exist yet (an
174
+ * organization created before the ledger, or by an older binary during a
175
+ * rolling upgrade), and retire records it either way, so the slug is
176
+ * retired before the row can go.
177
+ *
178
+ * A fault fails the delete with the organization intact. A delete that
179
+ * fails later (an edition's refusal on the pre-delete slot, a revocation
180
+ * fault) leaves a live organization whose slug reads as retired; nothing
181
+ * observes that, because a create of the slug is refused either way, and a
182
+ * retry of the delete resumes.
183
+ */
184
+ export function newRetireOrganizationSlugStep<Desc extends DescMessage>(
185
+ store: Store,
186
+ ): PipelineStep<Desc> {
187
+ return {
188
+ name: "RetireOrganizationSlug",
189
+ async execute(ctx: RequestContext<Desc>): Promise<void> {
190
+ const organization = ctx.get(EXISTING_RESOURCE_KEY) as
191
+ | Organization
192
+ | undefined;
193
+ const id = organization?.metadata?.id ?? "";
194
+ if (id === "") {
195
+ throw internalError(
196
+ new Error(
197
+ "organization delete reached RetireOrganizationSlug without its loaded row",
198
+ ),
199
+ "failed to retire the organization slug",
200
+ );
201
+ }
202
+ try {
203
+ await store.organizationSlugs.retire(id);
204
+ } catch (error) {
205
+ throw internalError(error, "failed to retire the organization slug");
206
+ }
207
+ },
208
+ };
209
+ }