@pulse-compute/wasm-host-runtime 1.0.0-beta.5 → 1.0.0-beta.6

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/README.md CHANGED
@@ -7,9 +7,9 @@
7
7
  > **Supported entry points:** `None for application authors.`<br>
8
8
  > **Stability:** Internal compiler/runtime interface synchronized with this release set.<br>
9
9
  > **npm:** [`@pulse-compute/wasm-host-runtime`](https://www.npmjs.com/package/@pulse-compute/wasm-host-runtime)<br>
10
- > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/)
10
+ > **Canonical documentation:** [Package guide](https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/)
11
11
  >
12
- > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.5` package policy.
12
+ > This release-status block is generated from the synchronized `Pulse 1.0.0-beta.6` package policy.
13
13
  <!-- pulse-package-status:end -->
14
14
 
15
15
  Package-owned PulseWasm host-runtime build-time builders and local runtime helpers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pulse-compute/wasm-host-runtime",
3
- "version": "1.0.0-beta.5",
3
+ "version": "1.0.0-beta.6",
4
4
  "license": "Apache-2.0",
5
5
  "engines": {
6
6
  "node": "^22.14.0 || ^24.0.0"
@@ -28,11 +28,11 @@
28
28
  "./runtime/canonical-native-host": "./src/runtime/canonical-native-host.js"
29
29
  },
30
30
  "dependencies": {
31
- "@pulse-compute/crypto": "1.0.0-beta.5",
32
- "@pulse-compute/runtime": "1.0.0-beta.5",
33
- "@pulse-compute/wasm-build-support": "1.0.0-beta.5",
34
- "@pulse-compute/wasm-contracts": "1.0.0-beta.5",
35
- "@pulse-compute/wasm-schema-json": "1.0.0-beta.5"
31
+ "@pulse-compute/crypto": "1.0.0-beta.6",
32
+ "@pulse-compute/runtime": "1.0.0-beta.6",
33
+ "@pulse-compute/wasm-build-support": "1.0.0-beta.6",
34
+ "@pulse-compute/wasm-contracts": "1.0.0-beta.6",
35
+ "@pulse-compute/wasm-schema-json": "1.0.0-beta.6"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -48,7 +48,7 @@
48
48
  "url": "git+https://github.com/pulse-compute/pulse.git",
49
49
  "directory": "wasm/packages/host-runtime"
50
50
  },
51
- "homepage": "https://pulsecompute.io/v1.0.0-beta.5/packages/implementation-packages/",
51
+ "homepage": "https://pulsecompute.io/v1.0.0-beta.6/packages/implementation-packages/",
52
52
  "bugs": {
53
53
  "url": "https://github.com/pulse-compute/pulse/issues"
54
54
  }
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { createEffectInvocations, normalizeMaxEffects } = require('./effect-invocations.js');
2
3
  const portableKv = require('@pulse-compute/runtime/host');
3
4
 
4
5
 
@@ -262,7 +263,10 @@ function redactRuntimeError(error, sensitiveValues = new Set(), seen = new WeakS
262
263
  const rawDetail = ownDataValue(error, 'detail');
263
264
  const message = redactString(typeof rawMessage === 'string' ? rawMessage : 'Pulse runtime failure.', sensitiveValues);
264
265
  const name = redactString(typeof rawName === 'string' ? rawName : 'Error', sensitiveValues);
265
- const code = typeof rawCode === 'string' ? redactString(rawCode, sensitiveValues) : undefined;
266
+ // Keep the finite admitted discriminator intact; messages, causes and arbitrary
267
+ // provider codes still pass through ordinary sensitive-value redaction.
268
+ const code = portableKv.isApplicationError(error) ? rawCode
269
+ : typeof rawCode === 'string' ? redactString(rawCode, sensitiveValues) : undefined;
266
270
  const detail = rawDetail === undefined ? undefined : redactRuntimeValue(rawDetail, sensitiveValues, seen, 'detail', depth + 1);
267
271
 
268
272
  const safe = code === undefined
@@ -786,11 +790,12 @@ function normalizedInit(init = {}, schemaCodecs, options = {}) {
786
790
  let body;
787
791
  let bodyMode = 'none';
788
792
  if (hasBody) {
789
- if (typeof value.body !== 'string') {
793
+ const incoming = options.incomingBody && value.body === options.incomingBody.marker();
794
+ if (typeof value.body !== 'string' && !incoming) {
790
795
  throw new CanonicalRuntimeError('FetchRequestError', 'PULSE_FETCH_BODY_INVALID', 'Canonical fetch body must be a string.', { valueType: typeof value.body });
791
796
  }
792
797
  body = value.body;
793
- bodyMode = 'text';
798
+ bodyMode = incoming ? 'incoming-request-v1' : 'text';
794
799
  } else if (hasJson) {
795
800
  if (!headerValue(headers, 'content-type')) headers.push(['content-type', 'application/json; charset=utf-8']);
796
801
  if (hasSchema) {
@@ -936,6 +941,8 @@ function normalizeProviderEffect(effect, schemaCodecs, options = {}) {
936
941
  if (effect.kind === 'fetch') return normalizeFetchEffect(effect, schemaCodecs, options);
937
942
  const id = String(effect.id || '');
938
943
  if (!id) throw new CanonicalRuntimeError('CanonicalEffectProtocolError', 'PULSE_CANONICAL_EFFECT_PROTOCOL', 'Canonical provider effect requires an id.', { effect });
944
+ if (effect.kind === 'output.readTextChunk') return Object.freeze({ id, kind: effect.kind, providerKind: 'output', operation: 'readTextChunk', capability: 'request.body.transform' });
945
+ if (effect.kind === 'output.start' || effect.kind === 'output.write') return Object.freeze({ id, kind: effect.kind, providerKind: 'output', operation: effect.kind.slice(7), capability: 'response.output', argument0: effect.argument0 });
939
946
  if (effect.kind === 'time.now') return Object.freeze({ id, kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock', source: effect.source, ...(effect.groupKey === undefined ? {} : { groupKey: String(effect.groupKey) }) });
940
947
  if (effect.kind === 'config.get' || effect.kind === 'secret.get') {
941
948
  return Object.freeze({ id, kind: effect.kind, name: String(effect.name), ...(effect.groupKey === undefined ? {} : { groupKey: String(effect.groupKey) }), source: effect.source });
@@ -1364,6 +1371,9 @@ function createCanonicalHostRuntime(options = {}) {
1364
1371
  const metadata = assertCanonicalProgramCompatibility(programModule);
1365
1372
  executionSequence += 1;
1366
1373
  const executionId = String(executionOptions.executionId || `canonical-${adapter.id}-${executionSequence}`);
1374
+ const maxEffects = normalizeMaxEffects(executionOptions.maxEffects ?? options.maxEffects);
1375
+ const invocations = createEffectInvocations(() => executionOptions.requestBudget.check());
1376
+ const continuationIds = [];
1367
1377
  const sensitiveValues = initialSensitiveValues({ ...options, ...executionOptions });
1368
1378
  let executionStatus = 'failed';
1369
1379
  let executionFailure;
@@ -1416,23 +1426,24 @@ function createCanonicalHostRuntime(options = {}) {
1416
1426
  let dispatchedEffects = 0;
1417
1427
 
1418
1428
  function executionContinuations() {
1419
- const prefix = `${executionId}:`;
1420
- return registry.list().filter((entry) => entry.id.startsWith(prefix));
1429
+ return continuationIds.map(id => registry.get(id));
1421
1430
  }
1422
1431
 
1423
1432
  async function dispatchOne(effect) {
1424
1433
  executionOptions.requestBudget.check();
1434
+ const ticket = invocations.open(effect.id, effect.id);
1425
1435
  const normalized = normalizeProviderEffect(effect, schemaCodecs, runtimeOptions);
1426
1436
  const conditional = portableKv.isConditionalKv(normalized.kind);
1427
1437
  if (conditional) portableKv.registerKvRedactions(normalized, (value) => sensitiveValues.add(value));
1428
- recordTrace(effectTraceStart(adapter.id, executionId, normalized, sensitiveValues));
1438
+ recordTrace({ ...effectTraceStart(adapter.id, executionId, normalized, sensitiveValues), invocationId: ticket.invocationId });
1429
1439
  dispatchedEffects += 1;
1430
1440
  try {
1431
- const effectExecution = { ...options, ...executionOptions, metadata, executionId, trace: traceSink, registerRedactionValue: (value) => sensitiveValues.add(value), onKvObservation: recordTrace };
1441
+ const effectExecution = { ...options, ...executionOptions, metadata, executionId, invocationId: ticket.invocationId, trace: traceSink, registerRedactionValue: (value) => sensitiveValues.add(value), onKvObservation: recordTrace };
1432
1442
  let snapshot = conditional
1433
1443
  ? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)), effectExecution, runtimeOptions)
1434
1444
  : await executionOptions.requestBudget.race(adapter.dispatchEffect(normalized, effectExecution));
1435
1445
  executionOptions.requestBudget.check();
1446
+ invocations.assertPending(ticket);
1436
1447
  if (normalized.kind === 'config.get' || normalized.kind === 'secret.get') {
1437
1448
  if (snapshot !== undefined && typeof snapshot !== 'string') {
1438
1449
  throw new CanonicalRuntimeError(
@@ -1480,9 +1491,13 @@ function createCanonicalHostRuntime(options = {}) {
1480
1491
  })
1481
1492
  : snapshot;
1482
1493
  resolutionOrder.push(effect.id);
1483
- recordTrace(effectTraceResolved(adapter.id, executionId, normalized, value));
1484
- return value;
1494
+ recordTrace({ ...effectTraceResolved(adapter.id, executionId, normalized, value), invocationId: ticket.invocationId });
1495
+ return invocations.settle(ticket, () => value);
1485
1496
  } catch (error) {
1497
+ // A request deadline outranks a capability's concurrent abort result.
1498
+ try { executionOptions.requestBudget.check(); } catch (failure) {
1499
+ if (typeof failure?.code === 'string' && failure.code.startsWith('PULSE_REQUEST_')) error = failure;
1500
+ }
1486
1501
  recordTrace(Object.freeze({ type: 'effect-failed', provider: adapter.id, executionId, effectId: effect.id, kind: normalized.kind, error: error.name || 'Error', code: error.code }));
1487
1502
  throw redactRuntimeError(error, sensitiveValues);
1488
1503
  }
@@ -1533,12 +1548,18 @@ function createCanonicalHostRuntime(options = {}) {
1533
1548
  const marker = step.value;
1534
1549
  if (!marker || marker.protocol !== canonicalRuntimeContract.CANONICAL_RUNTIME_PROTOCOL_VERSION || !canonicalRuntimeContract.CANONICAL_EFFECT_MARKER_KINDS.includes(marker.kind)) throw new CanonicalRuntimeError('CanonicalEffectProtocolError', 'PULSE_CANONICAL_EFFECT_PROTOCOL', 'Canonical handler yielded an unsupported runtime marker.', { marker });
1535
1550
  const effects = marker.kind === 'group' ? marker.effects : [marker.effect];
1536
- const continuationId = `${executionId}:${marker.continuationId}`;
1551
+ const continuationId = `${executionId}:${invocations.execution}:${marker.continuationId}:${continuationIds.length + 1}`;
1537
1552
  registry.create({ id: continuationId, branchPoint: marker.continuationId, effectIds: effects.map((effect) => effect.id), ttlMs: executionOptions.continuationTtlMs || options.continuationTtlMs });
1553
+ continuationIds.push(continuationId);
1538
1554
  registry.wait(continuationId);
1539
1555
  recordTrace(Object.freeze({ type: 'continuation-waiting', provider: adapter.id, executionId, continuationId, effectIds: effects.map((effect) => effect.id) }));
1540
1556
  let value;
1541
1557
  try {
1558
+ if (dispatchedEffects + effects.length > maxEffects) {
1559
+ throw new CanonicalRuntimeError('EffectLimitError', 'PULSE_RUNTIME_EFFECT_LIMIT_EXCEEDED',
1560
+ `Pulse request exceeded the maximum of ${maxEffects} request-owned effects.`,
1561
+ { effectCount: dispatchedEffects, pendingEffects: effects.length, maxEffects });
1562
+ }
1542
1563
  value = marker.kind === 'group' ? await dispatchGroup(effects) : await dispatchOne(effects[0]);
1543
1564
  executionOptions.requestBudget.check();
1544
1565
  registry.resume(continuationId);
@@ -1584,6 +1605,7 @@ function createCanonicalHostRuntime(options = {}) {
1584
1605
  executionFailure = redactRuntimeError(error, sensitiveValues);
1585
1606
  throw executionFailure;
1586
1607
  } finally {
1608
+ invocations.close();
1587
1609
  try { adapter.disposeExecution(Object.freeze({ executionId, metadata, status: executionStatus, error: executionFailure })); }
1588
1610
  catch (_) { /* provider cleanup must not mask execution results */ }
1589
1611
  }
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
  const portableKv = require('@pulse-compute/runtime/host');
3
+ const { createEffectInvocations, normalizeMaxEffects } = require('./effect-invocations.js');
4
+ const { NativeValueBudget } = require('./native-value-budget.js');
3
5
 
4
6
  const crypto = require('node:crypto');
5
7
  const {
@@ -45,7 +47,6 @@ const canonicalRuntime = loadCanonicalRuntime();
45
47
  const schemaCodecTools = loadSchemaCodecTools();
46
48
  const eventContract = loadEventContract();
47
49
  let executionSequence = 0;
48
- const DEFAULT_MAX_EFFECTS = 1024;
49
50
 
50
51
  class CanonicalNativeHostError extends Error {
51
52
  constructor(message, code = 'PULSE_CANONICAL_NATIVE_HOST_FAILED', detail = {}) {
@@ -60,14 +61,6 @@ function sha256(value) {
60
61
  return crypto.createHash('sha256').update(value).digest('hex');
61
62
  }
62
63
 
63
- function normalizeMaxEffects(value) {
64
- const maxEffects = value === undefined ? DEFAULT_MAX_EFFECTS : Number(value);
65
- if (!Number.isSafeInteger(maxEffects) || maxEffects <= 0) {
66
- throw new TypeError('Pulse maxEffects must be a positive safe integer.');
67
- }
68
- return maxEffects;
69
- }
70
-
71
64
  function staticDecoderArgument(expression, effect) {
72
65
  if (expression && expression.kind === 'literal' && ['string', 'number', 'boolean'].includes(typeof expression.value)) return expression.value;
73
66
  if (expression && expression.kind === 'literal' && expression.value === null) return null;
@@ -117,13 +110,40 @@ function createSchemaCodecs(registryInput = {}) {
117
110
  }
118
111
 
119
112
  class ValueHeap {
120
- constructor() {
113
+ constructor(budget) {
114
+ this.budget = budget;
121
115
  this.values = new Map();
122
116
  this.next = 1;
117
+ // Immutable scalars have value semantics. Reuse common handles rather than
118
+ // retaining a new boxed value for every literal/property read in a loop.
119
+ // Bound the index itself and rotate its oldest mapping as the working set
120
+ // changes. Eviction never releases a handle or refunds its cumulative charge.
121
+ this.scalars = budget ? new Map() : undefined;
122
+ this.identities = budget ? new WeakMap() : undefined;
123
+ this.identityCount = 0;
124
+ this.negativeZero = Symbol('negative-zero');
123
125
  }
124
126
  put(value) {
127
+ this.budget?.charge(0, 0); // Cached reads cannot recover a latched failure.
128
+ const type = typeof value;
129
+ const scalar = value === null || type === 'undefined' || type === 'boolean' || type === 'number' || type === 'string';
130
+ const object = value !== null && type === 'object';
131
+ if (object && this.identities?.has(value)) return this.identities.get(value);
132
+ const key = Object.is(value, -0) ? this.negativeZero : value;
133
+ if (scalar && this.scalars?.has(key)) return this.scalars.get(key);
134
+ const cache = scalar && this.scalars;
135
+ const cacheIdentity = object && this.identities && this.identityCount < 8192;
136
+ // Charge index growth once. Replacing a scalar mapping reuses its bounded
137
+ // capacity; every new value handle below remains cumulatively charged.
138
+ if ((cache && this.scalars.size < 8192) || cacheIdentity) this.budget.charge(1, this.budget.policy.edgeBytes * 2);
139
+ this.budget?.retain(value);
125
140
  const handle = this.next++;
126
141
  this.values.set(handle, value);
142
+ if (cache) {
143
+ if (this.scalars.size === 8192) this.scalars.delete(this.scalars.keys().next().value);
144
+ this.scalars.set(key, handle);
145
+ }
146
+ if (cacheIdentity) { this.identities.set(value, handle); this.identityCount++; }
127
147
  return handle;
128
148
  }
129
149
  has(handle) { return this.values.has(Number(handle)); }
@@ -244,6 +264,7 @@ function nativeEffect(planEffect, payload) {
244
264
  resource: planEffect.resource,
245
265
  source: planEffect.source
246
266
  };
267
+ if (planEffect.kind === 'output.start' || planEffect.kind === 'output.write') return { ...base, argument0: payload.argument0 };
247
268
  if (planEffect.kind === 'time.now') return { ...base, providerKind: 'time', operation: 'now', capability: 'time.wall-clock' };
248
269
  if (planEffect.kind === 'fetch') {
249
270
  const response = nativeFetchResponseContract(planEffect, payload);
@@ -348,6 +369,9 @@ function validateNativeModule(module, plan) {
348
369
  const missing = runtimeContract.CANONICAL_NATIVE_EXPORT_NAMES.filter((name) => !exportNames.has(name));
349
370
  if (missing.length > 0) throw new CanonicalNativeHostError('Native module is missing required ABI exports.', 'PULSE_CANONICAL_NATIVE_ABI_EXPORT_MISMATCH', { missing });
350
371
  if (!plan || typeof plan !== 'object' || !plan.planHash) throw new TypeError('Native module execution requires its canonical native plan.');
372
+ if (plan.packages?.application?.effectFailure === 'package-completion' && !exports.some(entry => entry.name === 'pulse_package_set_effect_failure' && entry.kind === 'function')) {
373
+ throw new CanonicalNativeHostError('Native package application is missing its effect failure ABI.', 'PULSE_CANONICAL_NATIVE_ABI_EXPORT_MISMATCH');
374
+ }
351
375
  const eventReachable = Boolean(plan.events && plan.events.catalog && plan.events.catalog.events.length > 0);
352
376
  const eventExportNames = eventContract.EVENT_NATIVE_ABI_EXTENSION.exports.map((entry) => entry.name);
353
377
  const presentEventExports = eventExportNames.filter((name) => exportNames.has(name));
@@ -371,9 +395,32 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
371
395
  const moduleShape = validateNativeModule(module, plan);
372
396
  const adapter = normalizeProviderAdapter(options.providerAdapter || options.provider);
373
397
  const executionPlane = options.executionPlane === 'event' ? 'event' : 'http';
374
- const heap = new ValueHeap();
398
+ const memoryBudget = (runtimeContract.hasBoundedReadLoop(plan) || plan.capabilities.includes('response.output') || plan.capabilities.includes('request.body.transform'))
399
+ ? new NativeValueBudget(runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY, close) : undefined;
400
+ const heap = new ValueHeap(memoryBudget);
375
401
  const pending = [];
402
+ const invocations = createEffectInvocations(() => {
403
+ options.requestBudget?.check();
404
+ if (options.signal?.aborted) throw nativeRequestCancelled(options.signal);
405
+ });
406
+ function close() {
407
+ invocations.close();
408
+ pending.length = 0;
409
+ options.signal?.removeEventListener('abort', close);
410
+ }
411
+ function run(operation) {
412
+ try {
413
+ invocations.assertOpen();
414
+ const status = operation();
415
+ if (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.COMPLETE || status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.FAILED) close();
416
+ return status;
417
+ } catch (error) { close(); throw error; }
418
+ }
376
419
  const trace = [];
420
+ if (memoryBudget) Object.defineProperty(trace, 'push', { value(...entries) {
421
+ for (const entry of entries) memoryBudget.retain(entry);
422
+ return Array.prototype.push.apply(this, entries);
423
+ } });
377
424
  const sensitiveValues = new Set();
378
425
  const configuredSecrets = options.secrets && typeof options.secrets === 'object' ? options.secrets : {};
379
426
  for (const value of Object.values(configuredSecrets)) if (typeof value === 'string' && value.length > 0) sensitiveValues.add(value);
@@ -569,6 +616,15 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
569
616
  }
570
617
 
571
618
  const pulseHost = {
619
+ value_json(handle) {
620
+ const text = JSON.stringify(value(handle) ?? null);
621
+ if (Buffer.byteLength(text, 'utf8') > runtimeContract.CANONICAL_NATIVE_VALUE_TRANSFER_MAX_BYTES) throw new CanonicalNativeHostError('Native value transfer exceeds 1 MiB.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
622
+ const exports = instance.exports;
623
+ const pointer = exports.__new(text.length * 2, exports.pulse_schema_string_id());
624
+ const view = new Uint16Array(memory().buffer, pointer, text.length);
625
+ for (let index = 0; index < text.length; index++) view[index] = text.charCodeAt(index);
626
+ return pointer;
627
+ },
572
628
  value_undefined() { return put(undefined); },
573
629
  value_null() { return put(null); },
574
630
  value_boolean(input) { return put(Number(input) !== 0); },
@@ -578,26 +634,43 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
578
634
  value_array_push(arrayHandle, valueHandle) {
579
635
  const target = value(arrayHandle);
580
636
  if (!Array.isArray(target)) throw new CanonicalNativeHostError('value_array_push target is not an array.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { arrayHandle });
637
+ memoryBudget?.write(target, String(target.length), value(valueHandle));
581
638
  target.push(value(valueHandle));
582
639
  },
583
640
  value_array_spread(arrayHandle, valueHandle) {
584
641
  const target = value(arrayHandle);
585
642
  const source = value(valueHandle);
586
643
  if (!Array.isArray(target) || !source || typeof source[Symbol.iterator] !== 'function') throw new CanonicalNativeHostError('value_array_spread requires iterable input.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
587
- target.push(...source);
644
+ const append = item => {
645
+ memoryBudget?.write(target, String(target.length), item);
646
+ target.push(item);
647
+ };
648
+ if (Array.isArray(source)) {
649
+ const length = source.length;
650
+ for (let index = 0; index < length; index++) append(source[index]);
651
+ } else for (const item of source) append(item);
588
652
  },
589
653
  value_object() { return put({}); },
590
654
  value_object_set(objectHandle, keyHandle, valueHandle) {
591
655
  const target = value(objectHandle);
592
656
  if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_set target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
593
- target[String(value(keyHandle))] = value(valueHandle);
657
+ const key = String(value(keyHandle));
658
+ memoryBudget?.write(target, key, value(valueHandle));
659
+ target[key] = value(valueHandle);
594
660
  },
595
661
  value_object_spread(objectHandle, valueHandle) {
596
662
  const target = value(objectHandle);
597
663
  const source = value(valueHandle);
598
664
  if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_spread target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
599
665
  if (source === null || source === undefined) return;
600
- Object.assign(target, source);
666
+ // String spreading materializes one property per UTF-16 code unit.
667
+ // Reserve that capacity before asking the engine to enumerate its keys.
668
+ if (typeof source === 'string') memoryBudget?.charge(source.length,
669
+ source.length * (runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY.valueBytes + runtimeContract.CANONICAL_NATIVE_READ_LOOP_MEMORY.edgeBytes));
670
+ for (const key of Object.keys(source)) {
671
+ memoryBudget?.write(target, key, source[key]);
672
+ target[key] = source[key];
673
+ }
601
674
  },
602
675
  value_property(objectHandle, keyHandle) {
603
676
  const target = value(objectHandle);
@@ -608,6 +681,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
608
681
  const target = value(objectHandle);
609
682
  if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write a property on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
610
683
  const assigned = value(valueHandle);
684
+ memoryBudget?.write(target, String(value(keyHandle)), assigned);
611
685
  target[String(value(keyHandle))] = assigned;
612
686
  return valueHandle;
613
687
  },
@@ -620,6 +694,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
620
694
  const target = value(objectHandle);
621
695
  if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write an element on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
622
696
  const assigned = value(valueHandle);
697
+ memoryBudget?.write(target, value(keyHandle), assigned);
623
698
  target[value(keyHandle)] = assigned;
624
699
  return valueHandle;
625
700
  },
@@ -657,9 +732,19 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
657
732
  const header = context.ctx.req.headers.find(([key]) => String(key).toLowerCase() === name);
658
733
  return put(header ? header[1] : undefined);
659
734
  },
660
- request_text() { requireHttpSurface('ctx.req.text'); return put(context.ctx.req.text()); },
735
+ request_body() {
736
+ requireHttpSurface('ctx.req.body');
737
+ if (!options.incomingBody || adapter.id !== 'node') throw new CanonicalNativeHostError('Incoming forwarding requires an admitted Node request owner.', 'PULSE_REQUEST_FORWARDING_UNAVAILABLE');
738
+ return put(options.incomingBody.marker());
739
+ },
740
+ request_text() {
741
+ requireHttpSurface('ctx.req.text');
742
+ if (options.incomingBody) throw new CanonicalNativeHostError('Native forwarding/transform admission cannot also project structured request bytes.', 'PULSE_REQUEST_BODY_OWNERSHIP');
743
+ return put(context.ctx.req.text());
744
+ },
661
745
  request_json(schemaHandle) {
662
746
  requireHttpSurface('ctx.req.json');
747
+ if (options.incomingBody) throw new CanonicalNativeHostError('Native forwarding/transform admission cannot also project structured request bytes.', 'PULSE_REQUEST_BODY_OWNERSHIP');
663
748
  const schema = value(schemaHandle);
664
749
  return put(schema === undefined ? context.ctx.req.json() : context.ctx.req.json(schema));
665
750
  },
@@ -736,14 +821,26 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
736
821
  if (!response || typeof response.header !== 'function') throw new CanonicalNativeHostError('fetch_header requires a fetch response.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
737
822
  return put(response.header(value(nameHandle)));
738
823
  },
824
+ output_close() {
825
+ if (!options.outputExecution) throw new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires Node HTTP execution.');
826
+ return put(options.outputExecution.close());
827
+ },
739
828
  effect_begin(effectIndex, payloadHandle) {
829
+ options.outputExecution?.assertEffect();
740
830
  const index = Number(effectIndex);
741
831
  const effect = plan.effects && plan.effects[index];
742
832
  if (!effect) throw new CanonicalNativeHostError(`Native module requested unknown effect index ${index}.`, 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: index });
743
833
  const payload = value(payloadHandle);
834
+ // Admission happens while the guest builds its suspension group. A bad
835
+ // later member throws before the host dispatch loop starts any member.
836
+ if (effect.kind === 'fetch' && options.incomingBody) {
837
+ const admitted = canonicalRuntime.normalizeProviderEffect(nativeEffect(effect, payload), schemaCodecs, { ...options, strict, target: 'native', provider: adapter.id });
838
+ if (admitted.init.bodyMode === 'incoming-request-v1') options.incomingBody.claim(admitted.init.body, admitted.init);
839
+ }
744
840
  const kvAdmission = portableKv.isConditionalKv(effect.kind) ? portableKv.admitConditionalKv(nativeEffect(effect, payload), options) : undefined;
745
841
  if (kvAdmission) portableKv.registerKvRedactions(kvAdmission, (value) => sensitiveValues.add(value));
746
- pending.push(Object.freeze({ index, effect, payload, ...(kvAdmission ? { kvAdmission } : {}) }));
842
+ const ticket = invocations.open(index, effect.id);
843
+ pending.push(Object.freeze({ index, effect, payload, ticket, ...(kvAdmission ? { kvAdmission } : {}) }));
747
844
  }
748
845
  };
749
846
 
@@ -763,6 +860,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
763
860
  let applicationError = 0;
764
861
  const applicationErrors = executionPlane === 'http' && (plan.routing?.entries || []).some(entry => entry.kind === 'error');
765
862
  function captureApplicationError(error) {
863
+ if (options.outputExecution?.started) return false;
766
864
  if (!applicationErrors || options.signal?.aborted || !portableKv.isApplicationError(error)) return false;
767
865
  if (!applicationError) {
768
866
  const safe = canonicalRuntime.redactRuntimeError(error, sensitiveValues);
@@ -857,13 +955,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
857
955
  return result;
858
956
  }
859
957
 
860
- function setEffectResult(index, result) {
861
- const handle = put(result);
862
- const accepted = instance.exports.pulse_set_effect_result(Number(index), handle);
863
- if (accepted !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
864
- throw new CanonicalNativeHostError(`Native module rejected effect result index ${index}.`, 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: Number(index), errorCode: instance.exports.pulse_last_error_code() });
865
- }
866
- return handle;
958
+ function setEffectResult(ticket, result) {
959
+ return invocations.settle(ticket, () => {
960
+ const handle = put(result);
961
+ const accepted = instance.exports.pulse_set_effect_result(ticket.slot, handle);
962
+ if (accepted !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
963
+ close();
964
+ throw new CanonicalNativeHostError('Native module rejected an invocation result.', 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: ticket.slot, errorCode: instance.exports.pulse_last_error_code() });
965
+ }
966
+ return handle;
967
+ });
968
+ }
969
+
970
+ function setEffectFailure(ticket) {
971
+ return invocations.settle(ticket, () => {
972
+ if (instance.exports.pulse_package_set_effect_failure(ticket.slot) !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
973
+ close();
974
+ throw new CanonicalNativeHostError('Native package rejected an invocation failure.', 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID');
975
+ }
976
+ });
867
977
  }
868
978
 
869
979
  function resultValue() {
@@ -903,9 +1013,11 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
903
1013
  schemaId: selected.schemaId,
904
1014
  payloadHandle
905
1015
  });
906
- return instance.exports.pulse_event_start(selected.runtimeId, payloadHandle);
1016
+ return run(() => instance.exports.pulse_event_start(selected.runtimeId, payloadHandle));
907
1017
  }
908
1018
 
1019
+ options.signal?.addEventListener('abort', close, { once: true });
1020
+ if (options.signal?.aborted) close();
909
1021
  return Object.freeze({
910
1022
  version: runtimeContract.CANONICAL_NATIVE_HOST_VERSION,
911
1023
  plan,
@@ -922,14 +1034,17 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
922
1034
  cryptoVerifier: nativeCrypto.verifier,
923
1035
  cryptoRealization: nativeCrypto.realization,
924
1036
  executionPlane,
925
- start() { requireExecutionPlane('http'); return instance.exports.pulse_start(); },
1037
+ start() { requireExecutionPlane('http'); return run(() => instance.exports.pulse_start()); },
926
1038
  startEvent,
927
1039
  eventSelection() { return activeEvent; },
928
- resume() { return instance.exports.pulse_resume(); },
1040
+ resume() { return run(() => instance.exports.pulse_resume()); },
1041
+ close,
1042
+ assertPendingEffect: invocations.assertPending,
929
1043
  pendingEffects: takePending,
930
1044
  prepareEffectResult,
931
1045
  captureApplicationError,
932
1046
  setEffectResult,
1047
+ setEffectFailure,
933
1048
  resultValue,
934
1049
  response() { requireHttpSurface('HTTP completion'); return canonicalRuntime.finalResponse(resultValue(), context.ctx.req.method); },
935
1050
  programCounter() { return instance.exports.pulse_program_counter(); },
@@ -977,6 +1092,13 @@ function raceNativeSignal(value, signal, eventMode = true) {
977
1092
  }
978
1093
 
979
1094
  async function executeCanonicalNativeInvocation(compiled, options = {}, invocation = Object.freeze({ plane: 'http' })) {
1095
+ const ownedResponseBodies = new Set();
1096
+ let responseBodyTransferred, invocationClosed = false;
1097
+ const cancelBody = body => { try { void body.cancel().catch(() => {}); } catch (_) {} };
1098
+ options = { ...options, registerResponseBody(body) {
1099
+ if (invocationClosed) cancelBody(body);
1100
+ else ownedResponseBodies.add(body);
1101
+ } };
980
1102
  const eventMode = invocation && invocation.plane === 'event';
981
1103
  const maxEffects = normalizeMaxEffects(options.maxEffects);
982
1104
  const controller = instantiateCanonicalNativeModule(compiled, { ...options, executionPlane: eventMode ? 'event' : 'http' });
@@ -1020,6 +1142,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1020
1142
  }
1021
1143
  activeContinuation = {
1022
1144
  id: continuation.id,
1145
+ invocationId: `continuation-${pending[0].ticket.invocationId}`,
1023
1146
  kind: continuation.kind,
1024
1147
  effectIds: Object.freeze([...pendingEffectIds]),
1025
1148
  stateIndex: continuationState,
@@ -1027,7 +1150,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1027
1150
  states: ['created', 'waiting']
1028
1151
  };
1029
1152
  continuations.push(activeContinuation);
1030
- controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-waiting', executionId, continuationId: continuation.id, continuationState, effectIds: Object.freeze([...pendingEffectIds]) }, controller.sensitiveValues)));
1153
+ controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-waiting', executionId, continuationId: continuation.id, invocationId: activeContinuation.invocationId, continuationState, effectIds: Object.freeze([...pendingEffectIds]) }, controller.sensitiveValues)));
1031
1154
  if (effectCount + pending.length > maxEffects) {
1032
1155
  throw new CanonicalNativeHostError(
1033
1156
  eventMode
@@ -1039,6 +1162,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1039
1162
  }
1040
1163
  effectCount += pending.length;
1041
1164
 
1165
+ if (pending.length > 1 && pending.some(entry => entry.effect.kind.startsWith('output.'))) throw new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_PARALLEL_FORBIDDEN', 'Output effects cannot be grouped.');
1042
1166
  const settled = await raceNativeSignal(Promise.allSettled(pending.map(async (entry) => {
1043
1167
  options.requestBudget?.check();
1044
1168
  if (eventMode && options.signal && options.signal.aborted) {
@@ -1046,7 +1170,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1046
1170
  }
1047
1171
  const rawEffect = { ...nativeEffect(entry.effect, entry.payload), ...entry.kvAdmission };
1048
1172
  const conditional = portableKv.isConditionalKv(rawEffect.kind);
1049
- const privateEffect = conditional || ((controller.plan.packages && controller.plan.packages.effects) || []).some((item) =>
1173
+ const privateEffect = rawEffect.kind.startsWith('output.') || conditional || ((controller.plan.packages && controller.plan.packages.effects) || []).some((item) =>
1050
1174
  item.package === entry.effect.package && item.contractId === entry.effect.contractId && item.operation === entry.effect.operation
1051
1175
  && item.redaction && Object.keys(item.redaction).length > 0);
1052
1176
  const normalized = canonicalRuntime.normalizeProviderEffect(rawEffect, controller.schemaCodecs, {
@@ -1058,10 +1182,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1058
1182
  target: 'native',
1059
1183
  provider: adapter.id
1060
1184
  });
1061
- controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-start', executionId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, payload: privateEffect ? '<redacted>' : entry.payload }, controller.sensitiveValues)));
1185
+ controller.assertPendingEffect(entry.ticket);
1186
+ controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-start', executionId, invocationId: entry.ticket.invocationId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, payload: privateEffect ? '<redacted>' : entry.payload }, controller.sensitiveValues)));
1062
1187
  const effectExecution = {
1063
1188
  ...options,
1064
1189
  executionId,
1190
+ invocationId: entry.ticket.invocationId,
1065
1191
  metadata: compiled && compiled.plan ? compiled.plan.canonical : controller.plan.canonical,
1066
1192
  plan: controller.plan,
1067
1193
  packageArtifacts,
@@ -1075,13 +1201,20 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1075
1201
  return controller.schemaCodecs.decode(String(schemaId), value, context.source || 'package-effect');
1076
1202
  }
1077
1203
  };
1078
- const rawResult = conditional
1204
+ options.outputExecution?.assertEffect();
1205
+ const rawResult = normalized.kind.startsWith('output.')
1206
+ ? await (options.outputExecution ? options.outputExecution.dispatch(normalized) : Promise.reject(new portableKv.PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires Node HTTP execution.')))
1207
+ : conditional
1079
1208
  ? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)),
1080
1209
  { ...effectExecution, onKvObservation: (event) => controller.trace.push(event) }, options)
1081
1210
  : await raceNativeSignal(adapter.dispatchEffect(normalized, effectExecution), options.signal, eventMode);
1082
1211
  options.requestBudget?.check();
1083
- const result = controller.prepareEffectResult(entry.index, rawResult);
1084
- controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-resolved', executionId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, result: privateEffect || entry.effect.kind === 'secret.get' ? '<redacted>' : result && typeof result.toJSON === 'function' ? result.toJSON() : result }, controller.sensitiveValues)));
1212
+ controller.assertPendingEffect(entry.ticket);
1213
+ // executeConditionalKv already validates, detaches, freezes and registers
1214
+ // redactions using these execution limits. Reuse only that internal
1215
+ // result; raw controller callers still use prepareEffectResult.
1216
+ const result = conditional ? rawResult : controller.prepareEffectResult(entry.index, rawResult);
1217
+ controller.trace.push(Object.freeze(redactValue({ type: 'native-effect-resolved', executionId, invocationId: entry.ticket.invocationId, provider: adapter.id, effectId: normalized.id, kind: normalized.kind, result: privateEffect || entry.effect.kind === 'secret.get' ? '<redacted>' : result && typeof result.toJSON === 'function' ? result.toJSON() : result }, controller.sensitiveValues)));
1085
1218
  resolutionOrder.push(entry.effect.id);
1086
1219
  return { entry, result };
1087
1220
  })), options.signal, eventMode);
@@ -1093,12 +1226,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1093
1226
  if (!eventMode && options.signal?.aborted) throw nativeRequestCancelled(options.signal);
1094
1227
  const failures = settled.filter(entry => entry.status === 'rejected');
1095
1228
  const failed = failures.find(entry => !portableKv.isApplicationError(entry.reason)) || failures[0];
1096
- if (failed && (!failures.every(entry => portableKv.isApplicationError(entry.reason))
1229
+ const packageFailure = controller.plan.packages?.application?.effectFailure === 'package-completion';
1230
+ if (failed && !packageFailure && (!failures.every(entry => portableKv.isApplicationError(entry.reason))
1097
1231
  || !controller.captureApplicationError(failed.reason))) {
1098
1232
  throw canonicalRuntime.redactRuntimeError(failed.reason, controller.sensitiveValues);
1099
1233
  }
1100
- settled.forEach((item, index) => controller.setEffectResult(pending[index].index,
1101
- item.status === 'fulfilled' ? item.value.result : undefined));
1234
+ settled.forEach((item, index) => {
1235
+ if (item.status === 'rejected' && packageFailure) controller.setEffectFailure(pending[index].ticket);
1236
+ else controller.setEffectResult(pending[index].ticket, item.status === 'fulfilled' ? item.value.result : undefined);
1237
+ });
1102
1238
  const previousPc = controller.programCounter();
1103
1239
  activeContinuation.state = 'resumed';
1104
1240
  activeContinuation.states.push('resumed');
@@ -1108,7 +1244,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1108
1244
  }
1109
1245
  activeContinuation.state = failed ? 'failed' : 'completed';
1110
1246
  activeContinuation.states.push(activeContinuation.state);
1111
- controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, continuationState }, controller.sensitiveValues)));
1247
+ controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, invocationId: activeContinuation.invocationId, continuationState }, controller.sensitiveValues)));
1112
1248
  activeContinuation = undefined;
1113
1249
  }
1114
1250
 
@@ -1136,12 +1272,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1136
1272
  resolutionOrder: Object.freeze([...resolutionOrder]),
1137
1273
  continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
1138
1274
  trace: Object.freeze([...controller.trace]),
1139
- valueHandleCount: controller.heap.size()
1275
+ valueHandleCount: controller.heap.size(),
1276
+ ...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
1140
1277
  });
1141
1278
  }
1142
1279
  if (options.signal?.aborted) throw nativeRequestCancelled(options.signal);
1143
1280
  options.requestBudget?.check();
1281
+ options.outputExecution?.validateResult(controller.resultValue());
1144
1282
  const response = controller.response();
1283
+ responseBodyTransferred = response.bodyStream;
1145
1284
  controller.trace.push(Object.freeze(redactValue({ type: 'native-execution-completed', executionId, provider: adapter.id, status: response.status, bodyClass: response.bodyClass }, controller.sensitiveValues)));
1146
1285
  return Object.freeze({
1147
1286
  status: 'completed',
@@ -1155,10 +1294,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1155
1294
  wasmSha256: controller.wasmSha256,
1156
1295
  response,
1157
1296
  effectCount,
1297
+ ...((options.incomingBody || options.outputExecution) ? {guestMemoryBytes:controller.exports.memory.buffer.byteLength} : {}),
1158
1298
  resolutionOrder: Object.freeze([...resolutionOrder]),
1159
1299
  continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
1160
1300
  trace: Object.freeze([...controller.trace]),
1161
- valueHandleCount: controller.heap.size()
1301
+ valueHandleCount: controller.heap.size(),
1302
+ ...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
1162
1303
  });
1163
1304
  } catch (error) {
1164
1305
  executionFailure = canonicalRuntime.redactRuntimeError(error, controller.sensitiveValues);
@@ -1175,6 +1316,10 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1175
1316
  }) });
1176
1317
  throw executionFailure;
1177
1318
  } finally {
1319
+ invocationClosed = true;
1320
+ for (const body of ownedResponseBodies) if (body !== responseBodyTransferred) cancelBody(body);
1321
+ ownedResponseBodies.clear();
1322
+ controller.close();
1178
1323
  try { adapter.disposeExecution(Object.freeze({ executionId, metadata: controller.plan.canonical, plan: controller.plan, status: executionFailure ? 'failed' : 'completed', error: executionFailure })); }
1179
1324
  catch (_) { /* provider cleanup must not mask execution */ }
1180
1325
  }
@@ -1184,7 +1329,7 @@ async function executeCanonicalNativeModule(compiled, options = {}) {
1184
1329
  const budget = portableKv.createRequestBudget(options);
1185
1330
  try {
1186
1331
  budget.check();
1187
- return await executeCanonicalNativeInvocation(compiled, { ...options, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock }, Object.freeze({ plane: 'http' }));
1332
+ return await executeCanonicalNativeInvocation(compiled, { ...options, responseSignal: options.signal, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock }, Object.freeze({ plane: 'http' }));
1188
1333
  } finally { if (!options.requestBudget) budget.close(); }
1189
1334
  }
1190
1335
 
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const { CANONICAL_EFFECT_INVOCATION_POLICY } = require('@pulse-compute/wasm-contracts/handler/canonical-runtime');
4
+ let executionSequence = 0;
5
+
6
+ function normalizeMaxEffects(value) {
7
+ const max = value === undefined ? CANONICAL_EFFECT_INVOCATION_POLICY.defaultMaxEffects : Number(value);
8
+ if (!Number.isSafeInteger(max) || max <= 0) throw new TypeError('Pulse maxEffects must be a positive safe integer.');
9
+ return max;
10
+ }
11
+
12
+ // A ticket is authenticated by object identity, never by caller-supplied fields.
13
+ // Only pending slots are retained; the sequence is cumulative across loop visits.
14
+ function createEffectInvocations(check = () => {}) {
15
+ const execution = ++executionSequence;
16
+ const active = new Map();
17
+ let sequence = 0;
18
+ let closed = false;
19
+ function invalid() {
20
+ const error = new Error('Effect invocation is stale, already settled, foreign, or closed.');
21
+ error.name = 'EffectInvocationError';
22
+ error.code = 'PULSE_EFFECT_INVOCATION_INVALID';
23
+ throw error;
24
+ }
25
+ function assertOpen() { if (closed) invalid(); check(); }
26
+ function assertPending(ticket) {
27
+ assertOpen();
28
+ if (!ticket || active.get(ticket.slot) !== ticket) invalid();
29
+ return ticket;
30
+ }
31
+ return Object.freeze({
32
+ execution,
33
+ open(slot, siteId) {
34
+ assertOpen();
35
+ if (active.has(slot) || !Number.isSafeInteger(sequence + 1)) invalid();
36
+ const ticket = Object.freeze({ slot, siteId, sequence: ++sequence, invocationId: `effect-${execution}-${sequence}` });
37
+ active.set(slot, ticket);
38
+ return ticket;
39
+ },
40
+ assertOpen,
41
+ assertPending,
42
+ settle(ticket, consume) {
43
+ assertPending(ticket);
44
+ active.delete(ticket.slot); // Consume before calling into the guest.
45
+ return consume();
46
+ },
47
+ close() { closed = true; active.clear(); },
48
+ get closed() { return closed; }
49
+ });
50
+ }
51
+
52
+ module.exports = { createEffectInvocations, normalizeMaxEffects };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const {
4
4
  CRYPTO_ES256_GUEST_LINKED_IMPLEMENTATION,
5
+ CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION, CRYPTO_RS256_GUEST_LINKED_REALIZATION,
5
6
  CRYPTO_ES256_GUEST_LINKED_REALIZATION,
6
7
  CRYPTO_GUEST_SOURCE_IMPLEMENTATION,
7
8
  CRYPTO_REALIZATION_PLAN_VERSION
@@ -230,6 +231,9 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
230
231
  const moduleExports = input.exports;
231
232
  const selected = selectedHs256(plan);
232
233
  const selectedSignature = selectedEs256(plan);
234
+ const selectedRsa = selectedAlgorithm(plan, 'RS256', CRYPTO_RS256_GUEST_LINKED_REALIZATION, 'guest-linked', CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION);
235
+ let rsa;
236
+ if (selectedRsa) { try { rsa = require('@pulse-compute/crypto/pulsewasm-native').bindNativeRs256(moduleExports); } catch {} }
233
237
  const memory = moduleExports && moduleExports.memory;
234
238
  const verifyHs256 = moduleExports && moduleExports[HS256_EXPORT];
235
239
  const verifyEs256 = moduleExports && moduleExports[ES256_EXPORT];
@@ -249,6 +253,7 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
249
253
  const digestMac = byteAlgorithms.length > 0
250
254
  ? require('@pulse-compute/crypto/pulsewasm-native').bindNativeDigestMac(moduleExports) : undefined;
251
255
  const selectedRealizations = Object.freeze([
256
+ ...(selectedRsa ? [{ algorithm: 'RS256', realization: CRYPTO_RS256_GUEST_LINKED_REALIZATION, implementation: CRYPTO_RS256_GUEST_LINKED_IMPLEMENTATION, guestUnitRequired: true, available: Boolean(rsa) }] : []),
252
257
  ...byteAlgorithms.map((algorithm) => Object.freeze({ algorithm, realization: HS256_REALIZATION,
253
258
  implementation: CRYPTO_GUEST_SOURCE_IMPLEMENTATION, guestUnitRequired: false, available: Boolean(digestMac) })),
254
259
  ...(selected ? [Object.freeze({
@@ -276,8 +281,12 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
276
281
  ...(selectedRealizations.length === 1 ? selectedRealizations[0] : {})
277
282
  });
278
283
 
284
+ const signer = es256Available && typeof moduleExports.pulse_crypto_es256_sign === 'function'
285
+ ? require('@pulse-compute/crypto/pulsewasm-native').bindNativeEs256Signer(moduleExports) : undefined;
279
286
  const verifier = Object.freeze({
280
- ...(digestMac ? { bytes: Object.freeze({
287
+ ...(digestMac || signer || rsa ? { bytes: Object.freeze({
288
+ ...(signer ? { es256Sign: signer } : {}),
289
+ ...(rsa ? { rs256Sign: rsa.sign } : {}),
281
290
  ...(byteAlgorithms.includes('SHA-256') ? { sha256: digestMac.sha256 } : {}),
282
291
  ...(byteAlgorithms.includes('HMAC-SHA256') ? { hmacSha256: digestMac.hmacSha256 } : {})
283
292
  }) } : {}),
@@ -313,6 +322,13 @@ function createNativeGuestSourceCryptoVerifier(input = {}) {
313
322
  }),
314
323
  signature: Object.freeze({
315
324
  verify(request) {
325
+ if (ownData(request, 'algorithm') === 'RS256') {
326
+ const key = ownData(request, 'key');
327
+ if (ownData(key, 'type') !== 'rsa-public-key-bytes') return result('invalid-key');
328
+ if (!rsa) return result('realization-failure');
329
+ try { return rsa.verify(ownData(key, 'bytes'), ownData(request, 'data'), ownData(request, 'signature')); }
330
+ catch (error) { return result(error.code === 'PULSE_CRYPTO_KEY_INVALID' ? 'invalid-key' : 'invalid-input'); }
331
+ }
316
332
  const normalized = normalizeSignatureRequest(request);
317
333
  if (normalized.result) return normalized.result;
318
334
  if (!es256Available) return result('realization-failure');
@@ -0,0 +1,64 @@
1
+ 'use strict';
2
+
3
+ // A conservative cumulative charge, deliberately never refunded. Counting a
4
+ // primitive again on a property read can overestimate retention. Object graphs
5
+ // are visited once; guest mutations are charged separately at the host imports.
6
+ // No charge releases an alias, pending payload, schema value, or returned root.
7
+ class NativeValueBudget {
8
+ constructor(policy, onFailure = () => {}) {
9
+ this.policy = policy;
10
+ this.onFailure = onFailure;
11
+ this.bytes = 0;
12
+ this.values = 0;
13
+ this.failure = undefined;
14
+ this.objects = new WeakSet();
15
+ }
16
+
17
+ charge(values, bytes) {
18
+ if (this.failure) throw this.failure;
19
+ if (values > this.policy.maxValues - this.values || bytes > this.policy.maxBytes - this.bytes) {
20
+ const error = new Error('Native read-loop execution exceeded its retained-value budget.');
21
+ error.code = 'PULSE_RUNTIME_MEMORY_LIMIT_EXCEEDED';
22
+ error.detail = Object.freeze({ bytes: this.bytes, values: this.values,
23
+ maxBytes: this.policy.maxBytes, maxValues: this.policy.maxValues });
24
+ this.failure = error;
25
+ this.onFailure();
26
+ throw error;
27
+ }
28
+ this.values += values;
29
+ this.bytes += bytes;
30
+ }
31
+
32
+ retain(value) {
33
+ const visit = input => {
34
+ this.charge(1, this.policy.valueBytes);
35
+ if (typeof input === 'string') this.charge(0, input.length * 2);
36
+ else if (input && typeof input === 'object' && !this.objects.has(input)) {
37
+ this.objects.add(input);
38
+ // Charge sparse capacity before visiting elements. Avoid allocating an
39
+ // intermediate array proportional to an untrusted container's length.
40
+ if (Array.isArray(input)) this.charge(input.length, input.length * this.policy.edgeBytes);
41
+ for (const key in input) if (Object.hasOwn(input, key)) {
42
+ this.charge(1, this.policy.edgeBytes + key.length * 2);
43
+ visit(input[key]);
44
+ }
45
+ }
46
+ };
47
+ visit(value);
48
+ }
49
+
50
+ write(target, key, value) {
51
+ this.charge(1, this.policy.edgeBytes + String(key).length * 2);
52
+ if (Array.isArray(target)) {
53
+ const index = Number(key);
54
+ const length = key === 'length' ? Number(value) :
55
+ Number.isInteger(index) && index >= 0 && String(index) === String(key) ? index + 1 : target.length;
56
+ if (length > target.length) this.charge(length - target.length, (length - target.length) * this.policy.edgeBytes);
57
+ }
58
+ this.retain(value);
59
+ }
60
+
61
+ snapshot() { return Object.freeze({ version: this.policy.version, bytes: this.bytes, values: this.values }); }
62
+ }
63
+
64
+ module.exports = { NativeValueBudget };