@aldus-runtime/release 0.2.0-next.20 → 0.2.0-next.21

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.
package/src/executor.ts CHANGED
@@ -26,7 +26,8 @@
26
26
  import type { ActorRef, AldusEvent, ReleaseReceipt } from "@aldus-runtime/core";
27
27
  import { SCHEMA_VERSION, newEventId, newReleaseId } from "@aldus-runtime/core";
28
28
 
29
- import type { AdapterRegistry, AdapterOutcome, ReleaseRequest } from "./adapter.js";
29
+ import { cannotEstablish, isCannotEstablish } from "./adapter.js";
30
+ import type { AdapterRegistry, AdapterOutcome, ReleaseRequest, RemoteState } from "./adapter.js";
30
31
  import { assertBundleValid, deriveIdempotencyKey, type ReleaseBundle } from "./bundle.js";
31
32
  import { ReleaseErrorCodes, releaseError } from "./errors.js";
32
33
  import type { OperationCriticality, ReleaseOperation } from "./operation.js";
@@ -78,6 +79,37 @@ export interface BundleStatus {
78
79
  }
79
80
 
80
81
  /** What reconciliation did about one operation. */
82
+ /**
83
+ * Which reconciliation findings an operator is warned about (§20; #169).
84
+ *
85
+ * An **allow-list**, keyed on the action. It was a deny-list — any finding carrying an explanation
86
+ * except `confirmed_absent` — so every action added later was opted in by default, with nobody
87
+ * deciding. `not_reconciled_repeatable` was opted in exactly that way, and appeared in the
88
+ * warnings of every ordinary release of a bundle holding a repeatable operation, reporting that
89
+ * the expected thing had happened.
90
+ *
91
+ * Membership answers the question a warning asks: **did something go differently than intended?**
92
+ *
93
+ * - `repaired` — yes. The local record was wrong and had to be corrected.
94
+ * - `unavailable` — yes. Reconciliation could not run, so an operator is being told that a
95
+ * protection they may assume is absent.
96
+ * - `confirmed_absent` — no. The ordinary path; most operations in a fresh bundle are absent, and
97
+ * one line each would bury the two above.
98
+ * - `not_reconciled_repeatable` — no. The declared, approved state of the bundle. The reason is in
99
+ * the reconciliation report for anyone reading it.
100
+ *
101
+ * Adding an action now requires either adding it here or leaving it out, and both are decisions.
102
+ * Neither happens by default.
103
+ */
104
+ const OPERATOR_FACING_FINDINGS: ReadonlySet<ReconciliationFinding["action"]> = new Set([
105
+ "repaired",
106
+ "unavailable",
107
+ // Yes. The operation may or may not have happened and nobody can tell — the state an operator
108
+ // most needs to see, and the one the deny-list would have surfaced by accident rather than by
109
+ // decision (#169).
110
+ "cannot_establish",
111
+ ]);
112
+
81
113
  export interface ReconciliationFinding {
82
114
  operationId: string;
83
115
  idempotencyKey: string;
@@ -89,7 +121,21 @@ export interface ReconciliationFinding {
89
121
  /** The destination does not hold it, so the operation genuinely still needs to run. */
90
122
  | "confirmed_absent"
91
123
  /** The adapter cannot query the destination (contract §17 "where the platform allows it"). */
92
- | "unavailable";
124
+ | "unavailable"
125
+ /**
126
+ * The operation declares its effect safe to repeat, so it was not queried (#169).
127
+ *
128
+ * Deliberately its own action rather than folded into `confirmed_absent`. Nothing was
129
+ * searched for, and saying it was absent would be the false statement this exists to stop.
130
+ */
131
+ | "not_reconciled_repeatable"
132
+ /**
133
+ * The adapter was asked and could not establish whether the operation happened (#169).
134
+ *
135
+ * Distinct from `unavailable`, which is a destination that cannot be queried at all, and from
136
+ * `confirmed_absent`, which is a completed search. This one asked and got no answer.
137
+ */
138
+ | "cannot_establish";
93
139
  explanation?: string;
94
140
  }
95
141
 
@@ -218,7 +264,7 @@ export class ReleaseExecutor {
218
264
  const findings: ReconciliationFinding[] = [];
219
265
  const repaired: ReleaseReceipt[] = [];
220
266
 
221
- for (const { operation, idempotencyKey } of this.#plan(bundle)) {
267
+ for (const { operation, criticality, idempotencyKey } of this.#plan(bundle)) {
222
268
  const receipt = latest.get(idempotencyKey);
223
269
  if (receipt !== undefined && receipt.status !== "pending") {
224
270
  findings.push({
@@ -229,6 +275,23 @@ export class ReleaseExecutor {
229
275
  continue;
230
276
  }
231
277
 
278
+ // Asked of nothing, because the answer would change nothing. A repeatable operation may be
279
+ // performed again whatever the destination holds, so querying it buys no decision — and
280
+ // where reconciliation runs before execution, as it does inside `execute`, a "not there"
281
+ // reading can mean "the operation before it has not run yet" rather than "it did not
282
+ // happen". An adapter forced to answer that question has to invent one (#169).
283
+ if (operation.repeatable !== undefined) {
284
+ findings.push({
285
+ operationId: operation.operationId,
286
+ idempotencyKey,
287
+ action: "not_reconciled_repeatable",
288
+ explanation:
289
+ `"${operation.operationId}" declares its effect safe to repeat, so the destination ` +
290
+ `was not queried: ${operation.repeatable.reason}`,
291
+ });
292
+ continue;
293
+ }
294
+
232
295
  const adapter = this.#adapters.require(operation.destination);
233
296
  if (adapter.lookup === undefined) {
234
297
  findings.push({
@@ -247,8 +310,106 @@ export class ReleaseExecutor {
247
310
  idempotencyKey,
248
311
  runId: bundle.runId,
249
312
  };
250
- const remote = await adapter.lookup(request);
313
+ // Guarded, and the guard is criticality-aware. `reconcile` used to `await` this bare, so a
314
+ // throw from any operation's `lookup` aborted the whole pass before a single `execute` —
315
+ // a quota error raised while asking about a tidy-up blocked the upload beside it (#169).
316
+ //
317
+ // A required operation's throw still aborts. Fail-closed belongs where a mistake publishes
318
+ // twice; it does not belong where a best-effort tidy-up could not be queried.
319
+ let remote: RemoteState;
320
+ try {
321
+ remote = await adapter.lookup(request);
322
+ } catch (thrown) {
323
+ if (criticality === "required") throw thrown;
324
+ remote = cannotEstablish(
325
+ `querying destination "${operation.destination}" failed: ${
326
+ thrown instanceof Error ? thrown.message : String(thrown)
327
+ }`,
328
+ );
329
+ }
330
+
331
+ if (isCannotEstablish(remote)) {
332
+ // Asked, and no answer. What happens next depends on what the operation is for.
333
+ if (criticality === "required") {
334
+ // Unknown must never license a publish. A required operation whose prior outcome cannot
335
+ // be established is refused before anything executes, because performing it might
336
+ // repeat it and skipping it might drop it, and neither is a choice to make blind.
337
+ throw releaseError(
338
+ ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE,
339
+ `Whether required operation "${operation.operationId}" already happened could not be ` +
340
+ `established: ${remote.reason}. Executing would risk performing it twice and ` +
341
+ "skipping it would risk dropping it, so the bundle is refused until the outcome is " +
342
+ "known (contract §17).",
343
+ {
344
+ category: "conflict",
345
+ retryable: false,
346
+ details: {
347
+ runId: bundle.runId,
348
+ operationId: operation.operationId,
349
+ destination: operation.destination,
350
+ },
351
+ },
352
+ );
353
+ }
354
+
355
+ // Best-effort: **no receipt**, and not performed this pass.
356
+ //
357
+ // Writing a `skipped` receipt was the obvious thing and it was wrong in the way this whole
358
+ // issue is about. `skipped` is terminal in both directions — `execute` skips it and
359
+ // `reconcile` treats it as already recorded — so one momentary query failure permanently
360
+ // retired the operation for that bundle. At the destination that produced #169 quota
361
+ // exhaustion is routine, so a rate limit while *asking about* a tidy-up would silently
362
+ // drop it forever, and the record would say `skipped`: a decision, when what happened was
363
+ // an unanswered question.
364
+ //
365
+ // A durable record of a transient failure is the same defect as a durable record of a
366
+ // search nobody performed. So the not-performing lives in this pass only — the finding and
367
+ // the warning say what happened now, and the next pass asks again.
368
+ //
369
+ // Not performed either, because executing on an unknown prior state is the other half of
370
+ // the trap: it might repeat an operation that already happened.
371
+ findings.push({
372
+ operationId: operation.operationId,
373
+ idempotencyKey,
374
+ action: "cannot_establish",
375
+ explanation:
376
+ `Whether "${operation.operationId}" already happened could not be established, so it ` +
377
+ `was not attempted: ${remote.reason}`,
378
+ });
379
+ continue;
380
+ }
381
+
382
+ // Not an issued cannot-establish, so it must be the other arm — and that has to be checked
383
+ // rather than assumed. An assembled `{ reason: "..." }` is not an issued state, so
384
+ // `isCannotEstablish` correctly rejects it, and it would then reach `!remote.exists` with
385
+ // `exists` undefined and be recorded as `confirmed_absent`: a completed search, asserted
386
+ // from a value that is not a `RemoteState` at all.
387
+ //
388
+ // Fifth instance of the same lesson in this contract (#170 has the other four). A narrowing
389
+ // that trusts the declared type is not a narrowing.
390
+ if (typeof remote.exists !== "boolean") {
391
+ throw releaseError(
392
+ ReleaseErrorCodes.OPERATION_INVALID,
393
+ `The adapter for destination "${operation.destination}" answered the lookup for ` +
394
+ `"${operation.operationId}" with a value that is neither a remote state nor an ` +
395
+ "issued `cannotEstablish`. Reading it as absent would record a completed search that " +
396
+ "did not happen (contract §17).",
397
+ {
398
+ category: "validation",
399
+ retryable: false,
400
+ details: {
401
+ runId: bundle.runId,
402
+ operationId: operation.operationId,
403
+ destination: operation.destination,
404
+ },
405
+ },
406
+ );
407
+ }
408
+
251
409
  if (!remote.exists) {
410
+ // Reserved to a `lookup` that actually returned `exists: false`. Every other reason an
411
+ // operation might not have been found now has its own action, so this one means what it
412
+ // says: a completed search established that the destination does not hold it (#169).
252
413
  findings.push({
253
414
  operationId: operation.operationId,
254
415
  idempotencyKey,
@@ -292,11 +453,17 @@ export class ReleaseExecutor {
292
453
  const warnings: string[] = [];
293
454
  const written: ReleaseReceipt[] = [];
294
455
 
456
+ // Operations this pass must not attempt, held in memory and never written down. A best-effort
457
+ // operation whose prior state could not be established is not performed now — executing on an
458
+ // unknown prior state might repeat something that already happened — and leaves no durable
459
+ // trace, so the next pass asks again rather than finding a terminal receipt (#169).
460
+ const unestablished = new Set<string>();
295
461
  if (options.reconcile !== false) {
296
462
  const report = await this.reconcile(bundle, { actor: options.actor });
297
463
  written.push(...report.repaired);
298
464
  for (const finding of report.findings) {
299
- if (finding.explanation !== undefined && finding.action !== "confirmed_absent") {
465
+ if (finding.action === "cannot_establish") unestablished.add(finding.operationId);
466
+ if (finding.explanation !== undefined && OPERATOR_FACING_FINDINGS.has(finding.action)) {
300
467
  warnings.push(finding.explanation);
301
468
  }
302
469
  }
@@ -318,13 +485,28 @@ export class ReleaseExecutor {
318
485
  continue;
319
486
  }
320
487
 
488
+ // Skipped for this pass only. No receipt was written, so a later pass reaches the same
489
+ // operation with the same undecided state and asks the destination again.
490
+ if (unestablished.has(operation.operationId)) continue;
491
+
321
492
  const existing = latest.get(idempotencyKey);
322
493
  if (existing?.status === "succeeded" || existing?.status === "skipped") continue;
323
494
 
324
495
  // An operation whose outcome was never confirmed must not be retried (contract §17). If
325
496
  // reconciliation could have resolved it, it already ran above and this receipt would be
326
497
  // terminal; reaching here means the destination cannot be queried.
327
- if (existing?.status === "pending") {
498
+ //
499
+ // Unless the operation says repeating it is safe, which is exactly the fact this refusal
500
+ // needs and previously had no way to hear. Without it a best-effort tidy-up whose outcome
501
+ // was once unconfirmed refused **every later release of that bundle** — the same operation
502
+ // whose failure `execute` treats as a warning, blocking the release forever because one
503
+ // past attempt went unanswered (#169).
504
+ if (existing?.status === "pending" && operation.repeatable !== undefined) {
505
+ warnings.push(
506
+ `"${operation.operationId}" has an unconfirmed earlier outcome and declares its effect ` +
507
+ `safe to repeat, so it is being performed again: ${operation.repeatable.reason}`,
508
+ );
509
+ } else if (existing?.status === "pending") {
328
510
  throw releaseError(
329
511
  ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE,
330
512
  `"${operation.operationId}" was attempted and its outcome was never confirmed, and ` +
@@ -456,6 +638,11 @@ export class ReleaseExecutor {
456
638
  ...(outcome.status === "succeeded" && outcome.remoteUrl !== undefined
457
639
  ? { remoteUrl: outcome.remoteUrl }
458
640
  : {}),
641
+ // Carried through to the durable record. A field an adapter can set and nothing reads is
642
+ // the defect #107 reported, one contract over.
643
+ ...(outcome.status === "succeeded" && outcome.note !== undefined
644
+ ? { note: outcome.note }
645
+ : {}),
459
646
  // `pending` is not terminal, so it carries no completion time (contract §17).
460
647
  ...(outcome.status === "pending" ? {} : { completedAt: at }),
461
648
  ...(outcome.status === "failed"
package/src/index.ts CHANGED
@@ -18,7 +18,10 @@
18
18
  export {
19
19
  AdapterRegistry,
20
20
  RecordingReleaseAdapter,
21
+ cannotEstablish,
22
+ isCannotEstablish,
21
23
  type AdapterOutcome,
24
+ type CannotEstablish,
22
25
  type RecordingAdapterOptions,
23
26
  type ReleaseAdapter,
24
27
  type ReleaseRequest,
@@ -56,7 +59,9 @@ export {
56
59
 
57
60
  export {
58
61
  bestEffortOperation,
62
+ repeatable,
59
63
  requiredOperation,
64
+ type RepeatableDeclaration,
60
65
  type BestEffortOperation,
61
66
  type OperationCriticality,
62
67
  type ReleaseOperation,
package/src/operation.ts CHANGED
@@ -20,8 +20,55 @@
20
20
  * constructor below. A caller cannot hand-write an object literal that satisfies
21
21
  * {@link RequiredOperation}.
22
22
  */
23
+ import { ReleaseErrorCodes, releaseError } from "./errors.js";
24
+
23
25
  declare const CRITICALITY: unique symbol;
24
26
 
27
+ /** @see RepeatableDeclaration */
28
+ declare const REPEATABILITY: unique symbol;
29
+
30
+ /**
31
+ * A statement that an operation's effect may be performed more than once (§17, §19.1; #169).
32
+ *
33
+ * Minted by {@link repeatable} and not writable as an object literal, for the same reason
34
+ * `RequiredOperation` is not: this licenses re-performing a real external effect, and a shape a
35
+ * caller can assemble is a shape that gets assembled from configuration by someone who has not
36
+ * thought about it.
37
+ *
38
+ * The reason is **required**. An operation that may be repeated is one an approver is being asked
39
+ * to accept the repetition of, and "safe to repeat" with no account of why is not something anyone
40
+ * can approve or audit.
41
+ */
42
+ export interface RepeatableDeclaration {
43
+ readonly [REPEATABILITY]: "repeatable";
44
+ /**
45
+ * Why repeating this effect is safe, for the approver and for the operator.
46
+ *
47
+ * Shown in the reconciliation finding that records the operation was not queried, so a reader
48
+ * sees the justification rather than only the outcome.
49
+ */
50
+ readonly reason: string;
51
+ }
52
+
53
+ /**
54
+ * Declare that repeating this operation's effect is safe (§17, §19.1; #169).
55
+ *
56
+ * @throws {AldusError} when the reason is empty. A declaration that licenses repetition and says
57
+ * nothing about why is the thing this exists to prevent.
58
+ */
59
+ export function repeatable(reason: string): RepeatableDeclaration {
60
+ if (reason.trim().length === 0) {
61
+ throw releaseError(
62
+ ReleaseErrorCodes.OPERATION_INVALID,
63
+ "A repeatable declaration must say why repeating the effect is safe. It licenses performing " +
64
+ "an external effect more than once, and an approver cannot accept that from a bare flag " +
65
+ "(contract §13.4, §17).",
66
+ { category: "validation", retryable: false, details: {} },
67
+ );
68
+ }
69
+ return { reason } as RepeatableDeclaration;
70
+ }
71
+
25
72
  /** Fields shared by both categories. */
26
73
  export interface ReleaseOperationBase {
27
74
  /**
@@ -65,6 +112,23 @@ export interface ReleaseOperationBase {
65
112
  * The gate engine decides whether the authority is held; this package never re-decides it.
66
113
  */
67
114
  requiresAuthority?: string;
115
+ /**
116
+ * Declares that repeating this operation's effect is safe (§17, §19.1; #169).
117
+ *
118
+ * **Absent means one-shot**, which is the conservative reading and the behaviour every existing
119
+ * bundle already has. Repetition is licensed only by saying so.
120
+ *
121
+ * In the **bundle**, not on the adapter and not in `RemoteState`. §13.4 binds a release approval
122
+ * to the bundle, so a fact that licenses re-performing an effect has to be visible in the
123
+ * artifact an approver approved — an adapter-side flag would let an adapter license repeating an
124
+ * operation the approver believed happened once.
125
+ *
126
+ * Not inferable from {@link ReleaseOperationBase} either. `deriveIdempotencyKey` documents its
127
+ * result as the key that makes re-running safe, and that holds only where the destination
128
+ * honours the key; plenty do not. A key's presence is a request, not a guarantee, so
129
+ * repeatability has to be stated.
130
+ */
131
+ repeatable?: RepeatableDeclaration;
68
132
  /** Opaque parameters passed through to the adapter. Never inspected here. */
69
133
  parameters?: Readonly<Record<string, unknown>>;
70
134
  }