@stigmer/server 3.37.0 → 3.38.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 (105) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +3 -0
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/domain/agentexecution/artifacts.d.ts.map +1 -1
  5. package/dist/domain/agentexecution/artifacts.js +15 -6
  6. package/dist/domain/agentexecution/artifacts.js.map +1 -1
  7. package/dist/domain/agentexecution/lifecycle.d.ts.map +1 -1
  8. package/dist/domain/agentexecution/lifecycle.js +5 -3
  9. package/dist/domain/agentexecution/lifecycle.js.map +1 -1
  10. package/dist/domain/agentexecution/platform-rows.d.ts +63 -0
  11. package/dist/domain/agentexecution/platform-rows.d.ts.map +1 -0
  12. package/dist/domain/agentexecution/platform-rows.js +106 -0
  13. package/dist/domain/agentexecution/platform-rows.js.map +1 -0
  14. package/dist/domain/agentexecution/subscribe.d.ts.map +1 -1
  15. package/dist/domain/agentexecution/subscribe.js +7 -4
  16. package/dist/domain/agentexecution/subscribe.js.map +1 -1
  17. package/dist/domain/agentexecution/update-status.d.ts.map +1 -1
  18. package/dist/domain/agentexecution/update-status.js +48 -17
  19. package/dist/domain/agentexecution/update-status.js.map +1 -1
  20. package/dist/domain/iampolicy/grant-path.d.ts +7 -3
  21. package/dist/domain/iampolicy/grant-path.d.ts.map +1 -1
  22. package/dist/domain/iampolicy/grant-path.js +10 -2
  23. package/dist/domain/iampolicy/grant-path.js.map +1 -1
  24. package/dist/domain/organization/controller.d.ts +4 -1
  25. package/dist/domain/organization/controller.d.ts.map +1 -1
  26. package/dist/domain/organization/controller.js +28 -4
  27. package/dist/domain/organization/controller.js.map +1 -1
  28. package/dist/domain/organization/steps.d.ts +22 -0
  29. package/dist/domain/organization/steps.d.ts.map +1 -1
  30. package/dist/domain/organization/steps.js +50 -2
  31. package/dist/domain/organization/steps.js.map +1 -1
  32. package/dist/domain/workflowexecution/create-steps.d.ts.map +1 -1
  33. package/dist/domain/workflowexecution/create-steps.js +6 -2
  34. package/dist/domain/workflowexecution/create-steps.js.map +1 -1
  35. package/dist/domain/workflowexecution/lifecycle.d.ts.map +1 -1
  36. package/dist/domain/workflowexecution/lifecycle.js +5 -2
  37. package/dist/domain/workflowexecution/lifecycle.js.map +1 -1
  38. package/dist/domain/workflowexecution/send-signal.d.ts.map +1 -1
  39. package/dist/domain/workflowexecution/send-signal.js +6 -3
  40. package/dist/domain/workflowexecution/send-signal.js.map +1 -1
  41. package/dist/domain/workflowexecution/submit-approval.d.ts.map +1 -1
  42. package/dist/domain/workflowexecution/submit-approval.js +6 -2
  43. package/dist/domain/workflowexecution/submit-approval.js.map +1 -1
  44. package/dist/domain/workflowexecution/submit-file-decision.d.ts.map +1 -1
  45. package/dist/domain/workflowexecution/submit-file-decision.js +6 -2
  46. package/dist/domain/workflowexecution/submit-file-decision.js.map +1 -1
  47. package/dist/domain/workflowexecution/submit-workflow-task-approval.d.ts.map +1 -1
  48. package/dist/domain/workflowexecution/submit-workflow-task-approval.js +6 -2
  49. package/dist/domain/workflowexecution/submit-workflow-task-approval.js.map +1 -1
  50. package/dist/domain/workflowexecution/subscribe-events.d.ts.map +1 -1
  51. package/dist/domain/workflowexecution/subscribe-events.js +6 -2
  52. package/dist/domain/workflowexecution/subscribe-events.js.map +1 -1
  53. package/dist/domain/workflowexecution/subscribe.d.ts.map +1 -1
  54. package/dist/domain/workflowexecution/subscribe.js +7 -4
  55. package/dist/domain/workflowexecution/subscribe.js.map +1 -1
  56. package/dist/extensions/gate-slots.d.ts +14 -1
  57. package/dist/extensions/gate-slots.d.ts.map +1 -1
  58. package/dist/extensions/gate-slots.js +1 -0
  59. package/dist/extensions/gate-slots.js.map +1 -1
  60. package/dist/pipeline/steps/authorization-tuples.d.ts +6 -2
  61. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  62. package/dist/pipeline/steps/authorization-tuples.js +6 -2
  63. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  64. package/dist/temporal/agentexecution/runner-result.d.ts +12 -0
  65. package/dist/temporal/agentexecution/runner-result.d.ts.map +1 -1
  66. package/dist/temporal/agentexecution/runner-result.js +15 -0
  67. package/dist/temporal/agentexecution/runner-result.js.map +1 -1
  68. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.d.ts.map +1 -1
  69. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js +44 -22
  70. package/dist/temporal/agentexecution/workflows/invoke-agent-execution.js.map +1 -1
  71. package/package.json +6 -6
  72. package/src/boot/compose.ts +3 -0
  73. package/src/domain/agentexecution/__tests__/store-faults.test.ts +315 -0
  74. package/src/domain/agentexecution/__tests__/update-status.test.ts +217 -0
  75. package/src/domain/agentexecution/artifacts.ts +21 -12
  76. package/src/domain/agentexecution/lifecycle.ts +5 -3
  77. package/src/domain/agentexecution/platform-rows.ts +133 -0
  78. package/src/domain/agentexecution/subscribe.ts +7 -3
  79. package/src/domain/agentexecution/update-status.ts +70 -19
  80. package/src/domain/iampolicy/__tests__/grant-path.test.ts +40 -7
  81. package/src/domain/iampolicy/grant-path.ts +19 -5
  82. package/src/domain/organization/__tests__/organization-delete.test.ts +266 -0
  83. package/src/domain/organization/controller.ts +40 -8
  84. package/src/domain/organization/steps.ts +66 -2
  85. package/src/domain/workflowexecution/__tests__/store-faults.test.ts +418 -0
  86. package/src/domain/workflowexecution/create-steps.ts +6 -2
  87. package/src/domain/workflowexecution/lifecycle.ts +5 -2
  88. package/src/domain/workflowexecution/send-signal.ts +11 -4
  89. package/src/domain/workflowexecution/submit-approval.ts +6 -2
  90. package/src/domain/workflowexecution/submit-file-decision.ts +6 -2
  91. package/src/domain/workflowexecution/submit-workflow-task-approval.ts +6 -2
  92. package/src/domain/workflowexecution/subscribe-events.ts +6 -2
  93. package/src/domain/workflowexecution/subscribe.ts +11 -4
  94. package/src/extensions/__tests__/registry.test.ts +3 -0
  95. package/src/extensions/gate-slots.ts +14 -0
  96. package/src/pipeline/__tests__/blind-not-found.test.ts +297 -0
  97. package/src/pipeline/steps/authorization-tuples.ts +6 -2
  98. package/src/temporal/agentexecution/__tests__/invoke-workflow.test.ts +51 -1
  99. package/src/temporal/agentexecution/__tests__/own-behalf-status-writes.test.ts +149 -9
  100. package/src/temporal/agentexecution/__tests__/replay-histories/runner-failed.json +757 -0
  101. package/src/temporal/agentexecution/__tests__/replay-histories/user-cancel.json +755 -0
  102. package/src/temporal/agentexecution/__tests__/replay.test.ts +4 -2
  103. package/src/temporal/agentexecution/runner-result.ts +16 -0
  104. package/src/temporal/agentexecution/workflows/invoke-agent-execution.ts +49 -22
  105. package/src/domain/agentexecution/__tests__/execution-usage-report.test.ts +0 -78
