@aldus-runtime/release 0.1.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.
package/dist/ports.js ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Ports the release executor reads and writes through.
3
+ *
4
+ * Contract §7 requires core models to be independent of physical storage, so this package binds
5
+ * to interfaces rather than to a store. Each is kept to the operations actually used and tested:
6
+ * an aspirational method on a port is worse than an absent one, because a second adapter is
7
+ * written against it and only discovers at runtime that nothing honours it.
8
+ */
9
+ /**
10
+ * The most recent receipt per idempotency key.
11
+ *
12
+ * Later receipts win, which is what makes a retry after a failure resolve to the retry's outcome
13
+ * rather than the original failure.
14
+ */
15
+ export function latestByKey(receipts) {
16
+ const latest = new Map();
17
+ for (const receipt of receipts)
18
+ latest.set(receipt.idempotencyKey, receipt);
19
+ return latest;
20
+ }
21
+ /** An in-memory {@link ReleaseReceiptStore}, for tests and for dry runs. */
22
+ export class MemoryReleaseReceiptStore {
23
+ #byRun = new Map();
24
+ list(runId) {
25
+ return Promise.resolve([...(this.#byRun.get(runId) ?? [])]);
26
+ }
27
+ append(runId, receipt) {
28
+ const existing = this.#byRun.get(runId);
29
+ if (existing === undefined)
30
+ this.#byRun.set(runId, [receipt]);
31
+ else
32
+ existing.push(receipt);
33
+ return Promise.resolve();
34
+ }
35
+ /**
36
+ * Discard every receipt for a Run, as a lost or never-written `release.json` would.
37
+ *
38
+ * Exists for the reconciliation tests: losing a receipt whose operation succeeded remotely is
39
+ * the exact condition §17's reconciliation requirement addresses, and it has to be reproducible
40
+ * to be tested.
41
+ */
42
+ forget(runId) {
43
+ this.#byRun.delete(runId);
44
+ }
45
+ }
46
+ /** An in-memory {@link ReleaseEventSink} that retains what it was given, for tests. */
47
+ export class MemoryReleaseEventSink {
48
+ events = [];
49
+ emit(event) {
50
+ this.events.push(event);
51
+ return Promise.resolve();
52
+ }
53
+ }
54
+ /**
55
+ * A {@link ReleaseReceiptStore} backed by `@aldus-runtime/file-store`'s per-Run `release.json` (§7).
56
+ *
57
+ * `RunStore.addRecord` takes the Run lock itself, so this must not be called from inside code
58
+ * that already holds it — file locks are not re-entrant and the acquisition is refused outright
59
+ * (ADR-0005). The executor therefore holds no Run lock while writing receipts.
60
+ */
61
+ export function runStoreReceipts(runs) {
62
+ return {
63
+ list: (runId) => runs.listRecords(runId, "release"),
64
+ append: (runId, receipt) => runs.addRecord(runId, "release", receipt),
65
+ };
66
+ }
67
+ /**
68
+ * A {@link ReleaseEventSink} backed by `@aldus-runtime/file-store`'s event log (§6.4).
69
+ *
70
+ * Carries the same caution as {@link runStoreReceipts}: `EventStore.append` takes the Run lock to
71
+ * assign a sequence (ADR-0005).
72
+ */
73
+ export function eventStoreSink(events) {
74
+ return {
75
+ emit: async (event) => {
76
+ await events.append(event.runId, event);
77
+ },
78
+ };
79
+ }
80
+ //# sourceMappingURL=ports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ports.js","sourceRoot":"","sources":["../src/ports.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AA+BH;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,QAAmC;IAC7D,MAAM,MAAM,GAAG,IAAI,GAAG,EAA0B,CAAC;IACjD,KAAK,MAAM,OAAO,IAAI,QAAQ;QAAE,MAAM,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,OAAO,CAAC,CAAC;IAC5E,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,4EAA4E;AAC5E,MAAM,OAAO,yBAAyB;IAC3B,MAAM,GAAG,IAAI,GAAG,EAA4B,CAAC;IAEtD,IAAI,CAAC,KAAa;QAChB,OAAO,OAAO,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IAC9D,CAAC;IAED,MAAM,CAAC,KAAa,EAAE,OAAuB;QAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACxC,IAAI,QAAQ,KAAK,SAAS;YAAE,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,OAAO,CAAC,CAAC,CAAC;;YACzD,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC5B,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAED;;;;;;OAMG;IACH,MAAM,CAAC,KAAa;QAClB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC5B,CAAC;CACF;AAED,uFAAuF;AACvF,MAAM,OAAO,sBAAsB;IACxB,MAAM,GAAiB,EAAE,CAAC;IAEnC,IAAI,CAAC,KAAiB;QACpB,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACxB,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;CACF;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAc;IAC7C,OAAO;QACL,IAAI,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,EAAE,SAAS,CAAC;QACnD,MAAM,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,SAAS,EAAE,OAAO,CAAC;KACtE,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,MAAkB;IAC/C,OAAO;QACL,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,EAAE;YACpB,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;QAC1C,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@aldus-runtime/release",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Aldus Release \u2014 release bundles, resumable idempotent operations, adapter contracts, and external-state reconciliation.",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jamchen/aldus",
10
+ "directory": "packages/aldus-release"
11
+ },
12
+ "homepage": "https://github.com/jamchen/aldus/tree/main/packages/aldus-release#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/jamchen/aldus/issues"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsc -b",
30
+ "typecheck": "tsc -b --pretty",
31
+ "test": "vitest run",
32
+ "test:watch": "vitest",
33
+ "typecheck:test": "tsc -p tsconfig.test.json"
34
+ },
35
+ "dependencies": {
36
+ "@aldus-runtime/core": "0.1.0",
37
+ "@aldus-runtime/file-store": "0.1.0",
38
+ "@aldus-runtime/gate-engine": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "@aldus-runtime/testkit": "0.1.0",
42
+ "@types/node": "^26.2.0",
43
+ "typescript": "^7.0.2",
44
+ "vitest": "^4.1.10"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org"
49
+ }
50
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * The release adapter contract (architecture contract §17, §4.3).
3
+ *
4
+ * This package contains **no platform client, and names no platform.** Contract §4.2 keeps
5
+ * publishing platforms out of the runtime and §1.2 explicitly rules out prescribing particular
6
+ * release targets; §4.3 places provider and release configuration in Integration. Contract §22's
7
+ * own wording for this work package names two platforms, and following that wording literally
8
+ * would have put an adopter's channel semantics inside the runtime — the dependency direction
9
+ * §4.3 forbids.
10
+ *
11
+ * So what lives here is the shape an adopter implements, plus the resumable machinery that shape
12
+ * plugs into. The only implementation in this package is {@link RecordingReleaseAdapter}, a test
13
+ * double.
14
+ */
15
+
16
+ import type { ReleaseOperation } from "./operation.js";
17
+ import { ReleaseErrorCodes, releaseError } from "./errors.js";
18
+
19
+ /** What an adapter is asked to do. */
20
+ export interface ReleaseRequest {
21
+ /** The operation being performed. */
22
+ operation: ReleaseOperation;
23
+ /**
24
+ * The key that makes this request safe to repeat (contract §17, §19.1).
25
+ *
26
+ * An adapter whose platform supports client-supplied idempotency keys SHOULD pass this
27
+ * through, so that a repeat caused by a lost response is deduplicated remotely as well as
28
+ * locally.
29
+ */
30
+ idempotencyKey: string;
31
+ /** Run the release belongs to. */
32
+ runId: string;
33
+ }
34
+
35
+ /** What an adapter reports back. */
36
+ export type AdapterOutcome =
37
+ | {
38
+ status: "succeeded";
39
+ /** Identifier assigned by the destination, where one is returned. */
40
+ remoteId?: string;
41
+ /** Address of the released item, where one is meaningful. */
42
+ remoteUrl?: string;
43
+ }
44
+ | {
45
+ status: "failed";
46
+ /** Operator-facing reason, already redacted by the adapter (contract §19.2). */
47
+ message: string;
48
+ /** Whether repeating the identical request could plausibly succeed (contract §19.1). */
49
+ retryable?: boolean;
50
+ }
51
+ | {
52
+ /**
53
+ * The outcome is genuinely unknown — a timeout, a lost connection after the request was
54
+ * accepted, a platform that acknowledges asynchronously.
55
+ *
56
+ * Distinct from `failed` and the reason `ReleaseReceipt` has a `pending` status: an
57
+ * unknown outcome MUST be reconciled against the destination rather than retried, because
58
+ * retrying something that already succeeded is how a double publish happens.
59
+ */
60
+ status: "pending";
61
+ message?: string;
62
+ };
63
+
64
+ /** What reconciliation found at the destination. */
65
+ export interface RemoteState {
66
+ /** Whether the destination holds the result of this operation. */
67
+ exists: boolean;
68
+ remoteId?: string;
69
+ remoteUrl?: string;
70
+ }
71
+
72
+ /**
73
+ * An adopter's implementation for one destination.
74
+ *
75
+ * Implementations live in an adopter integration, never here (§4.3).
76
+ */
77
+ export interface ReleaseAdapter {
78
+ /** The destination this adapter serves, matching `ReleaseOperation.destination`. */
79
+ readonly destination: string;
80
+ /** Perform one operation. */
81
+ execute(request: ReleaseRequest): Promise<AdapterOutcome>;
82
+ /**
83
+ * Ask the destination whether an operation already happened (contract §17).
84
+ *
85
+ * Optional because §17 qualifies resumability with "where the platform allows it" — a
86
+ * destination with no way to query prior state genuinely cannot support this. An adapter that
87
+ * omits it makes reconciliation impossible for its operations, and the executor reports that
88
+ * rather than guessing; see {@link ReleaseErrorCodes.RECONCILIATION_UNAVAILABLE}.
89
+ */
90
+ lookup?(request: ReleaseRequest): Promise<RemoteState>;
91
+ }
92
+
93
+ /** Adapters by destination. */
94
+ export class AdapterRegistry {
95
+ readonly #byDestination = new Map<string, ReleaseAdapter>();
96
+
97
+ constructor(adapters: readonly ReleaseAdapter[] = []) {
98
+ for (const adapter of adapters) this.register(adapter);
99
+ }
100
+
101
+ /** Register an adapter, replacing any previous one for its destination. */
102
+ register(adapter: ReleaseAdapter): void {
103
+ this.#byDestination.set(adapter.destination, adapter);
104
+ }
105
+
106
+ /** The adapter for a destination, or `undefined`. */
107
+ find(destination: string): ReleaseAdapter | undefined {
108
+ return this.#byDestination.get(destination);
109
+ }
110
+
111
+ /**
112
+ * The adapter for a destination.
113
+ *
114
+ * @throws {AldusError} `ALDUS_RELEASE_ADAPTER_NOT_REGISTERED` when none is registered. Refusing
115
+ * is deliberate: a missing adapter means an operator declared work nothing can perform, and
116
+ * skipping it would report a complete release that never touched the destination.
117
+ */
118
+ require(destination: string): ReleaseAdapter {
119
+ const adapter = this.#byDestination.get(destination);
120
+ if (adapter === undefined) {
121
+ throw releaseError(
122
+ ReleaseErrorCodes.ADAPTER_NOT_REGISTERED,
123
+ `No release adapter is registered for destination "${destination}".`,
124
+ { category: "validation", retryable: false, details: { destination } },
125
+ );
126
+ }
127
+ return adapter;
128
+ }
129
+ }
130
+
131
+ /** Scripted behaviour for {@link RecordingReleaseAdapter}. */
132
+ export interface RecordingAdapterOptions {
133
+ /** Outcome per `operationId`. Anything unlisted succeeds. */
134
+ outcomes?: Readonly<Record<string, AdapterOutcome>>;
135
+ /** Remote state per idempotency key, as reconciliation would find it. */
136
+ remote?: Readonly<Record<string, RemoteState>>;
137
+ /** Omit `lookup` entirely, modelling a destination that cannot be queried. */
138
+ withoutLookup?: boolean;
139
+ }
140
+
141
+ /**
142
+ * A test double that records what it was asked to do.
143
+ *
144
+ * Counting executions is the point: the double-publish tests assert that a *real* second
145
+ * execution never happened, which a spy on the executor could not establish.
146
+ */
147
+ export class RecordingReleaseAdapter implements ReleaseAdapter {
148
+ readonly destination: string;
149
+ /** Every request passed to {@link execute}, in order. */
150
+ readonly executed: ReleaseRequest[] = [];
151
+ /** Every request passed to {@link lookup}, in order. */
152
+ readonly lookedUp: ReleaseRequest[] = [];
153
+ readonly #options: RecordingAdapterOptions;
154
+ /** Remote state, mutable so a test can simulate a destination that already holds a result. */
155
+ readonly remote: Map<string, RemoteState>;
156
+
157
+ /**
158
+ * Present only when the destination can be queried.
159
+ *
160
+ * An own property assigned in the constructor rather than a prototype method, because
161
+ * `withoutLookup` has to make `lookup` genuinely **absent**. A method that returns "not found"
162
+ * would be indistinguishable from a destination that was queried and had nothing — and those
163
+ * are opposite situations: one means the operation still needs to run, the other means nobody
164
+ * can say. Deleting a prototype method does not remove it from the instance, which is the bug
165
+ * this shape avoids.
166
+ */
167
+ readonly lookup?: (request: ReleaseRequest) => Promise<RemoteState>;
168
+
169
+ constructor(destination: string, options: RecordingAdapterOptions = {}) {
170
+ this.destination = destination;
171
+ this.#options = options;
172
+ this.remote = new Map(Object.entries(options.remote ?? {}));
173
+ if (options.withoutLookup !== true) {
174
+ this.lookup = (request: ReleaseRequest): Promise<RemoteState> => {
175
+ this.lookedUp.push(request);
176
+ return Promise.resolve(this.remote.get(request.idempotencyKey) ?? { exists: false });
177
+ };
178
+ }
179
+ }
180
+
181
+ execute(request: ReleaseRequest): Promise<AdapterOutcome> {
182
+ this.executed.push(request);
183
+ const scripted = this.#options.outcomes?.[request.operation.operationId];
184
+ const outcome: AdapterOutcome = scripted ?? {
185
+ status: "succeeded",
186
+ remoteId: `remote-${request.operation.operationId}`,
187
+ };
188
+ if (outcome.status === "succeeded") {
189
+ this.remote.set(request.idempotencyKey, {
190
+ exists: true,
191
+ ...(outcome.remoteId === undefined ? {} : { remoteId: outcome.remoteId }),
192
+ ...(outcome.remoteUrl === undefined ? {} : { remoteUrl: outcome.remoteUrl }),
193
+ });
194
+ }
195
+ return Promise.resolve(outcome);
196
+ }
197
+
198
+ /** How many times an operation was actually sent to the destination. */
199
+ executionCount(operationId: string): number {
200
+ return this.executed.filter((request) => request.operation.operationId === operationId).length;
201
+ }
202
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Release authorization (architecture contract §13.4, §17, §18.1).
3
+ *
4
+ * This package does **not** decide whether a release is approved. `@aldus-runtime/gate-engine` already
5
+ * models §13.4's separation of uploading from making public as two gates granting two
6
+ * operations, and derives staleness from bound digests (ADR-0009). Re-deciding any of that here
7
+ * would produce a second approval path — and §3.6 exists because a second path is how an
8
+ * approval that nobody recorded ends up authorizing something.
9
+ *
10
+ * So the executor consumes a verdict through a narrow port, and {@link gateEngineAuthorizer}
11
+ * wires that port to the real engine.
12
+ */
13
+
14
+ import type { GateEngine, SubjectsByGate } from "@aldus-runtime/gate-engine";
15
+
16
+ /** A verdict on one operation's authority. */
17
+ export interface AuthorityVerdict {
18
+ authorized: boolean;
19
+ /** Why not, when refused. Shown to an operator, so it must name the gate. */
20
+ explanation?: string;
21
+ /** The gate that granted it, when authorized — recorded for trace (contract §20). */
22
+ gateId?: string;
23
+ }
24
+
25
+ /**
26
+ * Whatever decides if a release operation may proceed.
27
+ *
28
+ * A port rather than a direct dependency so that the executor can be tested against a refusal
29
+ * without constructing a gate graph, and so an adopter with its own authority model can supply
30
+ * one. The shipped implementation is {@link gateEngineAuthorizer}.
31
+ */
32
+ export interface ReleaseAuthorizer {
33
+ /**
34
+ * Whether `authority` is currently held for this Run.
35
+ *
36
+ * `authority` is the operation string a gate grants, e.g. the separate upload and publication
37
+ * authorities of §13.4.
38
+ */
39
+ check(runId: string, authority: string): Promise<AuthorityVerdict>;
40
+ }
41
+
42
+ /**
43
+ * A {@link ReleaseAuthorizer} backed by the gate engine (contract §13).
44
+ *
45
+ * `subjects` are the current digests of everything the gates bind — the final render, captions,
46
+ * metadata, destination, and visibility policy of §13.4. They are supplied by the caller and
47
+ * read afresh on every check, so an approval that was valid when the bundle was assembled and
48
+ * has since drifted is refused at the moment of release rather than at the moment of planning.
49
+ */
50
+ export function gateEngineAuthorizer(
51
+ engine: GateEngine,
52
+ subjects: SubjectsByGate | (() => SubjectsByGate | Promise<SubjectsByGate>),
53
+ ): ReleaseAuthorizer {
54
+ return {
55
+ async check(runId: string, authority: string): Promise<AuthorityVerdict> {
56
+ const current = typeof subjects === "function" ? await subjects() : subjects;
57
+ const result = await engine.authorize(runId, authority, current);
58
+ return result.authorized
59
+ ? { authorized: true, gateId: result.gateId }
60
+ : { authorized: false, explanation: result.explanation };
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * An authorizer that permits everything.
67
+ *
68
+ * For operations that genuinely need no approval, and for tests exercising execution mechanics
69
+ * rather than authority. Deliberately named for what it does: a default called
70
+ * `defaultAuthorizer` would be reached for without the reader noticing it approves everything.
71
+ */
72
+ export function permitAllAuthorizer(): ReleaseAuthorizer {
73
+ return { check: () => Promise.resolve({ authorized: true }) };
74
+ }
package/src/bundle.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Release bundles (architecture contract §17).
3
+ *
4
+ * A bundle is a **declaration**, not a record of what happened: contract §17 states that
5
+ * "publishing is a domain, not a single command", and the bundle is the domain's unit of intent.
6
+ * What happened lives in `ReleaseReceipt`s, one per operation, in the Run's `release.json`
7
+ * (§7).
8
+ *
9
+ * There is deliberately no persisted bundle state. Execution state is derived from the receipts
10
+ * on every inspection, for the same reason ADR-0009 derives gate state rather than storing it: a
11
+ * stored "in progress" flag survives a crash that the operation it describes did not, and an
12
+ * operator then reads a status that was true once and is not true now.
13
+ */
14
+
15
+ import { createHash } from "node:crypto";
16
+
17
+ import { ReleaseErrorCodes, releaseError } from "./errors.js";
18
+ import type { BestEffortOperation, ReleaseOperation, RequiredOperation } from "./operation.js";
19
+
20
+ /**
21
+ * A set of release operations for one Run.
22
+ *
23
+ * The two lists are the §17 distinction between pre-release hard gates and post-upload
24
+ * best-effort work. Required operations run first, in order; best-effort operations run only
25
+ * once every required one has succeeded, because §17 calls them "post-upload".
26
+ */
27
+ export interface ReleaseBundle {
28
+ /** Identity of this bundle. Part of every operation's idempotency key. */
29
+ bundleId: string;
30
+ /** Run this release belongs to (contract §6). */
31
+ runId: string;
32
+ /** Canonical Episode identity, for the emitted events (contract §6.4). */
33
+ episodeId: string;
34
+ /** Operations whose failure fails the release, in execution order. */
35
+ required: readonly RequiredOperation[];
36
+ /** Operations attempted after the required ones, whose failure is recorded but tolerated. */
37
+ bestEffort: readonly BestEffortOperation[];
38
+ }
39
+
40
+ /** Every operation in a bundle, required first, in execution order. */
41
+ export function operationsOf(bundle: ReleaseBundle): readonly ReleaseOperation[] {
42
+ return [...bundle.required, ...bundle.bestEffort];
43
+ }
44
+
45
+ /**
46
+ * Check a bundle's internal consistency.
47
+ *
48
+ * @throws {AldusError} `ALDUS_RELEASE_EMPTY_BUNDLE` if it declares no operations.
49
+ * @throws {AldusError} `ALDUS_RELEASE_DUPLICATE_OPERATION` if an `operationId` repeats.
50
+ */
51
+ export function assertBundleValid(bundle: ReleaseBundle): void {
52
+ const operations = operationsOf(bundle);
53
+ if (operations.length === 0) {
54
+ throw releaseError(
55
+ ReleaseErrorCodes.EMPTY_BUNDLE,
56
+ `Release bundle "${bundle.bundleId}" declares no operations, so executing it would report ` +
57
+ "a successful release that published nothing.",
58
+ { category: "validation", details: { bundleId: bundle.bundleId } },
59
+ );
60
+ }
61
+
62
+ const seen = new Set<string>();
63
+ for (const operation of operations) {
64
+ if (seen.has(operation.operationId)) {
65
+ throw releaseError(
66
+ ReleaseErrorCodes.DUPLICATE_OPERATION,
67
+ `Release bundle "${bundle.bundleId}" declares "${operation.operationId}" twice. ` +
68
+ "Operation ids match receipts back to operations, so a duplicate would let one " +
69
+ "operation inherit the other's outcome.",
70
+ {
71
+ category: "validation",
72
+ details: { bundleId: bundle.bundleId, operationId: operation.operationId },
73
+ },
74
+ );
75
+ }
76
+ seen.add(operation.operationId);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Derive the idempotency key for one operation (contract §17, §19.1).
82
+ *
83
+ * §17 requires each operation to be "independently idempotent and resumable where the platform
84
+ * allows it", and `ReleaseReceipt.idempotencyKey` is the mechanism. The key is **derived, not
85
+ * supplied**, from the bundle, the operation, its destination, and the digests of what it
86
+ * releases — so a resumed execution computes the identical key without having to remember one,
87
+ * and a changed input produces a different key rather than silently reusing an old outcome.
88
+ *
89
+ * Input hashes are sorted before digesting: the set of things released is what matters, not the
90
+ * order a caller happened to list them in.
91
+ */
92
+ export function deriveIdempotencyKey(bundleId: string, operation: ReleaseOperation): string {
93
+ const material = JSON.stringify({
94
+ bundleId,
95
+ operationId: operation.operationId,
96
+ kind: operation.kind,
97
+ destination: operation.destination,
98
+ inputHashes: [...operation.inputHashes].sort(),
99
+ });
100
+ return createHash("sha256").update(material, "utf8").digest("hex");
101
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Failures specific to release execution.
3
+ *
4
+ * Aldus Core deliberately keeps no central error-code registry, so that a package can name a new
5
+ * failure without forking Core. These codes are this package's contribution; they carry the same
6
+ * `ALDUS_` prefix and `SCREAMING_SNAKE_CASE` shape so production trace (contract §20) stays
7
+ * uniform across packages.
8
+ */
9
+
10
+ import { AldusError, type ErrorCategory } from "@aldus-runtime/core";
11
+
12
+ /** Error codes raised by the release executor. */
13
+ export const ReleaseErrorCodes = {
14
+ /**
15
+ * An operation was attempted that no gate authorizes.
16
+ *
17
+ * Contract §13.4 binds release approval to the final render, captions, metadata, destination,
18
+ * and visibility policy, and §17 requires uploading and making public to be separate
19
+ * operations. An unauthorized release is refused rather than warned about: a warning that
20
+ * still publishes is not a gate.
21
+ */
22
+ RELEASE_NOT_AUTHORIZED: "ALDUS_RELEASE_NOT_AUTHORIZED",
23
+ /** No adapter is registered for an operation's destination. */
24
+ ADAPTER_NOT_REGISTERED: "ALDUS_RELEASE_ADAPTER_NOT_REGISTERED",
25
+ /** A bundle declared two operations with the same `operationId`. */
26
+ DUPLICATE_OPERATION: "ALDUS_RELEASE_DUPLICATE_OPERATION",
27
+ /** A bundle was constructed with no operations at all. */
28
+ EMPTY_BUNDLE: "ALDUS_RELEASE_EMPTY_BUNDLE",
29
+ /**
30
+ * A required operation failed, so the release did not complete.
31
+ *
32
+ * Distinct from a best-effort failure, which is recorded and does not fail the release
33
+ * (contract §17: "Pre-release hard gates and post-upload best-effort operations MUST be
34
+ * distinguished").
35
+ */
36
+ REQUIRED_OPERATION_FAILED: "ALDUS_RELEASE_REQUIRED_OPERATION_FAILED",
37
+ /**
38
+ * A stored receipt disagrees with the bundle that produced it.
39
+ *
40
+ * The identity of an operation is its `operationId` plus its idempotency key. A receipt whose
41
+ * key no longer matches describes a different operation than the one about to run, and reusing
42
+ * it would let changed inputs inherit an old approval's outcome.
43
+ */
44
+ RECEIPT_MISMATCH: "ALDUS_RELEASE_RECEIPT_MISMATCH",
45
+ /**
46
+ * Reconciliation was required but the adapter cannot look up remote state.
47
+ *
48
+ * Contract §17 requires operations to be resumable "where the platform allows it". Where it
49
+ * does not, the honest outcome is to say so rather than to re-execute and risk a duplicate
50
+ * publish.
51
+ */
52
+ RECONCILIATION_UNAVAILABLE: "ALDUS_RELEASE_RECONCILIATION_UNAVAILABLE",
53
+ } as const;
54
+
55
+ /** @see ReleaseErrorCodes */
56
+ export type ReleaseErrorCode = (typeof ReleaseErrorCodes)[keyof typeof ReleaseErrorCodes];
57
+
58
+ /** Construct an {@link AldusError} with a release code. */
59
+ export function releaseError(
60
+ code: ReleaseErrorCode,
61
+ message: string,
62
+ options: { category: ErrorCategory; retryable?: boolean; details?: Record<string, unknown> },
63
+ ): AldusError {
64
+ return new AldusError(code, message, options);
65
+ }