@stigmer/server 3.38.3 → 3.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) 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/agentshare/controller.d.ts +2 -2
  14. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  15. package/dist/domain/agentshare/controller.js +7 -3
  16. package/dist/domain/agentshare/controller.js.map +1 -1
  17. package/dist/domain/organization/controller.d.ts.map +1 -1
  18. package/dist/domain/organization/controller.js +33 -14
  19. package/dist/domain/organization/controller.js.map +1 -1
  20. package/dist/domain/organization/slug-ledger.d.ts +86 -0
  21. package/dist/domain/organization/slug-ledger.d.ts.map +1 -0
  22. package/dist/domain/organization/slug-ledger.js +128 -0
  23. package/dist/domain/organization/slug-ledger.js.map +1 -0
  24. package/dist/domain/organization/steps.d.ts +12 -3
  25. package/dist/domain/organization/steps.d.ts.map +1 -1
  26. package/dist/domain/organization/steps.js +28 -6
  27. package/dist/domain/organization/steps.js.map +1 -1
  28. package/dist/extensions/gate-slots.d.ts +7 -5
  29. package/dist/extensions/gate-slots.d.ts.map +1 -1
  30. package/dist/extensions/gate-slots.js.map +1 -1
  31. package/dist/index.d.ts +3 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/pipeline/errors.d.ts +8 -0
  36. package/dist/pipeline/errors.d.ts.map +1 -1
  37. package/dist/pipeline/errors.js +19 -0
  38. package/dist/pipeline/errors.js.map +1 -1
  39. package/dist/store/interface.d.ts +67 -0
  40. package/dist/store/interface.d.ts.map +1 -1
  41. package/dist/store/interface.js.map +1 -1
  42. package/dist/store/organization-slug-history.d.ts +71 -0
  43. package/dist/store/organization-slug-history.d.ts.map +1 -0
  44. package/dist/store/organization-slug-history.js +88 -0
  45. package/dist/store/organization-slug-history.js.map +1 -0
  46. package/dist/store/postgres/migrations.d.ts +3 -1
  47. package/dist/store/postgres/migrations.d.ts.map +1 -1
  48. package/dist/store/postgres/migrations.js +69 -1
  49. package/dist/store/postgres/migrations.js.map +1 -1
  50. package/dist/store/postgres/store.d.ts +2 -1
  51. package/dist/store/postgres/store.d.ts.map +1 -1
  52. package/dist/store/postgres/store.js +49 -0
  53. package/dist/store/postgres/store.js.map +1 -1
  54. package/dist/store/sqlite/migrations.d.ts +3 -1
  55. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  56. package/dist/store/sqlite/migrations.js +54 -1
  57. package/dist/store/sqlite/migrations.js.map +1 -1
  58. package/dist/store/sqlite/store.d.ts +2 -1
  59. package/dist/store/sqlite/store.d.ts.map +1 -1
  60. package/dist/store/sqlite/store.js +55 -0
  61. package/dist/store/sqlite/store.js.map +1 -1
  62. package/package.json +6 -6
  63. package/src/domain/agent/__tests__/store-faults.test.ts +105 -0
  64. package/src/domain/agent/controller.ts +9 -5
  65. package/src/domain/agentchannel/__tests__/store-faults.test.ts +126 -0
  66. package/src/domain/agentchannel/controller.ts +12 -7
  67. package/src/domain/agentinstance/__tests__/store-faults.test.ts +110 -0
  68. package/src/domain/agentinstance/controller.ts +9 -5
  69. package/src/domain/agentshare/__tests__/store-faults.test.ts +101 -0
  70. package/src/domain/agentshare/controller.ts +9 -5
  71. package/src/domain/organization/__tests__/organization-delete.test.ts +73 -18
  72. package/src/domain/organization/__tests__/organization-slugs.test.ts +386 -0
  73. package/src/domain/organization/controller.ts +36 -14
  74. package/src/domain/organization/slug-ledger.ts +209 -0
  75. package/src/domain/organization/steps.ts +28 -6
  76. package/src/extensions/gate-slots.ts +7 -5
  77. package/src/index.ts +11 -0
  78. package/src/pipeline/__tests__/blind-not-found.test.ts +0 -4
  79. package/src/pipeline/errors.ts +23 -0
  80. package/src/store/__tests__/organization-slug-history.test.ts +109 -0
  81. package/src/store/__tests__/store-contract.ts +86 -1
  82. package/src/store/interface.ts +70 -0
  83. package/src/store/organization-slug-history.ts +159 -0
  84. package/src/store/postgres/__tests__/migrations.test.ts +138 -2
  85. package/src/store/postgres/__tests__/store-contract.test.ts +1 -0
  86. package/src/store/postgres/migrations.ts +84 -1
  87. package/src/store/postgres/store.ts +84 -0
  88. package/src/store/sqlite/__tests__/migrations.test.ts +139 -10
  89. package/src/store/sqlite/migrations.ts +69 -1
  90. package/src/store/sqlite/store.ts +84 -0