@@ -0,0 +1,266 @@
1
+ /**
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.
7
+ *
8
+ * - the `org-delete:pre-delete` slot runs with the organization loaded
9
+ * and still stored, and its rows still in place;
10
+ * - a slot step's refusal answers its own code and copy, and leaves the
11
+ * organization and its rows;
12
+ * - the organization's policy rows are revoked before its row, as
13
+ * principal and as resource: a revocation fault answers Internal with
14
+ * fixed copy, leaves the organization and its owner row, and a retry
15
+ * completes the delete;
16
+ * - a slug freed by the delete and created again carries none of the old
17
+ * organization's rows.
18
+ *
19
+ * The policy store is the library's in-memory double, composed as the
20
+ * IamPolicy store driver so a test can make one row's delete fault; the
21
+ * grant path, the role lifecycle and the chain are the library's own.
22
+ */
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import path from "node:path";
26
+
27
+ import type { DescMessage } from "@bufbuild/protobuf";
28
+ import { Code, ConnectError, createClient } from "@connectrpc/connect";
29
+ import type { Client, Transport } from "@connectrpc/connect";
30
+ import { createGrpcTransport } from "@connectrpc/connect-node";
31
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
32
+
33
+ import { IamPolicyCommandController } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/command_pb";
34
+ import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
35
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
36
+ import { OrganizationCommandController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/command_pb";
37
+ import { OrganizationQueryController } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/query_pb";
38
+
39
+ import { loadConfig } from "../../../boot/config.js";
40
+ import { composeServer } from "../../../boot/compose.js";
41
+ import type { ComposedServer } from "../../../boot/compose.js";
42
+ import { createLogger } from "../../../boot/logger.js";
43
+ import type { GateSlotName } from "../../../extensions/gate-slots.js";
44
+ import type { ServerExtension } from "../../../extensions/registry.js";
45
+ import type { PipelineStep } from "../../../pipeline/pipeline.js";
46
+ import { EXISTING_RESOURCE_KEY } from "../../../pipeline/steps/load-existing.js";
47
+ import {
48
+ resetOperatorIdentityForTests,
49
+ setOperatorIdentity,
50
+ } from "../../../pipeline/steps/defaults.js";
51
+ import {
52
+ fakeIamPolicyStore,
53
+ orgRole,
54
+ triple,
55
+ } from "../../iampolicy/__tests__/support.js";
56
+
57
+ const OPERATOR_EMAIL = "operator@example.com";
58
+ const REFUSED_SLUG = "delete-refused";
59
+ const OUTSIDER = "ida_0byc5k14t1e7b7kdxft7hwz1f7";
60
+ const AGENT = "agt_01hzzzzzzzzzzzzzzzzzzzzzzz";
61
+
62
+ async function grpcError(run: () => Promise<unknown>): Promise<ConnectError> {
63
+ try {
64
+ await run();
65
+ } catch (error) {
66
+ if (error instanceof ConnectError) {
67
+ return error;
68
+ }
69
+ throw error;
70
+ }
71
+ throw new Error("expected the call to fail");
72
+ }
73
+
74
+ describe("organization delete (composed server, trusted-local posture)", () => {
75
+ const policies = fakeIamPolicyStore();
76
+ /** A policy id whose delete faults while set: the store answering an error mid-revocation. */
77
+ let faultingDelete: string | undefined;
78
+ const faultablePolicies = {
79
+ ...policies,
80
+ async deleteById(id: string): Promise<void> {
81
+ if (id === faultingDelete) {
82
+ throw new Error("policy store unavailable");
83
+ }
84
+ await policies.deleteById(id);
85
+ },
86
+ };
87
+
88
+ /** What the slot's step saw, per organization, while it ran. */
89
+ const seenBySlot = new Map<string, { loaded: string; rowsNaming: number }>();
90
+ const slotStep: PipelineStep<DescMessage> = {
91
+ name: "FakeOrgDeleteStep",
92
+ execute: (ctx) => {
93
+ const organization = ctx.get(EXISTING_RESOURCE_KEY) as
94
+ | Organization
95
+ | undefined;
96
+ const id = organization?.metadata?.id ?? "";
97
+ seenBySlot.set(id, { loaded: id, rowsNaming: rowsNaming(id).length });
98
+ if (id === REFUSED_SLUG) {
99
+ throw new ConnectError(
100
+ "fake edition keeps something for this organization",
101
+ Code.FailedPrecondition,
102
+ );
103
+ }
104
+ },
105
+ };
106
+ const unit: ServerExtension = {
107
+ name: "fake-org-delete",
108
+ gateSteps: new Map<GateSlotName, ReadonlyArray<PipelineStep<DescMessage>>>([
109
+ ["org-delete:pre-delete", [slotStep]],
110
+ ]),
111
+ drivers: { iamPolicyStore: faultablePolicies },
112
+ };
113
+
114
+ let dir: string;
115
+ let server: ComposedServer;
116
+ let transport: Transport;
117
+ let organizations: Client<typeof OrganizationCommandController>;
118
+ let organizationQuery: Client<typeof OrganizationQueryController>;
119
+ /** The platform's own pipeline: the in-process transport's `internal` caller. */
120
+ let platform: Client<typeof IamPolicyCommandController>;
121
+
122
+ function rowsNaming(org: string): IamPolicy[] {
123
+ return [...policies.rows.values()].filter(
124
+ (policy) =>
125
+ (policy.spec?.resource?.kind === "organization" &&
126
+ policy.spec.resource.id === org) ||
127
+ (policy.spec?.principal?.kind === "organization" &&
128
+ policy.spec.principal.id === org),
129
+ );
130
+ }
131
+
132
+ /** An organization with its creator's owner row, an outsider's member row, and an agent's scope link to it. */
133
+ async function organizationWithRows(slug: string): Promise<void> {
134
+ await organizations.create({
135
+ apiVersion: "tenancy.stigmer.ai/v1",
136
+ kind: "Organization",
137
+ metadata: { name: slug, slug, org: "" },
138
+ spec: { description: "created by the organization delete test" },
139
+ });
140
+ await platform.bootstrapPolicy(orgRole(OUTSIDER, "member", slug));
141
+ await platform.bootstrapPolicy(
142
+ triple({ kind: "organization", id: slug }, "organization", {
143
+ kind: "agent",
144
+ id: AGENT,
145
+ }),
146
+ );
147
+ }
148
+
149
+ beforeAll(async () => {
150
+ dir = mkdtempSync(path.join(tmpdir(), "organization-delete-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
+ transport = createGrpcTransport({ baseUrl: `http://127.0.0.1:${port}` });
168
+ organizations = createClient(OrganizationCommandController, transport);
169
+ organizationQuery = createClient(OrganizationQueryController, transport);
170
+ platform = createClient(
171
+ IamPolicyCommandController,
172
+ server.inProcessTransport,
173
+ );
174
+ });
175
+
176
+ afterAll(async () => {
177
+ await server.shutdown();
178
+ resetOperatorIdentityForTests();
179
+ rmSync(dir, { recursive: true, force: true });
180
+ });
181
+
182
+ beforeEach(() => {
183
+ faultingDelete = undefined;
184
+ });
185
+
186
+ it("runs the slot with the organization and its rows in place, then removes every row naming it", async () => {
187
+ await organizationWithRows("delete-clean");
188
+ expect(rowsNaming("delete-clean")).toHaveLength(3);
189
+
190
+ await organizations.delete({ value: "delete-clean" });
191
+
192
+ expect(seenBySlot.get("delete-clean")).toEqual({
193
+ loaded: "delete-clean",
194
+ rowsNaming: 3,
195
+ });
196
+ expect(rowsNaming("delete-clean")).toEqual([]);
197
+ const gone = await grpcError(() =>
198
+ organizationQuery.get({ value: "delete-clean" }),
199
+ );
200
+ expect(gone.code).toBe(Code.NotFound);
201
+ });
202
+
203
+ it("a slot step's refusal answers its own copy and leaves the organization and its rows", async () => {
204
+ await organizationWithRows(REFUSED_SLUG);
205
+
206
+ const refused = await grpcError(() =>
207
+ organizations.delete({ value: REFUSED_SLUG }),
208
+ );
209
+
210
+ expect(refused.code).toBe(Code.FailedPrecondition);
211
+ expect(refused.rawMessage).toBe(
212
+ "fake edition keeps something for this organization",
213
+ );
214
+ expect(
215
+ (await organizationQuery.get({ value: REFUSED_SLUG })).metadata?.id,
216
+ ).toBe(REFUSED_SLUG);
217
+ expect(rowsNaming(REFUSED_SLUG)).toHaveLength(3);
218
+ });
219
+
220
+ it("a revocation fault fails the delete with the organization and its owner in place, and the retry completes it", async () => {
221
+ await organizationWithRows("delete-faults");
222
+ const member = rowsNaming("delete-faults").find(
223
+ (row) => row.spec?.relation === "member",
224
+ );
225
+ faultingDelete = member?.metadata?.id;
226
+
227
+ const failed = await grpcError(() =>
228
+ organizations.delete({ value: "delete-faults" }),
229
+ );
230
+
231
+ expect(failed.code).toBe(Code.Internal);
232
+ expect(failed.rawMessage).toBe(
233
+ "failed to remove the organization's access policies",
234
+ );
235
+ expect(
236
+ (await organizationQuery.get({ value: "delete-faults" })).metadata?.id,
237
+ ).toBe("delete-faults");
238
+ // The agent's scope link went first; the revocation stopped at the
239
+ // member row, before the owner row the retry needs.
240
+ expect(
241
+ rowsNaming("delete-faults")
242
+ .map((row) => row.spec?.relation)
243
+ .sort(),
244
+ ).toEqual(["member", "owner"]);
245
+
246
+ faultingDelete = undefined;
247
+ await organizations.delete({ value: "delete-faults" });
248
+ expect(rowsNaming("delete-faults")).toEqual([]);
249
+ });
250
+
251
+ it("a slug freed by the delete and created again carries none of the old organization's rows", async () => {
252
+ await organizationWithRows("delete-reborn");
253
+ await organizations.delete({ value: "delete-reborn" });
254
+
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
+ });
261
+
262
+ expect(
263
+ rowsNaming("delete-reborn").map((row) => row.spec?.relation),
264
+ ).toEqual(["owner"]);
265
+ });
266
+ });
@@ -54,6 +54,7 @@ import type { ResolvedGateSteps } from "../../extensions/gate-slots.js";
54
54
  import { stepsForSlot } from "../../extensions/gate-slots.js";
55
55
  import { ALL_ORGANIZATIONS } from "../../extensions/organization-directory.js";
56
56
  import type { OrganizationDirectory } from "../../extensions/organization-directory.js";
57
+ import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
57
58
  import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
58
59
  import { apiResourceKindKey } from "../../pipeline/interceptors/apiresource.js";
59
60
  import {
@@ -105,15 +106,21 @@ import { newValidateVisibilityStep } from "../../pipeline/steps/validate-visibil
105
106
  import { ResourceNotFoundError } from "../../store/interface.js";
106
107
  import type { Store } from "../../store/interface.js";
107
108
  import { organizationSearchExtractor } from "./search-extractor.js";
108
- import { newCheckOrgDuplicateStep, newCopySlugToIdStep } from "./steps.js";
109
+ import {
110
+ newCheckOrgDuplicateStep,
111
+ newCopySlugToIdStep,
112
+ newRevokeOrganizationPoliciesStep,
113
+ } from "./steps.js";
109
114
 
110
115
  export interface OrganizationControllerDeps {
111
116
  readonly store: Store;
112
117
  readonly logger: Logger;
113
118
  /** The composed authorization seam — the Authorize step at position 1 of every chain calls it (O2, DD-007 §3). */
114
119
  readonly authorizer: Authorizer;
115
- /** The composed slot registrations — this domain's post-persist slot (O4). */
120
+ /** The composed slot registrations — this domain's create and delete slots (O4). */
116
121
  readonly gateSteps: ResolvedGateSteps;
122
+ /** The one grant path: the delete revokes the organization's rows through it before its row goes. */
123
+ readonly grantPath: IamPolicyGrantPath;
117
124
  /** The composed tuple-lifecycle driver — undefined = the shared steps no-op (C2). */
118
125
  readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
119
126
  /** The composed query directory — undefined = OSS single-tenant behavior (C2). */
@@ -319,22 +326,38 @@ async function apply(
319
326
  : update(deps, withResolvedApplyId(OrganizationSchema, org, reqCtx), ctx);
320
327
  }
321
328
 
322
- /** Delete — returns the deleted organization (gRPC audit-trail convention). */
329
+ /**
330
+ * Delete — returns the deleted organization (gRPC audit-trail convention).
331
+ *
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:
336
+ *
337
+ * 1. the `org-delete:pre-delete` slot, where an edition refuses or
338
+ * removes the rows it keeps for the organization (empty in OSS);
339
+ * 2. RevokeOrganizationPolicies, every policy row naming the
340
+ * organization, through the grant path, never caught;
341
+ * 3. the row;
342
+ * 4. CleanupIamPolicies, the lifecycle's post-delete event: best-effort
343
+ * like every delete chain's, it revokes whatever a concurrent write
344
+ * named the organization with after step 2, and runs a composed
345
+ * driver's post-delete companions;
346
+ * 5. the search entry.
347
+ */
323
348
  async function deleteOrganization(
324
349
  deps: OrganizationControllerDeps,
325
350
  orgId: OrganizationId,
326
351
  ctx: HandlerContext,
327
352
  ): Promise<Organization> {
353
+ type DeleteInput = typeof OrganizationCommandController.method.delete.input;
328
354
  const reqCtx = new RequestContext(
329
355
  OrganizationCommandController.method.delete.input,
330
356
  orgId,
331
357
  callerIdentityOf(ctx),
332
358
  kindOf(ctx),
333
359
  );
334
- await newPipeline<typeof OrganizationCommandController.method.delete.input>(
335
- "organization-delete",
336
- deps.logger,
337
- )
360
+ const builder = newPipeline<DeleteInput>("organization-delete", deps.logger)
338
361
  .addStep(
339
362
  newAuthorizeStep(
340
363
  OrganizationCommandController.method.delete,
@@ -343,7 +366,16 @@ async function deleteOrganization(
343
366
  )
344
367
  .addStep(newValidateProtoStep())
345
368
  .addStep(newExtractResourceIdStep())
346
- .addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema))
369
+ .addStep(newLoadExistingForDeleteStep(deps.store, OrganizationSchema));
370
+ // The pre-delete gate slot (see the doc comment above). Empty in OSS.
371
+ for (const step of stepsForSlot<DeleteInput>(
372
+ deps.gateSteps,
373
+ "org-delete:pre-delete",
374
+ )) {
375
+ builder.addStep(step);
376
+ }
377
+ await builder
378
+ .addStep(newRevokeOrganizationPoliciesStep<DeleteInput>(deps.grantPath))
347
379
  .addStep(newDeleteResourceStep(deps.store))
348
380
  .addStep(
349
381
  newCleanupIamPoliciesStep(deps.authorizationLifecycle, deps.logger),
@@ -10,16 +10,28 @@
10
10
  * uniqueness guarantee, mirroring cloud's OrganizationCreateHandler
11
11
  * (CheckDuplicate + CopySlugToId) step-for-step.
12
12
  *
13
- * Proven by organization.conformance.test.ts (CONFORMANCE_TARGET=local)
14
- * and __tests__/organization.test.ts.
13
+ * The same deviation shapes the delete: a freed slug is anyone's to take,
14
+ * so newRevokeOrganizationPoliciesStep revokes the organization's policy
15
+ * rows BEFORE its row is deleted, and fails the delete when it cannot.
16
+ *
17
+ * Proven by organization.conformance.test.ts (CONFORMANCE_TARGET=local),
18
+ * __tests__/organization.test.ts and __tests__/organization-delete.test.ts.
15
19
  */
20
+ import { create } from "@bufbuild/protobuf";
21
+ import type { DescMessage } from "@bufbuild/protobuf";
22
+
16
23
  import { ResourceNotFoundError } from "../../store/interface.js";
17
24
  import type { Store } from "../../store/interface.js";
18
25
  import { alreadyExistsError, internalError } from "../../pipeline/errors.js";
19
26
  import type { PipelineStep } from "../../pipeline/pipeline.js";
20
27
  import type { RequestContext } from "../../pipeline/request-context.js";
28
+ import { EXISTING_RESOURCE_KEY } from "../../pipeline/steps/load-existing.js";
21
29
  import { metadataOf } from "../../pipeline/steps/shapes.js";
30
+ import type { IamPolicyGrantPath } from "../iampolicy/grant-path.js";
22
31
 
32
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
33
+ import { ApiResourceRefSchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/spec_pb";
34
+ import type { Organization } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
23
35
  import { OrganizationSchema } from "@stigmer/protos/ai/stigmer/tenancy/organization/v1/api_pb";
24
36
 
25
37
  /**
@@ -98,3 +110,55 @@ export function newCopySlugToIdStep(): PipelineStep<typeof OrganizationSchema> {
98
110
  },
99
111
  };
100
112
  }
113
+
114
+ /**
115
+ * Revokes every policy row that names the organization, as principal or as
116
+ * resource, before the delete removes its row: the rows its members hold
117
+ * on it, and the scope link every organization-scoped resource holds to
118
+ * it. Runs after the `org-delete:pre-delete` slot and before
119
+ * DeleteResource.
120
+ *
121
+ * The generic delete chains clean up after the row and log a fault,
122
+ * because a deleted resource's rows grant nothing once it is gone. An
123
+ * organization is the exception: its id is its slug, the delete frees the
124
+ * slug, and a row left behind would grant whoever creates the slug next.
125
+ * So this step does not catch. A fault fails the delete with the
126
+ * organization in place, and a retry resumes where the revocation stopped
127
+ * (the grant path revokes the organization's owners last, so the owner
128
+ * who retries still can). The grant path fires the composed driver's
129
+ * `onPolicyRevoked` before each row, so a composition's tuples go with
130
+ * their rows. The post-delete CleanupIamPolicies still runs: it catches
131
+ * whatever a concurrent write named the organization with in between.
132
+ */
133
+ export function newRevokeOrganizationPoliciesStep<Desc extends DescMessage>(
134
+ grantPath: IamPolicyGrantPath,
135
+ ): PipelineStep<Desc> {
136
+ return {
137
+ name: "RevokeOrganizationPolicies",
138
+ async execute(ctx: RequestContext<Desc>): Promise<void> {
139
+ const organization = ctx.get(EXISTING_RESOURCE_KEY) as
140
+ | Organization
141
+ | undefined;
142
+ const id = organization?.metadata?.id ?? "";
143
+ if (id === "") {
144
+ throw internalError(
145
+ new Error("organization delete reached RevokeOrganizationPolicies without its loaded row"),
146
+ "failed to remove the organization's access policies",
147
+ );
148
+ }
149
+ try {
150
+ await grantPath.cleanupResource(
151
+ create(ApiResourceRefSchema, {
152
+ kind: ApiResourceKind[ApiResourceKind.organization],
153
+ id,
154
+ }),
155
+ );
156
+ } catch (error) {
157
+ throw internalError(
158
+ error,
159
+ "failed to remove the organization's access policies",
160
+ );
161
+ }
162
+ },
163
+ };
164
+ }