@emilia-protocol/gate 0.15.2 → 0.17.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.
@@ -0,0 +1,1122 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Complete-mediation boundary for provider effects.
5
+ *
6
+ * Gate presents a short-lived signed execution envelope. The actuator verifies
7
+ * every binding under immutable local pins, atomically reserves the envelope,
8
+ * and only then enters a provider callback that owns its credential. Provider
9
+ * credentials are deliberately absent from every public type in this module.
10
+ */
11
+
12
+ import {
13
+ createHash,
14
+ createPrivateKey,
15
+ createPublicKey,
16
+ sign,
17
+ verify,
18
+ type KeyObject,
19
+ } from 'node:crypto';
20
+ import { canonicalize } from './execution-binding.js';
21
+
22
+ export const CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION =
23
+ 'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
24
+ export const CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM = 'Ed25519';
25
+ export const CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN =
26
+ 'EP-CONSEQUENCE-ACTUATOR-ENVELOPE-v1';
27
+ export const DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS = 60_000;
28
+ export const DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS = 2_000;
29
+ export const CONSEQUENCE_ACTUATOR_STORE_TABLE =
30
+ 'public.consequence_actuator_envelopes';
31
+ export const CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE =
32
+ 'consequence_actuator_executor';
33
+ export const CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE =
34
+ 'consequence_actuator_store_owner';
35
+ export const CONSEQUENCE_ACTUATOR_SQL = deepFreeze({
36
+ reserve: `SELECT envelope_digest
37
+ FROM consequence_actuator_private.reserve_envelope(
38
+ $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
39
+ $7::text, $8::text, $9::text, $10::timestamptz, $11::timestamptz,
40
+ $12::text
41
+ )`,
42
+ consume: `SELECT envelope_digest
43
+ FROM consequence_actuator_private.consume_envelope(
44
+ $1::text, $2::text, $3::text, $4::text, $5::text, $6::text,
45
+ $7::text, $8::text, $9::text, $10::text, $11::text
46
+ )`,
47
+ });
48
+
49
+ const MAX_CONFIGURED_TTL_MS = 5 * 60_000;
50
+ const MAX_CLOCK_SKEW_MS = 30_000;
51
+ const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
52
+ const CAID_PATTERN =
53
+ /^caid:1:[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*\.[1-9][0-9]*:[a-z0-9]+(?:-[a-z0-9]+)*:[A-Za-z0-9_-]{43}$/;
54
+ const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,255}$/;
55
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
56
+ const ENVELOPE_KEYS = ['payload', 'signature'] as const;
57
+ const PAYLOAD_KEYS = [
58
+ '@version',
59
+ 'issuer_id',
60
+ 'tenant_id',
61
+ 'attempt_id',
62
+ 'action_digest',
63
+ 'caid',
64
+ 'provider_account_id',
65
+ 'target_digest',
66
+ 'operation',
67
+ 'idempotency_key',
68
+ 'nonce',
69
+ 'issued_at',
70
+ 'expires_at',
71
+ ] as const;
72
+ const SIGNATURE_KEYS = ['algorithm', 'key_id', 'value'] as const;
73
+
74
+ type KeyMaterial = KeyObject | string | Buffer;
75
+
76
+ export interface ConsequenceExecutionEnvelopePayload {
77
+ '@version': typeof CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION;
78
+ issuer_id: string;
79
+ tenant_id: string;
80
+ attempt_id: string;
81
+ action_digest: string;
82
+ caid: string;
83
+ provider_account_id: string;
84
+ target_digest: string;
85
+ operation: string;
86
+ idempotency_key: string;
87
+ nonce: string;
88
+ issued_at: string;
89
+ expires_at: string;
90
+ }
91
+
92
+ export interface ConsequenceExecutionEnvelopeSignature {
93
+ algorithm: typeof CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM;
94
+ key_id: string;
95
+ value: string;
96
+ }
97
+
98
+ export interface SignedConsequenceExecutionEnvelope {
99
+ payload: ConsequenceExecutionEnvelopePayload;
100
+ signature: ConsequenceExecutionEnvelopeSignature;
101
+ }
102
+
103
+ export interface ConsequenceActuatorPins {
104
+ tenantId: string;
105
+ caid: string;
106
+ providerAccountId: string;
107
+ targetDigest: string;
108
+ operation: string;
109
+ envelopeIssuerId: string;
110
+ envelopeKeyId: string;
111
+ envelopePublicKey: KeyMaterial;
112
+ maxEnvelopeTtlMs?: number;
113
+ clockSkewMs?: number;
114
+ }
115
+
116
+ export interface VisibleConsequenceActuatorPins {
117
+ readonly tenantId: string;
118
+ readonly caid: string;
119
+ readonly providerAccountId: string;
120
+ readonly targetDigest: string;
121
+ readonly operation: string;
122
+ readonly envelopeIssuerId: string;
123
+ readonly envelopeKeyId: string;
124
+ readonly envelopePublicKeyFingerprint: string;
125
+ readonly maxEnvelopeTtlMs: number;
126
+ readonly clockSkewMs: number;
127
+ }
128
+
129
+ export interface ConsequenceActuatorReservation {
130
+ readonly tenantId: string;
131
+ readonly attemptId: string;
132
+ readonly actionDigest: string;
133
+ readonly caid: string;
134
+ readonly providerAccountId: string;
135
+ readonly targetDigest: string;
136
+ readonly operation: string;
137
+ readonly idempotencyKey: string;
138
+ readonly nonce: string;
139
+ readonly issuedAt: string;
140
+ readonly expiresAt: string;
141
+ readonly envelopeDigest: string;
142
+ }
143
+
144
+ export type ConsequenceActuatorOutcome = 'COMMITTED' | 'INDETERMINATE';
145
+
146
+ export interface ConsequenceActuatorConsumption
147
+ extends ConsequenceActuatorReservation {
148
+ readonly outcome: ConsequenceActuatorOutcome;
149
+ }
150
+
151
+ export interface ConsequenceActuatorStore {
152
+ /** Explicit marker for the built-in non-production test store. */
153
+ readonly testOnly?: true;
154
+ readonly durable: boolean;
155
+ readonly atomic: boolean;
156
+ readonly ownershipFenced: boolean;
157
+ readonly permanentConsumption: boolean;
158
+ reserve(reservation: ConsequenceActuatorReservation): Promise<boolean>;
159
+ consume(consumption: ConsequenceActuatorConsumption): Promise<boolean>;
160
+ }
161
+
162
+ export interface ConsequenceActuatorPgQueryResult {
163
+ readonly rowCount: number | null;
164
+ readonly rows: readonly unknown[];
165
+ }
166
+
167
+ /**
168
+ * A dedicated pool wrapper must label the login principal configured in its
169
+ * connection string. PostgreSQL independently enforces that SESSION_USER is a
170
+ * tenant-mapped member of consequence_actuator_executor.
171
+ */
172
+ export interface DedicatedConsequenceActuatorExecutorPool {
173
+ readonly principal: string;
174
+ query(
175
+ text: string,
176
+ values: readonly unknown[],
177
+ ): Promise<ConsequenceActuatorPgQueryResult>;
178
+ }
179
+
180
+ export interface PostgresConsequenceActuatorStoreOptions {
181
+ readonly tenantId: string;
182
+ readonly executorPrincipal: string;
183
+ readonly executorPool: DedicatedConsequenceActuatorExecutorPool;
184
+ }
185
+
186
+ export interface ConsequenceActuatorExecutionInput {
187
+ envelope: unknown;
188
+ attemptId: string;
189
+ actionDigest: string;
190
+ idempotencyKey: string;
191
+ }
192
+
193
+ export type ConsequenceActuatorRefusalReason =
194
+ | 'malformed_envelope'
195
+ | 'unsupported_version'
196
+ | 'signer_key_mismatch'
197
+ | 'issuer_mismatch'
198
+ | 'tenant_mismatch'
199
+ | 'caid_mismatch'
200
+ | 'provider_account_mismatch'
201
+ | 'target_mismatch'
202
+ | 'operation_mismatch'
203
+ | 'attempt_mismatch'
204
+ | 'action_digest_mismatch'
205
+ | 'idempotency_key_mismatch'
206
+ | 'envelope_not_yet_valid'
207
+ | 'envelope_expired'
208
+ | 'envelope_ttl_exceeded'
209
+ | 'signature_invalid'
210
+ | 'store_reserve_failed'
211
+ | 'envelope_replayed'
212
+ | 'provider_outcome_indeterminate'
213
+ | 'store_consume_unconfirmed';
214
+
215
+ export type ConsequenceActuatorExecutionResult<TResult> =
216
+ | {
217
+ readonly ok: true;
218
+ readonly invoked: true;
219
+ readonly result: TResult;
220
+ readonly envelopeDigest: string;
221
+ }
222
+ | {
223
+ readonly ok: false;
224
+ readonly invoked: boolean;
225
+ readonly reason: ConsequenceActuatorRefusalReason;
226
+ readonly envelopeDigest?: string;
227
+ };
228
+
229
+ export type ConsequenceEnvelopeVerification =
230
+ | {
231
+ readonly ok: true;
232
+ readonly payload: Readonly<ConsequenceExecutionEnvelopePayload>;
233
+ readonly envelopeDigest: string;
234
+ }
235
+ | {
236
+ readonly ok: false;
237
+ readonly reason: Exclude<
238
+ ConsequenceActuatorRefusalReason,
239
+ | 'store_reserve_failed'
240
+ | 'envelope_replayed'
241
+ | 'provider_outcome_indeterminate'
242
+ | 'store_consume_unconfirmed'
243
+ >;
244
+ };
245
+
246
+ interface NormalizedPins {
247
+ visible: VisibleConsequenceActuatorPins;
248
+ verificationKey: KeyObject;
249
+ }
250
+
251
+ interface VerifyExpectedBinding {
252
+ attemptId: string;
253
+ actionDigest: string;
254
+ idempotencyKey: string;
255
+ }
256
+
257
+ interface VerifyOptions {
258
+ pins: ConsequenceActuatorPins;
259
+ expected: VerifyExpectedBinding;
260
+ now?: number | (() => number);
261
+ }
262
+
263
+ interface ConsequenceActuatorOptions<TResult> {
264
+ pins: ConsequenceActuatorPins;
265
+ store: ConsequenceActuatorStore;
266
+ readonly testOnly?: true;
267
+ perform: (
268
+ binding: Readonly<ConsequenceExecutionEnvelopePayload>,
269
+ ) => TResult | Promise<TResult>;
270
+ now?: number | (() => number);
271
+ }
272
+
273
+ interface SignEnvelopeOptions {
274
+ privateKey: KeyMaterial;
275
+ keyId: string;
276
+ }
277
+
278
+ export interface MemoryConsequenceActuatorSnapshot
279
+ extends ConsequenceActuatorReservation {
280
+ readonly state: 'RESERVED' | 'CONSUMED';
281
+ readonly outcome: ConsequenceActuatorOutcome | null;
282
+ readonly reservedAt: string;
283
+ readonly consumedAt: string | null;
284
+ }
285
+
286
+ function isRecord(value: unknown): value is Record<string, unknown> {
287
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
288
+ }
289
+
290
+ function exactKeys(
291
+ value: Record<string, unknown>,
292
+ expected: readonly string[],
293
+ ): boolean {
294
+ const keys = Reflect.ownKeys(value);
295
+ return keys.length === expected.length
296
+ && keys.every((key) => typeof key === 'string' && expected.includes(key));
297
+ }
298
+
299
+ function deepFreeze<T>(value: T): T {
300
+ if (value === null || typeof value !== 'object') return value;
301
+ const stack: object[] = [value];
302
+ const seen = new WeakSet<object>();
303
+ while (stack.length > 0) {
304
+ const current = stack.pop()!;
305
+ if (seen.has(current)) continue;
306
+ seen.add(current);
307
+ for (const child of Object.values(current)) {
308
+ if (child !== null && typeof child === 'object') stack.push(child);
309
+ }
310
+ Object.freeze(current);
311
+ }
312
+ return value;
313
+ }
314
+
315
+ function canonicalClone(value: unknown): {
316
+ canonical: string;
317
+ value: unknown;
318
+ } {
319
+ const canonical = canonicalize(value);
320
+ return {
321
+ canonical,
322
+ value: deepFreeze(JSON.parse(canonical)),
323
+ };
324
+ }
325
+
326
+ function validIdentifier(value: unknown): value is string {
327
+ return typeof value === 'string'
328
+ && IDENTIFIER_PATTERN.test(value)
329
+ && Buffer.byteLength(value, 'utf8') <= 256;
330
+ }
331
+
332
+ function validDigest(value: unknown): value is string {
333
+ return typeof value === 'string' && DIGEST_PATTERN.test(value);
334
+ }
335
+
336
+ function canonicalInstant(value: unknown): number | null {
337
+ if (typeof value !== 'string') return null;
338
+ const milliseconds = Date.parse(value);
339
+ if (!Number.isSafeInteger(milliseconds) || milliseconds < 0) return null;
340
+ return new Date(milliseconds).toISOString() === value ? milliseconds : null;
341
+ }
342
+
343
+ function decodeCanonicalBase64Url(
344
+ value: unknown,
345
+ minimumBytes: number,
346
+ maximumBytes: number,
347
+ ): Buffer | null {
348
+ if (typeof value !== 'string' || !BASE64URL_PATTERN.test(value)) return null;
349
+ const bytes = Buffer.from(value, 'base64url');
350
+ if (
351
+ bytes.length < minimumBytes
352
+ || bytes.length > maximumBytes
353
+ || bytes.toString('base64url') !== value
354
+ ) {
355
+ return null;
356
+ }
357
+ return bytes;
358
+ }
359
+
360
+ function nowMilliseconds(now: number | (() => number) | undefined): number {
361
+ const value = typeof now === 'function' ? now() : (now ?? Date.now());
362
+ if (!Number.isSafeInteger(value) || value < 0) {
363
+ throw new TypeError(
364
+ 'consequence actuator clock must return a non-negative safe integer',
365
+ );
366
+ }
367
+ return value;
368
+ }
369
+
370
+ function normalizePrivateKey(value: KeyMaterial): KeyObject {
371
+ const key =
372
+ typeof value === 'object' && value !== null && 'type' in value
373
+ ? value as KeyObject
374
+ : createPrivateKey(value);
375
+ if (key.type !== 'private' || key.asymmetricKeyType !== 'ed25519') {
376
+ throw new TypeError('execution envelopes require an Ed25519 private key');
377
+ }
378
+ return key;
379
+ }
380
+
381
+ function isKeyObject(value: KeyMaterial): value is KeyObject {
382
+ return typeof value === 'object'
383
+ && value !== null
384
+ && 'type' in value
385
+ && ['private', 'public', 'secret'].includes((value as KeyObject).type);
386
+ }
387
+
388
+ function normalizePublicKey(value: KeyMaterial): KeyObject {
389
+ let imported: KeyObject;
390
+ if (isKeyObject(value)) {
391
+ if (value.type !== 'public') {
392
+ throw new TypeError(
393
+ 'the actuator must receive a public verification key',
394
+ );
395
+ }
396
+ imported = value;
397
+ } else {
398
+ imported = createPublicKey(value);
399
+ }
400
+ if (
401
+ imported.type !== 'public'
402
+ || imported.asymmetricKeyType !== 'ed25519'
403
+ ) {
404
+ throw new TypeError('the actuator verification pin must be Ed25519');
405
+ }
406
+ const der = imported.export({ type: 'spki', format: 'der' });
407
+ return createPublicKey({ key: Buffer.from(der), type: 'spki', format: 'der' });
408
+ }
409
+
410
+ function publicKeyFingerprint(key: KeyObject): string {
411
+ const der = key.export({ type: 'spki', format: 'der' });
412
+ return `sha256:${createHash('sha256').update(der).digest('hex')}`;
413
+ }
414
+
415
+ function signatureInput(canonicalPayload: string): Buffer {
416
+ return Buffer.concat([
417
+ Buffer.from(CONSEQUENCE_ACTUATOR_SIGNATURE_DOMAIN, 'utf8'),
418
+ Buffer.from([0]),
419
+ Buffer.from(canonicalPayload, 'utf8'),
420
+ ]);
421
+ }
422
+
423
+ function validatePayloadShape(
424
+ value: unknown,
425
+ ): value is ConsequenceExecutionEnvelopePayload {
426
+ if (!isRecord(value) || !exactKeys(value, PAYLOAD_KEYS)) return false;
427
+ const issuedAt = canonicalInstant(value.issued_at);
428
+ const expiresAt = canonicalInstant(value.expires_at);
429
+ return value['@version'] === CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION
430
+ && validIdentifier(value.issuer_id)
431
+ && validIdentifier(value.tenant_id)
432
+ && validIdentifier(value.attempt_id)
433
+ && validDigest(value.action_digest)
434
+ && typeof value.caid === 'string'
435
+ && CAID_PATTERN.test(value.caid)
436
+ && validIdentifier(value.provider_account_id)
437
+ && validDigest(value.target_digest)
438
+ && validIdentifier(value.operation)
439
+ && validIdentifier(value.idempotency_key)
440
+ && decodeCanonicalBase64Url(value.nonce, 16, 64) !== null
441
+ && issuedAt !== null
442
+ && expiresAt !== null
443
+ && expiresAt > issuedAt;
444
+ }
445
+
446
+ function normalizePins(pins: ConsequenceActuatorPins): NormalizedPins {
447
+ if (!isRecord(pins)) {
448
+ throw new TypeError('consequence actuator pins are required');
449
+ }
450
+ if (
451
+ !validIdentifier(pins.tenantId)
452
+ || typeof pins.caid !== 'string'
453
+ || !CAID_PATTERN.test(pins.caid)
454
+ || !validIdentifier(pins.providerAccountId)
455
+ || !validDigest(pins.targetDigest)
456
+ || !validIdentifier(pins.operation)
457
+ || !validIdentifier(pins.envelopeIssuerId)
458
+ || !validIdentifier(pins.envelopeKeyId)
459
+ ) {
460
+ throw new TypeError('consequence actuator pins are malformed');
461
+ }
462
+ const maxEnvelopeTtlMs =
463
+ pins.maxEnvelopeTtlMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_MAX_TTL_MS;
464
+ const clockSkewMs =
465
+ pins.clockSkewMs ?? DEFAULT_CONSEQUENCE_ACTUATOR_CLOCK_SKEW_MS;
466
+ if (
467
+ !Number.isSafeInteger(maxEnvelopeTtlMs)
468
+ || maxEnvelopeTtlMs < 1
469
+ || maxEnvelopeTtlMs > MAX_CONFIGURED_TTL_MS
470
+ ) {
471
+ throw new TypeError(
472
+ 'maxEnvelopeTtlMs must be between 1 millisecond and 5 minutes',
473
+ );
474
+ }
475
+ if (
476
+ !Number.isSafeInteger(clockSkewMs)
477
+ || clockSkewMs < 0
478
+ || clockSkewMs > MAX_CLOCK_SKEW_MS
479
+ ) {
480
+ throw new TypeError('clockSkewMs must be between 0 and 30 seconds');
481
+ }
482
+ const verificationKey = normalizePublicKey(pins.envelopePublicKey);
483
+ const visible = deepFreeze({
484
+ tenantId: pins.tenantId,
485
+ caid: pins.caid,
486
+ providerAccountId: pins.providerAccountId,
487
+ targetDigest: pins.targetDigest,
488
+ operation: pins.operation,
489
+ envelopeIssuerId: pins.envelopeIssuerId,
490
+ envelopeKeyId: pins.envelopeKeyId,
491
+ envelopePublicKeyFingerprint: publicKeyFingerprint(verificationKey),
492
+ maxEnvelopeTtlMs,
493
+ clockSkewMs,
494
+ });
495
+ return { visible, verificationKey };
496
+ }
497
+
498
+ function verifyWithNormalizedPins(
499
+ envelope: unknown,
500
+ pins: NormalizedPins,
501
+ expected: VerifyExpectedBinding,
502
+ now: number | (() => number) | undefined,
503
+ ): ConsequenceEnvelopeVerification {
504
+ let cloned: unknown;
505
+ let canonicalEnvelope: string;
506
+ try {
507
+ const result = canonicalClone(envelope);
508
+ cloned = result.value;
509
+ canonicalEnvelope = result.canonical;
510
+ } catch {
511
+ return { ok: false, reason: 'malformed_envelope' };
512
+ }
513
+ if (!isRecord(cloned) || !exactKeys(cloned, ENVELOPE_KEYS)) {
514
+ return { ok: false, reason: 'malformed_envelope' };
515
+ }
516
+ if (!isRecord(cloned.payload)) {
517
+ return { ok: false, reason: 'malformed_envelope' };
518
+ }
519
+ if (cloned.payload['@version'] !== CONSEQUENCE_ACTUATOR_ENVELOPE_VERSION) {
520
+ return { ok: false, reason: 'unsupported_version' };
521
+ }
522
+ if (!validatePayloadShape(cloned.payload)) {
523
+ return { ok: false, reason: 'malformed_envelope' };
524
+ }
525
+ if (
526
+ !isRecord(cloned.signature)
527
+ || !exactKeys(cloned.signature, SIGNATURE_KEYS)
528
+ || cloned.signature.algorithm !== CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM
529
+ || !validIdentifier(cloned.signature.key_id)
530
+ ) {
531
+ return { ok: false, reason: 'malformed_envelope' };
532
+ }
533
+ const signatureBytes = decodeCanonicalBase64Url(
534
+ cloned.signature.value,
535
+ 64,
536
+ 64,
537
+ );
538
+ if (signatureBytes === null) {
539
+ return { ok: false, reason: 'malformed_envelope' };
540
+ }
541
+
542
+ const payload = cloned.payload;
543
+ const signature = cloned.signature;
544
+ if (signature.key_id !== pins.visible.envelopeKeyId) {
545
+ return { ok: false, reason: 'signer_key_mismatch' };
546
+ }
547
+
548
+ const canonicalPayload = canonicalize(payload);
549
+ let validSignature = false;
550
+ try {
551
+ validSignature = verify(
552
+ null,
553
+ signatureInput(canonicalPayload),
554
+ pins.verificationKey,
555
+ signatureBytes,
556
+ );
557
+ } catch {
558
+ validSignature = false;
559
+ }
560
+ if (!validSignature) {
561
+ return { ok: false, reason: 'signature_invalid' };
562
+ }
563
+
564
+ if (payload.issuer_id !== pins.visible.envelopeIssuerId) {
565
+ return { ok: false, reason: 'issuer_mismatch' };
566
+ }
567
+ if (payload.tenant_id !== pins.visible.tenantId) {
568
+ return { ok: false, reason: 'tenant_mismatch' };
569
+ }
570
+ if (payload.caid !== pins.visible.caid) {
571
+ return { ok: false, reason: 'caid_mismatch' };
572
+ }
573
+ if (payload.provider_account_id !== pins.visible.providerAccountId) {
574
+ return { ok: false, reason: 'provider_account_mismatch' };
575
+ }
576
+ if (payload.target_digest !== pins.visible.targetDigest) {
577
+ return { ok: false, reason: 'target_mismatch' };
578
+ }
579
+ if (payload.operation !== pins.visible.operation) {
580
+ return { ok: false, reason: 'operation_mismatch' };
581
+ }
582
+ if (payload.attempt_id !== expected.attemptId) {
583
+ return { ok: false, reason: 'attempt_mismatch' };
584
+ }
585
+ if (payload.action_digest !== expected.actionDigest) {
586
+ return { ok: false, reason: 'action_digest_mismatch' };
587
+ }
588
+ if (payload.idempotency_key !== expected.idempotencyKey) {
589
+ return { ok: false, reason: 'idempotency_key_mismatch' };
590
+ }
591
+
592
+ let currentTime: number;
593
+ try {
594
+ currentTime = nowMilliseconds(now);
595
+ } catch {
596
+ return { ok: false, reason: 'malformed_envelope' };
597
+ }
598
+ const issuedAt = Date.parse(payload.issued_at);
599
+ const expiresAt = Date.parse(payload.expires_at);
600
+ if (issuedAt > currentTime + pins.visible.clockSkewMs) {
601
+ return { ok: false, reason: 'envelope_not_yet_valid' };
602
+ }
603
+ if (expiresAt <= currentTime) {
604
+ return { ok: false, reason: 'envelope_expired' };
605
+ }
606
+ if (expiresAt - issuedAt > pins.visible.maxEnvelopeTtlMs) {
607
+ return { ok: false, reason: 'envelope_ttl_exceeded' };
608
+ }
609
+
610
+ return {
611
+ ok: true,
612
+ payload,
613
+ envelopeDigest: `sha256:${createHash('sha256')
614
+ .update(canonicalEnvelope)
615
+ .digest('hex')}`,
616
+ };
617
+ }
618
+
619
+ function reservationFrom(
620
+ payload: Readonly<ConsequenceExecutionEnvelopePayload>,
621
+ envelopeDigest: string,
622
+ ): ConsequenceActuatorReservation {
623
+ return deepFreeze({
624
+ tenantId: payload.tenant_id,
625
+ attemptId: payload.attempt_id,
626
+ actionDigest: payload.action_digest,
627
+ caid: payload.caid,
628
+ providerAccountId: payload.provider_account_id,
629
+ targetDigest: payload.target_digest,
630
+ operation: payload.operation,
631
+ idempotencyKey: payload.idempotency_key,
632
+ nonce: payload.nonce,
633
+ issuedAt: payload.issued_at,
634
+ expiresAt: payload.expires_at,
635
+ envelopeDigest,
636
+ });
637
+ }
638
+
639
+ function refusal(
640
+ reason: ConsequenceActuatorRefusalReason,
641
+ invoked: boolean,
642
+ envelopeDigest?: string,
643
+ ): ConsequenceActuatorExecutionResult<never> {
644
+ return deepFreeze({
645
+ ok: false,
646
+ invoked,
647
+ reason,
648
+ ...(envelopeDigest === undefined ? {} : { envelopeDigest }),
649
+ });
650
+ }
651
+
652
+ /** Create a closed Ed25519 execution envelope for an already-authorized effect. */
653
+ export function signConsequenceExecutionEnvelope(
654
+ payload: ConsequenceExecutionEnvelopePayload,
655
+ options: SignEnvelopeOptions,
656
+ ): SignedConsequenceExecutionEnvelope {
657
+ const cloned = canonicalClone(payload);
658
+ if (!validatePayloadShape(cloned.value)) {
659
+ throw new TypeError('execution envelope payload is malformed');
660
+ }
661
+ if (!validIdentifier(options?.keyId)) {
662
+ throw new TypeError('execution envelope keyId is malformed');
663
+ }
664
+ const signature = sign(
665
+ null,
666
+ signatureInput(cloned.canonical),
667
+ normalizePrivateKey(options.privateKey),
668
+ ).toString('base64url');
669
+ return deepFreeze({
670
+ payload: cloned.value,
671
+ signature: {
672
+ algorithm: CONSEQUENCE_ACTUATOR_SIGNATURE_ALGORITHM,
673
+ key_id: options.keyId,
674
+ value: signature,
675
+ },
676
+ });
677
+ }
678
+
679
+ /** Verify an envelope without invoking a provider or mutating replay state. */
680
+ export function verifyConsequenceExecutionEnvelope(
681
+ envelope: unknown,
682
+ options: VerifyOptions,
683
+ ): ConsequenceEnvelopeVerification {
684
+ try {
685
+ return verifyWithNormalizedPins(
686
+ envelope,
687
+ normalizePins(options.pins),
688
+ options.expected,
689
+ options.now,
690
+ );
691
+ } catch {
692
+ return { ok: false, reason: 'malformed_envelope' };
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Credential-owning actuator. `perform` captures the provider credential in
698
+ * the actuator process; callers cannot submit or replace that credential.
699
+ */
700
+ export class ConsequenceActuator<TResult = unknown> {
701
+ readonly pins: VisibleConsequenceActuatorPins;
702
+
703
+ readonly #normalizedPins: NormalizedPins;
704
+ readonly #reserve: ConsequenceActuatorStore['reserve'];
705
+ readonly #consume: ConsequenceActuatorStore['consume'];
706
+ readonly #perform: (
707
+ binding: Readonly<ConsequenceExecutionEnvelopePayload>,
708
+ ) => TResult | Promise<TResult>;
709
+ readonly #now: number | (() => number) | undefined;
710
+
711
+ constructor(options: ConsequenceActuatorOptions<TResult>) {
712
+ if (
713
+ !isRecord(options)
714
+ || !isRecord(options.store)
715
+ || typeof options.store.reserve !== 'function'
716
+ || typeof options.store.consume !== 'function'
717
+ || typeof options.perform !== 'function'
718
+ ) {
719
+ throw new TypeError(
720
+ 'consequence actuator requires reserve/consume storage and a provider callback',
721
+ );
722
+ }
723
+ const productionCapable = options.store.durable === true
724
+ && options.store.atomic === true
725
+ && options.store.ownershipFenced === true
726
+ && options.store.permanentConsumption === true;
727
+ const explicitTestStore = options.store.testOnly === true
728
+ && options.testOnly === true;
729
+ if (!productionCapable && !explicitTestStore) {
730
+ throw new TypeError(
731
+ 'consequence actuator requires durable, atomic, ownership-fenced storage with permanent consumption; a non-production store requires testOnly: true',
732
+ );
733
+ }
734
+ this.#normalizedPins = normalizePins(options.pins);
735
+ this.pins = this.#normalizedPins.visible;
736
+ this.#reserve = options.store.reserve.bind(options.store);
737
+ this.#consume = options.store.consume.bind(options.store);
738
+ this.#perform = options.perform;
739
+ this.#now = options.now;
740
+ Object.freeze(this);
741
+ }
742
+
743
+ async execute(
744
+ input: ConsequenceActuatorExecutionInput,
745
+ ): Promise<ConsequenceActuatorExecutionResult<TResult>> {
746
+ const verified = verifyWithNormalizedPins(
747
+ input?.envelope,
748
+ this.#normalizedPins,
749
+ {
750
+ attemptId: input?.attemptId,
751
+ actionDigest: input?.actionDigest,
752
+ idempotencyKey: input?.idempotencyKey,
753
+ },
754
+ this.#now,
755
+ );
756
+ if (!verified.ok) {
757
+ return refusal(verified.reason, false);
758
+ }
759
+ const reservation = reservationFrom(
760
+ verified.payload,
761
+ verified.envelopeDigest,
762
+ );
763
+ let reserved = false;
764
+ try {
765
+ reserved = await this.#reserve(reservation);
766
+ } catch {
767
+ return refusal(
768
+ 'store_reserve_failed',
769
+ false,
770
+ verified.envelopeDigest,
771
+ );
772
+ }
773
+ if (reserved !== true) {
774
+ return refusal(
775
+ 'envelope_replayed',
776
+ false,
777
+ verified.envelopeDigest,
778
+ );
779
+ }
780
+
781
+ let providerResult: TResult;
782
+ try {
783
+ providerResult = await this.#perform(verified.payload);
784
+ } catch {
785
+ try {
786
+ const consumed = await this.#consume(
787
+ deepFreeze({ ...reservation, outcome: 'INDETERMINATE' as const }),
788
+ );
789
+ if (consumed !== true) {
790
+ return refusal(
791
+ 'store_consume_unconfirmed',
792
+ true,
793
+ verified.envelopeDigest,
794
+ );
795
+ }
796
+ } catch {
797
+ return refusal(
798
+ 'store_consume_unconfirmed',
799
+ true,
800
+ verified.envelopeDigest,
801
+ );
802
+ }
803
+ return refusal(
804
+ 'provider_outcome_indeterminate',
805
+ true,
806
+ verified.envelopeDigest,
807
+ );
808
+ }
809
+
810
+ try {
811
+ const consumed = await this.#consume(
812
+ deepFreeze({ ...reservation, outcome: 'COMMITTED' as const }),
813
+ );
814
+ if (consumed !== true) {
815
+ return refusal(
816
+ 'store_consume_unconfirmed',
817
+ true,
818
+ verified.envelopeDigest,
819
+ );
820
+ }
821
+ } catch {
822
+ return refusal(
823
+ 'store_consume_unconfirmed',
824
+ true,
825
+ verified.envelopeDigest,
826
+ );
827
+ }
828
+ return deepFreeze({
829
+ ok: true,
830
+ invoked: true,
831
+ result: providerResult,
832
+ envelopeDigest: verified.envelopeDigest,
833
+ });
834
+ }
835
+ }
836
+
837
+ function reservationKey(
838
+ tenantId: string,
839
+ nonce: string,
840
+ ): string {
841
+ return canonicalize([tenantId, nonce]);
842
+ }
843
+
844
+ function idempotencyKey(
845
+ reservation: ConsequenceActuatorReservation,
846
+ ): string {
847
+ return canonicalize([
848
+ reservation.tenantId,
849
+ reservation.providerAccountId,
850
+ reservation.operation,
851
+ reservation.idempotencyKey,
852
+ ]);
853
+ }
854
+
855
+ function sameReservation(
856
+ left: ConsequenceActuatorReservation,
857
+ right: ConsequenceActuatorReservation,
858
+ ): boolean {
859
+ const identity = (value: ConsequenceActuatorReservation) => ({
860
+ tenantId: value.tenantId,
861
+ attemptId: value.attemptId,
862
+ actionDigest: value.actionDigest,
863
+ caid: value.caid,
864
+ providerAccountId: value.providerAccountId,
865
+ targetDigest: value.targetDigest,
866
+ operation: value.operation,
867
+ idempotencyKey: value.idempotencyKey,
868
+ nonce: value.nonce,
869
+ issuedAt: value.issuedAt,
870
+ expiresAt: value.expiresAt,
871
+ envelopeDigest: value.envelopeDigest,
872
+ });
873
+ return canonicalize(identity(left)) === canonicalize(identity(right));
874
+ }
875
+
876
+ /**
877
+ * Race-safe, process-local reference store for tests. Map mutations occur
878
+ * before each async method yields, so concurrent calls have one reservation
879
+ * winner. Production must use the RPC-only durable store from the migration.
880
+ */
881
+ export class MemoryConsequenceActuatorStore
882
+ implements ConsequenceActuatorStore {
883
+ readonly testOnly = true as const;
884
+ readonly durable = false;
885
+ readonly atomic = true;
886
+ readonly ownershipFenced = false;
887
+ readonly permanentConsumption = false;
888
+
889
+ readonly #records = new Map<string, MemoryConsequenceActuatorSnapshot>();
890
+ readonly #idempotency = new Map<string, string>();
891
+ readonly #now: number | (() => number) | undefined;
892
+
893
+ constructor(options: { now?: number | (() => number) } = {}) {
894
+ this.#now = options.now;
895
+ }
896
+
897
+ async reserve(
898
+ reservation: ConsequenceActuatorReservation,
899
+ ): Promise<boolean> {
900
+ const key = reservationKey(reservation.tenantId, reservation.nonce);
901
+ const operationKey = idempotencyKey(reservation);
902
+ if (this.#records.has(key) || this.#idempotency.has(operationKey)) {
903
+ return false;
904
+ }
905
+ const reservedAt = new Date(nowMilliseconds(this.#now)).toISOString();
906
+ const snapshot = deepFreeze({
907
+ ...reservation,
908
+ state: 'RESERVED' as const,
909
+ outcome: null,
910
+ reservedAt,
911
+ consumedAt: null,
912
+ });
913
+ this.#records.set(key, snapshot);
914
+ this.#idempotency.set(operationKey, key);
915
+ return true;
916
+ }
917
+
918
+ async consume(
919
+ consumption: ConsequenceActuatorConsumption,
920
+ ): Promise<boolean> {
921
+ const key = reservationKey(consumption.tenantId, consumption.nonce);
922
+ const current = this.#records.get(key);
923
+ if (
924
+ current === undefined
925
+ || current.state !== 'RESERVED'
926
+ || !sameReservation(current, consumption)
927
+ || !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)
928
+ ) {
929
+ return false;
930
+ }
931
+ this.#records.set(key, deepFreeze({
932
+ ...current,
933
+ state: 'CONSUMED' as const,
934
+ outcome: consumption.outcome,
935
+ consumedAt: new Date(nowMilliseconds(this.#now)).toISOString(),
936
+ }));
937
+ return true;
938
+ }
939
+
940
+ snapshot(
941
+ tenantId: string,
942
+ nonce: string,
943
+ ): MemoryConsequenceActuatorSnapshot | null {
944
+ const snapshot = this.#records.get(reservationKey(tenantId, nonce));
945
+ if (snapshot === undefined) return null;
946
+ return canonicalClone(snapshot).value as MemoryConsequenceActuatorSnapshot;
947
+ }
948
+
949
+ get size(): number {
950
+ return this.#records.size;
951
+ }
952
+ }
953
+
954
+ export function createMemoryConsequenceActuatorStore(
955
+ options: { now?: number | (() => number) } = {},
956
+ ): MemoryConsequenceActuatorStore {
957
+ return new MemoryConsequenceActuatorStore(options);
958
+ }
959
+
960
+ const FORBIDDEN_EXECUTOR_PRINCIPALS = new Set([
961
+ 'anon',
962
+ 'authenticated',
963
+ 'postgres',
964
+ 'service_role',
965
+ CONSEQUENCE_ACTUATOR_EXECUTOR_ROLE,
966
+ CONSEQUENCE_ACTUATOR_STORE_OWNER_ROLE,
967
+ ]);
968
+
969
+ function validStoreBinding(
970
+ reservation: ConsequenceActuatorReservation,
971
+ ): boolean {
972
+ const issuedAt = canonicalInstant(reservation?.issuedAt);
973
+ const expiresAt = canonicalInstant(reservation?.expiresAt);
974
+ return validIdentifier(reservation?.tenantId)
975
+ && validIdentifier(reservation?.attemptId)
976
+ && validDigest(reservation?.actionDigest)
977
+ && typeof reservation?.caid === 'string'
978
+ && CAID_PATTERN.test(reservation.caid)
979
+ && validIdentifier(reservation?.providerAccountId)
980
+ && validDigest(reservation?.targetDigest)
981
+ && validIdentifier(reservation?.operation)
982
+ && validIdentifier(reservation?.idempotencyKey)
983
+ && decodeCanonicalBase64Url(reservation?.nonce, 16, 64) !== null
984
+ && issuedAt !== null
985
+ && expiresAt !== null
986
+ && expiresAt > issuedAt
987
+ && validDigest(reservation?.envelopeDigest);
988
+ }
989
+
990
+ function exactPostgresAcknowledgement(
991
+ result: ConsequenceActuatorPgQueryResult,
992
+ expectedEnvelopeDigest: string,
993
+ ): boolean {
994
+ if (
995
+ result !== null
996
+ && typeof result === 'object'
997
+ && result.rowCount === 0
998
+ && Array.isArray(result.rows)
999
+ && result.rows.length === 0
1000
+ ) {
1001
+ return false;
1002
+ }
1003
+ if (
1004
+ result === null
1005
+ || typeof result !== 'object'
1006
+ || result.rowCount !== 1
1007
+ || !Array.isArray(result.rows)
1008
+ || result.rows.length !== 1
1009
+ || !isRecord(result.rows[0])
1010
+ || result.rows[0].envelope_digest !== expectedEnvelopeDigest
1011
+ ) {
1012
+ throw new Error(
1013
+ 'consequence actuator store returned an ambiguous acknowledgement',
1014
+ );
1015
+ }
1016
+ return true;
1017
+ }
1018
+
1019
+ /**
1020
+ * Production adapter for the RPC-only PostgreSQL store. The supplied pool must
1021
+ * be dedicated to one tenant-mapped executor login; the adapter never emits
1022
+ * direct table SQL.
1023
+ */
1024
+ export class PostgresConsequenceActuatorStore
1025
+ implements ConsequenceActuatorStore {
1026
+ readonly durable = true;
1027
+ readonly atomic = true;
1028
+ readonly ownershipFenced = true;
1029
+ readonly permanentConsumption = true;
1030
+ readonly tenantId: string;
1031
+ readonly executorPrincipal: string;
1032
+
1033
+ readonly #query: DedicatedConsequenceActuatorExecutorPool['query'];
1034
+
1035
+ constructor(options: PostgresConsequenceActuatorStoreOptions) {
1036
+ if (
1037
+ !isRecord(options)
1038
+ || !validIdentifier(options.tenantId)
1039
+ || !validIdentifier(options.executorPrincipal)
1040
+ || FORBIDDEN_EXECUTOR_PRINCIPALS.has(options.executorPrincipal)
1041
+ || !isRecord(options.executorPool)
1042
+ || options.executorPool.principal !== options.executorPrincipal
1043
+ || typeof options.executorPool.query !== 'function'
1044
+ ) {
1045
+ throw new TypeError(
1046
+ 'a dedicated tenant executor principal and matching pool are required',
1047
+ );
1048
+ }
1049
+ this.tenantId = options.tenantId;
1050
+ this.executorPrincipal = options.executorPrincipal;
1051
+ this.#query = options.executorPool.query.bind(options.executorPool);
1052
+ Object.freeze(this);
1053
+ }
1054
+
1055
+ async reserve(
1056
+ reservation: ConsequenceActuatorReservation,
1057
+ ): Promise<boolean> {
1058
+ if (
1059
+ !validStoreBinding(reservation)
1060
+ || reservation.tenantId !== this.tenantId
1061
+ ) {
1062
+ throw new TypeError(
1063
+ 'consequence actuator reservation does not match the store tenant',
1064
+ );
1065
+ }
1066
+ const result = await this.#query(
1067
+ CONSEQUENCE_ACTUATOR_SQL.reserve,
1068
+ [
1069
+ reservation.tenantId,
1070
+ reservation.attemptId,
1071
+ reservation.actionDigest,
1072
+ reservation.caid,
1073
+ reservation.providerAccountId,
1074
+ reservation.targetDigest,
1075
+ reservation.operation,
1076
+ reservation.idempotencyKey,
1077
+ reservation.nonce,
1078
+ reservation.issuedAt,
1079
+ reservation.expiresAt,
1080
+ reservation.envelopeDigest,
1081
+ ],
1082
+ );
1083
+ return exactPostgresAcknowledgement(result, reservation.envelopeDigest);
1084
+ }
1085
+
1086
+ async consume(
1087
+ consumption: ConsequenceActuatorConsumption,
1088
+ ): Promise<boolean> {
1089
+ if (
1090
+ !validStoreBinding(consumption)
1091
+ || consumption.tenantId !== this.tenantId
1092
+ || !['COMMITTED', 'INDETERMINATE'].includes(consumption.outcome)
1093
+ ) {
1094
+ throw new TypeError(
1095
+ 'consequence actuator consumption does not match the store tenant',
1096
+ );
1097
+ }
1098
+ const result = await this.#query(
1099
+ CONSEQUENCE_ACTUATOR_SQL.consume,
1100
+ [
1101
+ consumption.tenantId,
1102
+ consumption.attemptId,
1103
+ consumption.actionDigest,
1104
+ consumption.caid,
1105
+ consumption.providerAccountId,
1106
+ consumption.targetDigest,
1107
+ consumption.operation,
1108
+ consumption.idempotencyKey,
1109
+ consumption.nonce,
1110
+ consumption.envelopeDigest,
1111
+ consumption.outcome,
1112
+ ],
1113
+ );
1114
+ return exactPostgresAcknowledgement(result, consumption.envelopeDigest);
1115
+ }
1116
+ }
1117
+
1118
+ export function createPostgresConsequenceActuatorStore(
1119
+ options: PostgresConsequenceActuatorStoreOptions,
1120
+ ): PostgresConsequenceActuatorStore {
1121
+ return new PostgresConsequenceActuatorStore(options);
1122
+ }