@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,133 @@
1
+ /**
2
+ * Platform rows: the MESSAGE_SYSTEM lines the server itself writes into
3
+ * an execution's transcript, and the transcript rules that keep them.
4
+ * The runner owns the transcript; these are the few rows only the
5
+ * platform can write, because only the platform knows the fact they
6
+ * state (stigmer#980).
7
+ *
8
+ * - Stop rows say why a run stopped when the stop came from outside the
9
+ * turn. The runner cannot tell a Pause from a Cancel: both reach its
10
+ * activity as the same Temporal cancellation (reason CANCELLED), and
11
+ * both RPCs signal the engine before they persist the phase
12
+ * (lifecycle.ts), so no stored fact distinguishes them in time. The
13
+ * invoke workflow knows which stop it is running, so it writes the row
14
+ * (workflows/invoke-agent-execution.ts) and the runner writes none.
15
+ * - A platform marker write is a status update whose messages are all
16
+ * MESSAGE_SYSTEM and do not extend the stored transcript: the
17
+ * workflow's stop rows and its broken-flow failure lines. A marker is
18
+ * appended, never substituted for the transcript. Every runner write
19
+ * is a superset of the stored transcript (a resumed runner seeds from
20
+ * it, runner/src/harness/turn-context.ts) and the runner only ever
21
+ * appends system rows, so a runner write never reads as a marker.
22
+ *
23
+ * Messages carry no id (agentexecution/v1/message.proto), so rows are
24
+ * identified by type and content: the stop copy is these exact
25
+ * constants, defined once here for the workflow and the merge, and never
26
+ * matched against copy some other component authors.
27
+ *
28
+ * Pure (protos only): the workflow bundle imports this module by direct
29
+ * path (src/temporal/README.md, workflow-bundle import discipline).
30
+ */
31
+ import {
32
+ ExecutionPhase,
33
+ MessageType,
34
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
35
+ import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
36
+
37
+ /** The stop row of a paused execution. Byte-identical to the copy the runner wrote before stigmer#980 moved it here. */
38
+ export const PAUSE_STOP_ROW =
39
+ "Execution paused by user. Use resume to continue.";
40
+
41
+ /** The stop row of a cancelled execution: a quiet terminal state, never a failure (stigmer#282). */
42
+ export const CANCEL_STOP_ROW = "Execution was cancelled.";
43
+
44
+ /** The stop row a phase carries, or undefined for a phase the platform does not explain. */
45
+ export function stopRowForPhase(phase: ExecutionPhase): string | undefined {
46
+ switch (phase) {
47
+ case ExecutionPhase.EXECUTION_PAUSED:
48
+ return PAUSE_STOP_ROW;
49
+ case ExecutionPhase.EXECUTION_CANCELLED:
50
+ return CANCEL_STOP_ROW;
51
+ default:
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Whether two rows are the same row: messages carry no id, so identity is type and content. */
57
+ function sameRow(a: AgentMessage, b: AgentMessage): boolean {
58
+ return a.type === b.type && a.content === b.content;
59
+ }
60
+
61
+ /** Whether transcript ends with tail, row for row. */
62
+ export function endsWithRows(
63
+ transcript: readonly AgentMessage[],
64
+ tail: readonly AgentMessage[],
65
+ ): boolean {
66
+ if (tail.length > transcript.length) {
67
+ return false;
68
+ }
69
+ const offset = transcript.length - tail.length;
70
+ return tail.every((row, i) => sameRow(transcript[offset + i], row));
71
+ }
72
+
73
+ /**
74
+ * Whether incoming is a platform marker write against existing: every
75
+ * incoming row is MESSAGE_SYSTEM, and it does not extend a non-empty
76
+ * stored transcript (existing is not a row-for-row prefix of incoming).
77
+ * An all-system write that does extend it is the runner's own growth; a
78
+ * write onto an empty transcript has nothing to preserve.
79
+ */
80
+ export function isPlatformMarkerWrite(
81
+ existing: readonly AgentMessage[],
82
+ incoming: readonly AgentMessage[],
83
+ ): boolean {
84
+ if (existing.length === 0 || incoming.length === 0) {
85
+ return false;
86
+ }
87
+ if (!incoming.every((row) => row.type === MessageType.MESSAGE_SYSTEM)) {
88
+ return false;
89
+ }
90
+ const extendsExisting =
91
+ incoming.length >= existing.length &&
92
+ existing.every((row, i) => sameRow(row, incoming[i]));
93
+ return !extendsExisting;
94
+ }
95
+
96
+ /**
97
+ * Appends a marker write's rows to the transcript; a retry of a marker
98
+ * already at the transcript's end appends nothing, so the workflow's
99
+ * activity retries stay idempotent.
100
+ */
101
+ export function appendPlatformMarker(
102
+ existing: readonly AgentMessage[],
103
+ marker: readonly AgentMessage[],
104
+ ): AgentMessage[] {
105
+ if (endsWithRows(existing, marker)) {
106
+ return [...existing];
107
+ }
108
+ return [...existing, ...marker];
109
+ }
110
+
111
+ /**
112
+ * The stop row the merge holds last: the stored transcript's final row
113
+ * when it is the stored phase's own stop row. While the execution rests
114
+ * in that phase, the row is the platform's, not part of what the runner's
115
+ * later writes replace; once the phase moves on (a resume), it is
116
+ * ordinary history and nothing is held.
117
+ */
118
+ export function heldStopRow(
119
+ phase: ExecutionPhase,
120
+ transcript: readonly AgentMessage[],
121
+ ): AgentMessage | undefined {
122
+ const copy = stopRowForPhase(phase);
123
+ const last = transcript[transcript.length - 1];
124
+ if (
125
+ copy === undefined ||
126
+ last === undefined ||
127
+ last.type !== MessageType.MESSAGE_SYSTEM ||
128
+ last.content !== copy
129
+ ) {
130
+ return undefined;
131
+ }
132
+ return last;
133
+ }
@@ -46,10 +46,12 @@ import type { Logger } from "../../boot/logger.js";
46
46
  import type { Authorizer } from "../../extensions/authorizer.js";
47
47
  import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
48
48
  import {
49
+ internalError,
49
50
  invalidArgumentError,
50
51
  notFoundError,
51
52
  } from "../../pipeline/errors.js";
52
53
  import { authorizeDirect } from "../../pipeline/steps/authorize.js";
54
+ import { ResourceNotFoundError } from "../../store/interface.js";
53
55
  import type { Store } from "../../store/interface.js";
54
56
 
55
57
  import { isTranscriptTerminalPhase } from "./phases.js";
@@ -97,9 +99,11 @@ export async function* subscribeExecution(
97
99
  id,
98
100
  AgentExecutionSchema,
99
101
  );
100
- } catch {
101
- // Go converts every load failure here to the same NotFound.
102
- throw notFoundError("AgentExecution", id);
102
+ } catch (error) {
103
+ if (error instanceof ResourceNotFoundError) {
104
+ throw notFoundError("AgentExecution", id);
105
+ }
106
+ throw internalError(error, "failed to load agent execution");
103
107
  }
104
108
 
105
109
  yield snapshot;
@@ -2,8 +2,8 @@
2
2
  * UpdateStatus — ports controller/update_status.go: the runner's
3
3
  * progressive status-update RPC and the domain's single merge chokepoint.
4
4
  *
5
- * Chain per Go: ValidateUpdateStatusInput → MergeAndPersistExecution →
6
- * NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
5
+ * Chain: Authorize → ValidateUpdateStatusInput → MergeAndPersistExecution
6
+ * → NotifyStatusObservers → BroadcastToStreams. The merge+persist is ONE
7
7
  * atomic read-modify-write under the store's per-resource write lock
8
8
  * (store.updateResource) — the same discipline SubmitApproval uses,
9
9
  * load-bearing now that the append-only approval_event_stream is the
@@ -24,10 +24,14 @@
24
24
  * wholesale with the runner's latest complete state, while server-owned
25
25
  * fields (approval decisions, the approval_event_stream, the file_review
26
26
  * ledger) are preserved/authored here because the runner never sends
27
- * them.
27
+ * them. The transcript's platform rows are the same kind of server-owned
28
+ * state (platform-rows.ts, stigmer#980): a platform marker write is
29
+ * appended and never replaces the transcript, and a PAUSED or CANCELLED
30
+ * execution's stop row stays last across the runner's later writes,
31
+ * because the Pause and Cancel RPCs, the invoke workflow and the runner
32
+ * all write a stopped execution and nothing orders them.
28
33
  *
29
- * Versus Stigmer Cloud, OSS excludes the Authorize, PublishToRedis, and
30
- * Publish steps (broadcast rides in-memory channels per ADR 011).
34
+ * Broadcast rides in-memory channels (ADR 011).
31
35
  */
32
36
  import { create } from "@bufbuild/protobuf";
33
37
 
@@ -78,6 +82,12 @@ import {
78
82
  isTerminalExecutionPhase,
79
83
  isTranscriptTerminalPhase,
80
84
  } from "./phases.js";
85
+ import {
86
+ appendPlatformMarker,
87
+ endsWithRows,
88
+ heldStopRow,
89
+ isPlatformMarkerWrite,
90
+ } from "./platform-rows.js";
81
91
  import {
82
92
  applyResponseDecorators,
83
93
  notifyStatusObservers,
@@ -257,6 +267,54 @@ export function nonTerminalTranscriptRegression(
257
267
  return { reject: false, reason: "" };
258
268
  }
259
269
 
270
+ /**
271
+ * The transcript the merge persists, or undefined to keep the stored one.
272
+ * The stored phase's stop row, when it closes the transcript, is held
273
+ * aside so the runner's settle write (which never carries it) is judged
274
+ * against the transcript beneath it, and re-appended after. A platform
275
+ * marker write is appended; anything else is the runner's write, replaced
276
+ * wholesale unless the regression guard rejects it.
277
+ */
278
+ function mergeTranscript(
279
+ existingPhase: ExecutionPhase,
280
+ existing: AgentMessage[],
281
+ incoming: AgentMessage[],
282
+ executionId: string,
283
+ logger: Logger,
284
+ ): AgentMessage[] | undefined {
285
+ const held = heldStopRow(existingPhase, existing);
286
+ const base = held === undefined ? existing : existing.slice(0, -1);
287
+
288
+ let next: AgentMessage[];
289
+ if (isPlatformMarkerWrite(base, incoming)) {
290
+ next = appendPlatformMarker(base, incoming);
291
+ } else {
292
+ const { reject, reason } = nonTerminalTranscriptRegression(
293
+ existingPhase,
294
+ base,
295
+ incoming,
296
+ );
297
+ if (reject) {
298
+ logger.warn(
299
+ "Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
300
+ {
301
+ executionId,
302
+ existingMessages: existing.length,
303
+ incomingMessages: incoming.length,
304
+ reason,
305
+ },
306
+ );
307
+ return undefined;
308
+ }
309
+ next = incoming;
310
+ }
311
+
312
+ if (held !== undefined && !endsWithRows(next, [held])) {
313
+ next = [...next, held];
314
+ }
315
+ return next;
316
+ }
317
+
260
318
  /**
261
319
  * Merges an incoming status update into the execution in place — the
262
320
  * runner-owns-the-transcript merge rules, exactly update_status.go
@@ -294,7 +352,8 @@ export function applyUpdateStatusMerge(
294
352
  // any update that would drop committed transcript history for a
295
353
  // non-terminal execution. The runner owns the transcript and only ever
296
354
  // GROWS it in flight; two regressions are rejected at this single
297
- // persistence chokepoint:
355
+ // persistence chokepoint (the platform's own rows are handled first,
356
+ // see mergeTranscript):
298
357
  // 1. A strictly SHORTER transcript — the classic partial write.
299
358
  // 2. A transcript that DROPS a committed tool-call id while appending
300
359
  // enough later turns to keep the count equal-or-greater
@@ -305,23 +364,15 @@ export function applyUpdateStatusMerge(
305
364
  // post-denial narration redaction), so a content check would reject
306
365
  // valid writes.
307
366
  if (requestStatus.messages.length > 0) {
308
- const { reject, reason } = nonTerminalTranscriptRegression(
367
+ const merged = mergeTranscript(
309
368
  existingPhase,
310
369
  existingMessages,
311
370
  requestStatus.messages,
371
+ input.executionId,
372
+ logger,
312
373
  );
313
- if (reject) {
314
- logger.warn(
315
- "Rejected status update that would drop committed transcript history for a non-terminal execution; keeping existing messages",
316
- {
317
- executionId: input.executionId,
318
- existingMessages: existingMessages.length,
319
- incomingMessages: requestStatus.messages.length,
320
- reason,
321
- },
322
- );
323
- } else {
324
- status.messages = requestStatus.messages;
374
+ if (merged !== undefined) {
375
+ status.messages = merged;
325
376
  }
326
377
  }
327
378
 
@@ -14,8 +14,11 @@
14
14
  * fail-open incident class). The absent arm fires the hook with the
15
15
  * spec and no policy, so a composition can still delete a bare tuple
16
16
  * written before the row mirror existed.
17
- * - cleanupResource is bidirectional (as target AND as principal),
18
- * deduplicated, revoking each row through the same revoke order.
17
+ * - cleanupResource is bidirectional (as principal first, then as
18
+ * target with the owner rows last), deduplicated, revoking each row
19
+ * through the same revoke order; a fault stops it with the owners
20
+ * still holding, since an organization's delete runs it before the
21
+ * organization's row goes and its owner is the one who retries.
19
22
  * - revokeOrgAccess revokes the account's direct rows on the
20
23
  * organization; the inert org-column arm the cloud carried is gone
21
24
  * (Q-OR-9).
@@ -378,17 +381,19 @@ describe("revoke: onPolicyRevoked before the row delete", () => {
378
381
  });
379
382
 
380
383
  describe("cleanupResource: bidirectional, deduplicated, each row through the revoke order", () => {
381
- it("revokes rows where the ref is the resource AND rows where it is the principal", async () => {
384
+ it("revokes rows where the ref is the principal, then where it is the resource, owners last", async () => {
382
385
  const recorded: RecordedEvent[] = [];
383
386
  const { policies, path } = pathOver(recorded, recordingLifecycle(recorded));
384
- const asResource = orgRole(ALICE, "owner", "acme");
387
+ const owner = orgRole(ALICE, "owner", "acme");
388
+ const member = orgRole(BOB, "member", "acme");
385
389
  const asPrincipal = triple(
386
390
  { kind: "organization", id: "acme" },
387
391
  "organization",
388
392
  { kind: "agent", id: AGENT },
389
393
  );
390
394
  const unrelated = orgRole(ALICE, "member", "globex");
391
- for (const spec of [asResource, asPrincipal, unrelated]) {
395
+ // Granted owner first, so the order below is the cleanup's, not the store's.
396
+ for (const spec of [owner, member, asPrincipal, unrelated]) {
392
397
  await path.grant(spec, alice);
393
398
  }
394
399
  recorded.length = 0;
@@ -399,17 +404,45 @@ describe("cleanupResource: bidirectional, deduplicated, each row through the rev
399
404
 
400
405
  expect([...policies.rows.keys()]).toEqual([policyIdFor(unrelated)]);
401
406
  expect(recorded).toEqual([
402
- { kind: "revoked", id: policyIdFor(asResource), relation: "owner" },
403
- { kind: "row-delete", id: policyIdFor(asResource) },
404
407
  {
405
408
  kind: "revoked",
406
409
  id: policyIdFor(asPrincipal),
407
410
  relation: "organization",
408
411
  },
409
412
  { kind: "row-delete", id: policyIdFor(asPrincipal) },
413
+ { kind: "revoked", id: policyIdFor(member), relation: "member" },
414
+ { kind: "row-delete", id: policyIdFor(member) },
415
+ { kind: "revoked", id: policyIdFor(owner), relation: "owner" },
416
+ { kind: "row-delete", id: policyIdFor(owner) },
410
417
  ]);
411
418
  });
412
419
 
420
+ it("stops at the first fault with the owner rows still in place", async () => {
421
+ const recorded: RecordedEvent[] = [];
422
+ const lifecycle = recordingLifecycle(recorded);
423
+ const { policies, path } = pathOver(recorded, {
424
+ ...lifecycle,
425
+ onPolicyRevoked: (event) =>
426
+ event.spec.relation === "member"
427
+ ? Promise.reject(new Error("tuple delete refused"))
428
+ : lifecycle.onPolicyRevoked!(event),
429
+ });
430
+ const owner = orgRole(ALICE, "owner", "acme");
431
+ const member = orgRole(BOB, "member", "acme");
432
+ for (const spec of [owner, member]) {
433
+ await path.grant(spec, alice);
434
+ }
435
+
436
+ await expect(
437
+ path.cleanupResource(
438
+ create(ApiResourceRefSchema, { kind: "organization", id: "acme" }),
439
+ ),
440
+ ).rejects.toThrow("tuple delete refused");
441
+
442
+ expect(policies.rows.has(policyIdFor(owner))).toBe(true);
443
+ expect(policies.rows.has(policyIdFor(member))).toBe(true);
444
+ });
445
+
413
446
  it("a ref that is both principal and resource of one row revokes it once", async () => {
414
447
  const recorded: RecordedEvent[] = [];
415
448
  const { path } = pathOver(recorded, recordingLifecycle(recorded));
@@ -59,6 +59,7 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
59
59
  import { ApiResourceMetadataSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/metadata_pb";
60
60
  import type { IamPolicy } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
61
61
  import { IamPolicySchema } from "@stigmer/protos/ai/stigmer/iam/iampolicy/v1/api_pb";
62
+ import { IamRole } from "@stigmer/protos/ai/stigmer/iam/v1/enum_pb";
62
63
  import type {
63
64
  ApiResourceRef,
64
65
  IamPolicySpec,
@@ -78,6 +79,9 @@ import { DuplicatePolicyError } from "./store.js";
78
79
  import type { IamPolicyStore } from "./store.js";
79
80
  import { refsOf, requireWellFormedTriple } from "./wire-refusals.js";
80
81
 
82
+ /** The relation `cleanupResource` revokes last on the ref's own rows. */
83
+ const OWNER_RELATION = IamRole[IamRole.owner];
84
+
81
85
  export interface GrantResult {
82
86
  readonly policy: IamPolicy;
83
87
  /** True when the triple was already held: no row was written, the hook still fired. */
@@ -99,9 +103,13 @@ export interface IamPolicyGrantPath {
99
103
  */
100
104
  revokeBySpec(spec: IamPolicySpec): Promise<IamPolicy | undefined>;
101
105
  /**
102
- * Revoke every row naming the ref as its resource, then every row naming
103
- * it as its principal, each through the revoke order; a row that is both
104
- * is revoked once. Nothing to clean is a no-op.
106
+ * Revoke every row naming the ref as its principal, then every row naming
107
+ * it as its resource with the `owner` rows last, each through the revoke
108
+ * order; a row that is both is revoked once. Nothing to clean is a no-op.
109
+ * The order matters where the ref still exists: an organization's delete
110
+ * runs this before its row goes and fails on the first fault, so a
111
+ * cleanup that stops partway has not yet removed the owners who may
112
+ * retry it.
105
113
  */
106
114
  cleanupResource(ref: ApiResourceRef): Promise<void>;
107
115
  /** Revoke every relation the account holds directly on the organization. */
@@ -209,9 +217,15 @@ export function newIamPolicyGrantPath(
209
217
  },
210
218
 
211
219
  async cleanupResource(ref): Promise<void> {
212
- const asResource = await policies.findByResource(ref.kind, ref.id);
213
220
  const asPrincipal = await policies.findByPrincipal(ref.kind, ref.id);
214
- for (const policy of distinctById([...asResource, ...asPrincipal])) {
221
+ const asResource = await policies.findByResource(ref.kind, ref.id);
222
+ const isOwner = (policy: IamPolicy): boolean =>
223
+ policy.spec?.relation === OWNER_RELATION;
224
+ for (const policy of distinctById([
225
+ ...asPrincipal,
226
+ ...asResource.filter((policy) => !isOwner(policy)),
227
+ ...asResource.filter(isOwner),
228
+ ])) {
215
229
  await revokeRow(policy);
216
230
  }
217
231
  },