@@ -1,20 +1,24 @@
1
1
  /**
2
2
  * Pins the organization delete's order through a composed server in the
3
- * trusted-local posture: everything that names the organization goes
4
- * before its row, and a fault stops the delete with the organization in
5
- * place. An organization's id is its slug and the delete frees the slug for
6
- * anyone, so a row left behind would belong to the slug's next holder.
3
+ * trusted-local posture: the slug is retired first, everything that grants
4
+ * on the organization goes before its row, and a fault stops the delete
5
+ * with the organization in place. An organization's id is its slug, and a
6
+ * slug is never taken again, so nothing a deleted organization left behind
7
+ * can pass to a new holder.
7
8
  *
8
9
  * - the `org-delete:pre-delete` slot runs with the organization loaded
9
10
  * and still stored, and its rows still in place;
10
11
  * - a slot step's refusal answers its own code and copy, and leaves the
11
- * organization and its rows;
12
+ * organization and its rows, its slug already retired;
13
+ * - a fault retiring the slug fails the delete before anything else, with
14
+ * the organization and its rows in place;
12
15
  * - the organization's policy rows are revoked before its row, as
13
16
  * principal and as resource: a revocation fault answers Internal with
14
17
  * fixed copy, leaves the organization and its owner row, and a retry
15
18
  * completes the delete;
16
- * - a slug freed by the delete and created again carries none of the old
17
- * organization's rows.
19
+ * - a create of a deleted organization's slug is refused with the
20
+ * ORGANIZATION_SLUG_RESERVED reason, and no row of the old organization
21
+ * gains an owner.
18
22
  *
19
23
  * The policy store is the library's in-memory double, composed as the
20
24
  * IamPolicy store driver so a test can make one row's delete fault; the
@@ -28,13 +32,22 @@ import type { DescMessage } from "@bufbuild/protobuf";
28
32
  import { Code, ConnectError, createClient } from "@connectrpc/connect";
29
33
  import type { Client, Transport } from "@connectrpc/connect";
30
34
  import { createGrpcTransport } from "@connectrpc/connect-node";
31
- import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
35
+ import {
36
+ afterAll,
37
+ beforeAll,
38
+ beforeEach,
39
+ describe,
40
+ expect,
41
+ it,
42
+ vi,
43
+ } from "vitest";
32
44
 
33
45
  import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
34
46
  import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
35
47
  import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
36
48
  import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
37
49
  import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
50
+ import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
38
51
 
39
52
  import { loadConfig } from "../../../boot/config.js";
40
53
  import { composeServer } from "../../../boot/compose.js";
@@ -215,6 +228,36 @@ describe("organization delete (composed server, trusted-local posture)", () => {
215
228
  (await organizationQuery.get({ value: REFUSED_SLUG })).metadata?.id,
216
229
  ).toBe(REFUSED_SLUG);
217
230
  expect(rowsNaming(REFUSED_SLUG)).toHaveLength(3);
231
+ // The slug was retired before the slot refused; the organization lives
232
+ // on, and its slug is refused to a create either way.
233
+ expect(
234
+ (await server.store.organizationSlugs.find(REFUSED_SLUG))?.retiredAt,
235
+ ).not.toBe("");
236
+ });
237
+
238
+ it("a fault retiring the slug fails the delete before anything else, with the organization and its rows in place", async () => {
239
+ await organizationWithRows("delete-retire-faults");
240
+ const retire = vi
241
+ .spyOn(server.store.organizationSlugs, "retire")
242
+ .mockRejectedValueOnce(new Error("ledger unavailable"));
243
+ try {
244
+ const failed = await grpcError(() =>
245
+ organizations.delete({ value: "delete-retire-faults" }),
246
+ );
247
+ expect(failed.code).toBe(Code.Internal);
248
+ expect(failed.rawMessage).toBe("failed to retire the organization slug");
249
+ } finally {
250
+ retire.mockRestore();
251
+ }
252
+ expect(seenBySlot.has("delete-retire-faults")).toBe(false);
253
+ expect(
254
+ (await organizationQuery.get({ value: "delete-retire-faults" })).metadata
255
+ ?.id,
256
+ ).toBe("delete-retire-faults");
257
+ expect(rowsNaming("delete-retire-faults")).toHaveLength(3);
258
+
259
+ await organizations.delete({ value: "delete-retire-faults" });
260
+ expect(rowsNaming("delete-retire-faults")).toEqual([]);
218
261
  });
219
262
 
220
263
  it("a revocation fault fails the delete with the organization and its owner in place, and the retry completes it", async () => {
@@ -248,19 +291,31 @@ describe("organization delete (composed server, trusted-local posture)", () => {
248
291
  expect(rowsNaming("delete-faults")).toEqual([]);
249
292
  });
250
293
 
251
- it("a slug freed by the delete and created again carries none of the old organization's rows", async () => {
294
+ it("a deleted organization's slug is never taken again: the create is refused with the reserved reason", async () => {
252
295
  await organizationWithRows("delete-reborn");
253
296
  await organizations.delete({ value: "delete-reborn" });
254
297
 
255
- await organizations.create({
256
- apiVersion: "tenancy.stigmer.ai/v1",
257
- kind: "Organization",
258
- metadata: { name: "delete-reborn", slug: "delete-reborn", org: "" },
259
- spec: { description: "the slug, taken again" },
260
- });
298
+ const refused = await grpcError(() =>
299
+ organizations.create({
300
+ apiVersion: "tenancy.stigmer.ai/v1",
301
+ kind: "Organization",
302
+ metadata: { name: "delete-reborn", slug: "delete-reborn", org: "" },
303
+ spec: { description: "the slug, taken again" },
304
+ }),
305
+ );
261
306
 
262
- expect(
263
- rowsNaming("delete-reborn").map((row) => row.spec?.relation),
264
- ).toEqual(["owner"]);
307
+ expect(refused.code).toBe(Code.AlreadyExists);
308
+ expect(refused.rawMessage).toBe(
309
+ "Organization slug 'delete-reborn' belonged to an organization that was deleted, and a slug is never reused",
310
+ );
311
+ const [reason] = refused.findDetails(ErrorInfoSchema);
312
+ expect(reason?.reason).toBe("ORGANIZATION_SLUG_RESERVED");
313
+ expect(reason?.domain).toBe("stigmer.ai");
314
+ expect(reason?.metadata).toEqual({ slug: "delete-reborn" });
315
+ expect(rowsNaming("delete-reborn")).toEqual([]);
316
+ const gone = await grpcError(() =>
317
+ organizationQuery.get({ value: "delete-reborn" }),
318
+ );
319
+ expect(gone.code).toBe(Code.NotFound);
265
320
  });
266
321
  });
@@ -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,