@pulse-compute/wasm-host-runtime 1.0.0-beta.4 → 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.4/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.4` 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.4",
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.4",
32
- "@pulse-compute/runtime": "1.0.0-beta.4",
33
- "@pulse-compute/wasm-build-support": "1.0.0-beta.4",
34
- "@pulse-compute/wasm-contracts": "1.0.0-beta.4",
35
- "@pulse-compute/wasm-schema-json": "1.0.0-beta.4"
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.4/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
@@ -390,6 +394,7 @@ function assertSchemaBodySize(schemaCodecs, text, schemaId, source) {
390
394
  const runtimeValueErrorCodes = Object.freeze({
391
395
  BodyTooLargeError: 'PULSE_BODY_TOO_LARGE',
392
396
  BodyDecodeError: 'PULSE_BODY_DECODE',
397
+ RequestBodyInvalidUtf8Error: 'PULSE_REQUEST_BODY_INVALID_UTF8',
393
398
  BodyUnavailableError: 'PULSE_BODY_UNAVAILABLE',
394
399
  OpaqueBodyInspectionError: 'PULSE_OPAQUE_BODY_INSPECTION',
395
400
  ResponseEncodeError: 'PULSE_RESPONSE_ENCODE',
@@ -431,7 +436,7 @@ function normalizeRequest(options = {}) {
431
436
  function createRequestSurface(request, options = {}, schemaCodecs, trace) {
432
437
  const body = runtimeApi.body.createValue(
433
438
  { body: request.body, headers: request.headers },
434
- { maxBytes: options.maxRequestBodyBytes || options.maxBodyBytes || options.maxStructuredBodyBytes }
439
+ { maxBytes: options.maxRequestBodyBytes || options.maxBodyBytes || options.maxStructuredBodyBytes, strictUtf8: true }
435
440
  );
436
441
  const schemaMemo = new Map();
437
442
  return Object.freeze({
@@ -634,6 +639,30 @@ function createCanonicalContext(options, trace) {
634
639
  }
635
640
 
636
641
  const ctx = Object.freeze({
642
+ decodeJson(text, schemaId) {
643
+ const key = requireSchemaId(schemaCodecs, schemaId, 'application-text');
644
+ const event = {
645
+ boundary: 'application-text', schemaId: key, responseCaseId: null,
646
+ operationId: `application-text:${key}`, status: null,
647
+ contentType: null, headers: [], bodyOwnership: 'application-owned',
648
+ target: options.target || null, provider: options.provider || null,
649
+ effectId: null, groupId: null
650
+ };
651
+ try {
652
+ if (typeof text !== 'string') {
653
+ throw new CanonicalRuntimeError('SchemaDecodeError', 'PULSE_SCHEMA_DECODE',
654
+ 'Application JSON text must be a string.',
655
+ { schemaId: key, source: 'application-text', expected: 'string', actualKind: typeof text });
656
+ }
657
+ assertSchemaBodySize(schemaCodecs, text, key, 'application-text');
658
+ const value = schemaCodecs.decodeJsonText(key, text, 'application-text');
659
+ jsonTrace(trace, { ...event, kind: 'json.decode.text' }, value);
660
+ return value;
661
+ } catch (error) {
662
+ jsonTrace(trace, { ...event, kind: 'json.decode.error', ...jsonTraceErrorDetail(error) });
663
+ throw error;
664
+ }
665
+ },
637
666
  encodeJson(value, schemaId) {
638
667
  const key = requireSchemaId(schemaCodecs, schemaId, 'application-value');
639
668
  const event = {
@@ -761,11 +790,12 @@ function normalizedInit(init = {}, schemaCodecs, options = {}) {
761
790
  let body;
762
791
  let bodyMode = 'none';
763
792
  if (hasBody) {
764
- if (typeof value.body !== 'string') {
793
+ const incoming = options.incomingBody && value.body === options.incomingBody.marker();
794
+ if (typeof value.body !== 'string' && !incoming) {
765
795
  throw new CanonicalRuntimeError('FetchRequestError', 'PULSE_FETCH_BODY_INVALID', 'Canonical fetch body must be a string.', { valueType: typeof value.body });
766
796
  }
767
797
  body = value.body;
768
- bodyMode = 'text';
798
+ bodyMode = incoming ? 'incoming-request-v1' : 'text';
769
799
  } else if (hasJson) {
770
800
  if (!headerValue(headers, 'content-type')) headers.push(['content-type', 'application/json; charset=utf-8']);
771
801
  if (hasSchema) {
@@ -911,6 +941,9 @@ function normalizeProviderEffect(effect, schemaCodecs, options = {}) {
911
941
  if (effect.kind === 'fetch') return normalizeFetchEffect(effect, schemaCodecs, options);
912
942
  const id = String(effect.id || '');
913
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 });
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) }) });
914
947
  if (effect.kind === 'config.get' || effect.kind === 'secret.get') {
915
948
  return Object.freeze({ id, kind: effect.kind, name: String(effect.name), ...(effect.groupKey === undefined ? {} : { groupKey: String(effect.groupKey) }), source: effect.source });
916
949
  }
@@ -1324,10 +1357,23 @@ function createCanonicalHostRuntime(options = {}) {
1324
1357
  const registry = options.continuationRegistry || createContinuationRegistry({ ttlMs: options.continuationTtlMs, clock: options.clock });
1325
1358
 
1326
1359
  async function execute(programModule, executionOptions = {}) {
1360
+ const input = { ...options, ...executionOptions };
1361
+ const budget = portableKv.createRequestBudget(input);
1362
+ try {
1363
+ budget.check();
1364
+ return await executeInvocation(programModule, { ...executionOptions, requestBudget: budget, signal: budget.signal,
1365
+ deadlineMonotonicMs: budget.deadlineMonotonicMs ?? input.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? input.kvClock : budget.clock });
1366
+ } finally { if (!input.requestBudget) budget.close(); }
1367
+ }
1368
+
1369
+ async function executeInvocation(programModule, executionOptions) {
1327
1370
  if (!programModule || typeof programModule.createHandler !== 'function') throw new TypeError('Canonical runtime requires a compiled canonical module.');
1328
1371
  const metadata = assertCanonicalProgramCompatibility(programModule);
1329
1372
  executionSequence += 1;
1330
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 = [];
1331
1377
  const sensitiveValues = initialSensitiveValues({ ...options, ...executionOptions });
1332
1378
  let executionStatus = 'failed';
1333
1379
  let executionFailure;
@@ -1380,21 +1426,24 @@ function createCanonicalHostRuntime(options = {}) {
1380
1426
  let dispatchedEffects = 0;
1381
1427
 
1382
1428
  function executionContinuations() {
1383
- const prefix = `${executionId}:`;
1384
- return registry.list().filter((entry) => entry.id.startsWith(prefix));
1429
+ return continuationIds.map(id => registry.get(id));
1385
1430
  }
1386
1431
 
1387
1432
  async function dispatchOne(effect) {
1433
+ executionOptions.requestBudget.check();
1434
+ const ticket = invocations.open(effect.id, effect.id);
1388
1435
  const normalized = normalizeProviderEffect(effect, schemaCodecs, runtimeOptions);
1389
1436
  const conditional = portableKv.isConditionalKv(normalized.kind);
1390
1437
  if (conditional) portableKv.registerKvRedactions(normalized, (value) => sensitiveValues.add(value));
1391
- recordTrace(effectTraceStart(adapter.id, executionId, normalized, sensitiveValues));
1438
+ recordTrace({ ...effectTraceStart(adapter.id, executionId, normalized, sensitiveValues), invocationId: ticket.invocationId });
1392
1439
  dispatchedEffects += 1;
1393
1440
  try {
1394
- 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 };
1395
1442
  let snapshot = conditional
1396
1443
  ? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)), effectExecution, runtimeOptions)
1397
- : await adapter.dispatchEffect(normalized, effectExecution);
1444
+ : await executionOptions.requestBudget.race(adapter.dispatchEffect(normalized, effectExecution));
1445
+ executionOptions.requestBudget.check();
1446
+ invocations.assertPending(ticket);
1398
1447
  if (normalized.kind === 'config.get' || normalized.kind === 'secret.get') {
1399
1448
  if (snapshot !== undefined && typeof snapshot !== 'string') {
1400
1449
  throw new CanonicalRuntimeError(
@@ -1415,6 +1464,7 @@ function createCanonicalHostRuntime(options = {}) {
1415
1464
  { receivedType: Array.isArray(snapshot) ? 'array' : snapshot === null ? 'null' : typeof snapshot }
1416
1465
  );
1417
1466
  }
1467
+ if (normalized.kind === 'time.now') snapshot = require('@pulse-compute/runtime/host').normalizeTimeResult(snapshot);
1418
1468
  if (normalized.kind === 'event.emit' && snapshot !== undefined) {
1419
1469
  throw new CanonicalRuntimeError(
1420
1470
  'EventAcceptanceError',
@@ -1441,9 +1491,13 @@ function createCanonicalHostRuntime(options = {}) {
1441
1491
  })
1442
1492
  : snapshot;
1443
1493
  resolutionOrder.push(effect.id);
1444
- recordTrace(effectTraceResolved(adapter.id, executionId, normalized, value));
1445
- return value;
1494
+ recordTrace({ ...effectTraceResolved(adapter.id, executionId, normalized, value), invocationId: ticket.invocationId });
1495
+ return invocations.settle(ticket, () => value);
1446
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
+ }
1447
1501
  recordTrace(Object.freeze({ type: 'effect-failed', provider: adapter.id, executionId, effectId: effect.id, kind: normalized.kind, error: error.name || 'Error', code: error.code }));
1448
1502
  throw redactRuntimeError(error, sensitiveValues);
1449
1503
  }
@@ -1487,20 +1541,27 @@ function createCanonicalHostRuntime(options = {}) {
1487
1541
  const generator = programModule.createHandler()(context.ctx, pulse);
1488
1542
  if (!generator || typeof generator.next !== 'function') throw new CanonicalRuntimeError('CanonicalHandlerProtocolError', 'PULSE_CANONICAL_HANDLER_PROTOCOL', 'Compiled canonical handler did not return a generator.');
1489
1543
  let step;
1490
- try { step = generator.next(); }
1544
+ try { executionOptions.requestBudget.check(); step = generator.next(); }
1491
1545
  catch (error) { error.execution = { executionId, trace: [...trace], continuations: executionContinuations() }; throw error; }
1492
1546
 
1493
1547
  while (!step.done) {
1494
1548
  const marker = step.value;
1495
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 });
1496
1550
  const effects = marker.kind === 'group' ? marker.effects : [marker.effect];
1497
- const continuationId = `${executionId}:${marker.continuationId}`;
1551
+ const continuationId = `${executionId}:${invocations.execution}:${marker.continuationId}:${continuationIds.length + 1}`;
1498
1552
  registry.create({ id: continuationId, branchPoint: marker.continuationId, effectIds: effects.map((effect) => effect.id), ttlMs: executionOptions.continuationTtlMs || options.continuationTtlMs });
1553
+ continuationIds.push(continuationId);
1499
1554
  registry.wait(continuationId);
1500
1555
  recordTrace(Object.freeze({ type: 'continuation-waiting', provider: adapter.id, executionId, continuationId, effectIds: effects.map((effect) => effect.id) }));
1501
1556
  let value;
1502
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
+ }
1503
1563
  value = marker.kind === 'group' ? await dispatchGroup(effects) : await dispatchOne(effects[0]);
1564
+ executionOptions.requestBudget.check();
1504
1565
  registry.resume(continuationId);
1505
1566
  recordTrace(Object.freeze({ type: 'continuation-resumed', provider: adapter.id, executionId, continuationId, branchPoint: marker.continuationId }));
1506
1567
  step = generator.next(value);
@@ -1517,7 +1578,7 @@ function createCanonicalHostRuntime(options = {}) {
1517
1578
  }
1518
1579
 
1519
1580
  let response;
1520
- try { response = finalResponse(step.value, context.request.method); }
1581
+ try { executionOptions.requestBudget.check(); response = finalResponse(step.value, context.request.method); }
1521
1582
  catch (error) {
1522
1583
  error.execution = Object.freeze({ executionId, trace: Object.freeze([...trace]), continuations: Object.freeze(executionContinuations()), resolutionOrder: Object.freeze([...resolutionOrder]) });
1523
1584
  throw error;
@@ -1544,6 +1605,7 @@ function createCanonicalHostRuntime(options = {}) {
1544
1605
  executionFailure = redactRuntimeError(error, sensitiveValues);
1545
1606
  throw executionFailure;
1546
1607
  } finally {
1608
+ invocations.close();
1547
1609
  try { adapter.disposeExecution(Object.freeze({ executionId, metadata, status: executionStatus, error: executionFailure })); }
1548
1610
  catch (_) { /* provider cleanup must not mask execution results */ }
1549
1611
  }
@@ -1570,6 +1632,7 @@ module.exports = {
1570
1632
  CanonicalRuntimeError,
1571
1633
  assertCanonicalProgramCompatibility,
1572
1634
  createCanonicalHostRuntime,
1635
+ createRequestBudget: portableKv.createRequestBudget,
1573
1636
  executeCanonicalProgram,
1574
1637
  createCanonicalContext,
1575
1638
  normalizeFetchResponse,
@@ -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,8 @@ 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 };
268
+ if (planEffect.kind === 'time.now') return { ...base, providerKind: 'time', operation: 'now', capability: 'time.wall-clock' };
247
269
  if (planEffect.kind === 'fetch') {
248
270
  const response = nativeFetchResponseContract(planEffect, payload);
249
271
  return { ...base, url: payload.url, init: payload.init, ...response };
@@ -347,6 +369,9 @@ function validateNativeModule(module, plan) {
347
369
  const missing = runtimeContract.CANONICAL_NATIVE_EXPORT_NAMES.filter((name) => !exportNames.has(name));
348
370
  if (missing.length > 0) throw new CanonicalNativeHostError('Native module is missing required ABI exports.', 'PULSE_CANONICAL_NATIVE_ABI_EXPORT_MISMATCH', { missing });
349
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
+ }
350
375
  const eventReachable = Boolean(plan.events && plan.events.catalog && plan.events.catalog.events.length > 0);
351
376
  const eventExportNames = eventContract.EVENT_NATIVE_ABI_EXTENSION.exports.map((entry) => entry.name);
352
377
  const presentEventExports = eventExportNames.filter((name) => exportNames.has(name));
@@ -370,9 +395,32 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
370
395
  const moduleShape = validateNativeModule(module, plan);
371
396
  const adapter = normalizeProviderAdapter(options.providerAdapter || options.provider);
372
397
  const executionPlane = options.executionPlane === 'event' ? 'event' : 'http';
373
- 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);
374
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
+ }
375
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
+ } });
376
424
  const sensitiveValues = new Set();
377
425
  const configuredSecrets = options.secrets && typeof options.secrets === 'object' ? options.secrets : {};
378
426
  for (const value of Object.values(configuredSecrets)) if (typeof value === 'string' && value.length > 0) sensitiveValues.add(value);
@@ -568,6 +616,15 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
568
616
  }
569
617
 
570
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
+ },
571
628
  value_undefined() { return put(undefined); },
572
629
  value_null() { return put(null); },
573
630
  value_boolean(input) { return put(Number(input) !== 0); },
@@ -577,26 +634,43 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
577
634
  value_array_push(arrayHandle, valueHandle) {
578
635
  const target = value(arrayHandle);
579
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));
580
638
  target.push(value(valueHandle));
581
639
  },
582
640
  value_array_spread(arrayHandle, valueHandle) {
583
641
  const target = value(arrayHandle);
584
642
  const source = value(valueHandle);
585
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');
586
- 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);
587
652
  },
588
653
  value_object() { return put({}); },
589
654
  value_object_set(objectHandle, keyHandle, valueHandle) {
590
655
  const target = value(objectHandle);
591
656
  if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_set target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
592
- target[String(value(keyHandle))] = value(valueHandle);
657
+ const key = String(value(keyHandle));
658
+ memoryBudget?.write(target, key, value(valueHandle));
659
+ target[key] = value(valueHandle);
593
660
  },
594
661
  value_object_spread(objectHandle, valueHandle) {
595
662
  const target = value(objectHandle);
596
663
  const source = value(valueHandle);
597
664
  if (!target || typeof target !== 'object') throw new CanonicalNativeHostError('value_object_spread target is not an object.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
598
665
  if (source === null || source === undefined) return;
599
- 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
+ }
600
674
  },
601
675
  value_property(objectHandle, keyHandle) {
602
676
  const target = value(objectHandle);
@@ -607,6 +681,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
607
681
  const target = value(objectHandle);
608
682
  if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write a property on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
609
683
  const assigned = value(valueHandle);
684
+ memoryBudget?.write(target, String(value(keyHandle)), assigned);
610
685
  target[String(value(keyHandle))] = assigned;
611
686
  return valueHandle;
612
687
  },
@@ -619,6 +694,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
619
694
  const target = value(objectHandle);
620
695
  if (target === null || target === undefined) throw new CanonicalNativeHostError('Cannot write an element on null or undefined.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE', { objectHandle });
621
696
  const assigned = value(valueHandle);
697
+ memoryBudget?.write(target, value(keyHandle), assigned);
622
698
  target[value(keyHandle)] = assigned;
623
699
  return valueHandle;
624
700
  },
@@ -631,6 +707,11 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
631
707
  return put(unary(operator, value(valueHandle)));
632
708
  },
633
709
  value_truthy(valueHandle) { return value(valueHandle) ? 1 : 0; },
710
+ value_string_trim(valueHandle) {
711
+ const input = value(valueHandle);
712
+ if (typeof input !== 'string') throw new CanonicalNativeHostError('String trim requires a string value.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
713
+ return put(input.trim());
714
+ },
634
715
  value_nullish(valueHandle) { return value(valueHandle) === null || value(valueHandle) === undefined ? 1 : 0; },
635
716
  log(level, messageHandle) {
636
717
  const methods = Object.freeze({ 1: 'error', 2: 'warn', 3: 'info', 4: 'debug' });
@@ -645,10 +726,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
645
726
  request_url() { requireHttpSurface('ctx.req.url'); return put(context.ctx.req.url); },
646
727
  request_path() { requireHttpSurface('ctx.req.path'); return put(context.ctx.req.path); },
647
728
  request_headers() { requireHttpSurface('ctx.req.headers'); return put(context.ctx.req.headers); },
648
- request_header(nameHandle) { requireHttpSurface('ctx.req.header'); return put(context.ctx.req.header(value(nameHandle))); },
649
- request_text() { requireHttpSurface('ctx.req.text'); return put(context.ctx.req.text()); },
729
+ request_header(nameHandle) {
730
+ requireHttpSurface('ctx.req.header');
731
+ const name = String(value(nameHandle)).toLowerCase();
732
+ const header = context.ctx.req.headers.find(([key]) => String(key).toLowerCase() === name);
733
+ return put(header ? header[1] : undefined);
734
+ },
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
+ },
650
745
  request_json(schemaHandle) {
651
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');
652
748
  const schema = value(schemaHandle);
653
749
  return put(schema === undefined ? context.ctx.req.json() : context.ctx.req.json(schema));
654
750
  },
@@ -656,6 +752,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
656
752
  router_param(pathHandle, patternHandle, nameHandle) { return put(routerParam(value(pathHandle), value(patternHandle), value(nameHandle))); },
657
753
  response_json(valueHandle, optionsHandle) { requireHttpSurface('ctx.json'); return put(context.ctx.json(value(valueHandle), value(optionsHandle))); },
658
754
  schema_encode(valueHandle, schemaHandle) { return put(context.ctx.encodeJson(value(valueHandle), value(schemaHandle))); },
755
+ schema_decode(textHandle, schemaHandle) { return put(context.ctx.decodeJson(value(textHandle), value(schemaHandle))); },
659
756
  response_text(valueHandle, optionsHandle) { requireHttpSurface('ctx.text'); return put(context.ctx.text(value(valueHandle), value(optionsHandle))); },
660
757
  response_custom(valueHandle) { requireHttpSurface('ctx.response'); return put(context.ctx.response(value(valueHandle))); },
661
758
  grip_is_websocket() {
@@ -724,14 +821,26 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
724
821
  if (!response || typeof response.header !== 'function') throw new CanonicalNativeHostError('fetch_header requires a fetch response.', 'PULSE_CANONICAL_NATIVE_VALUE_TYPE');
725
822
  return put(response.header(value(nameHandle)));
726
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
+ },
727
828
  effect_begin(effectIndex, payloadHandle) {
829
+ options.outputExecution?.assertEffect();
728
830
  const index = Number(effectIndex);
729
831
  const effect = plan.effects && plan.effects[index];
730
832
  if (!effect) throw new CanonicalNativeHostError(`Native module requested unknown effect index ${index}.`, 'PULSE_CANONICAL_NATIVE_EFFECT_INDEX_INVALID', { effectIndex: index });
731
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
+ }
732
840
  const kvAdmission = portableKv.isConditionalKv(effect.kind) ? portableKv.admitConditionalKv(nativeEffect(effect, payload), options) : undefined;
733
841
  if (kvAdmission) portableKv.registerKvRedactions(kvAdmission, (value) => sensitiveValues.add(value));
734
- 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 } : {}) }));
735
844
  }
736
845
  };
737
846
 
@@ -748,6 +857,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
748
857
  else env[item.name] = () => { throw new CanonicalNativeHostError(`Unsupported AssemblyScript env import ${item.name}.`, 'PULSE_CANONICAL_NATIVE_ENV_IMPORT_UNSUPPORTED', { name: item.name }); };
749
858
  }
750
859
 
860
+ let applicationError = 0;
861
+ const applicationErrors = executionPlane === 'http' && (plan.routing?.entries || []).some(entry => entry.kind === 'error');
862
+ function captureApplicationError(error) {
863
+ if (options.outputExecution?.started) return false;
864
+ if (!applicationErrors || options.signal?.aborted || !portableKv.isApplicationError(error)) return false;
865
+ if (!applicationError) {
866
+ const safe = canonicalRuntime.redactRuntimeError(error, sensitiveValues);
867
+ applicationError = put(Object.freeze({ name: safe.name, code: safe.code, message: safe.message }));
868
+ }
869
+ return true;
870
+ }
871
+ if (applicationErrors) for (const [name, implementation] of Object.entries(pulseHost)) {
872
+ pulseHost[name] = (...args) => {
873
+ if (applicationError) return 0;
874
+ try { return implementation(...args); }
875
+ catch (error) { if (!captureApplicationError(error)) throw error; return 0; }
876
+ };
877
+ }
878
+ pulseHost.router_error_take = () => { const error = applicationError; applicationError = 0; return error; };
751
879
  instance = new WebAssembly.Instance(module, { pulse_host: pulseHost, env });
752
880
  if (schemaIndexes.size > 0) {
753
881
  const available = new Set(moduleShape.exports.map((entry) => entry.name));
@@ -790,6 +918,7 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
790
918
  }
791
919
 
792
920
  let result = portableKv.isConditionalKv(effect.kind) ? portableKv.normalizeConditionalKvResult(effect, rawResult, options) : rawResult;
921
+ if (effect.kind === 'time.now') result = require('@pulse-compute/runtime/host').normalizeTimeResult(rawResult);
793
922
  if (effect.kind === 'event.emit' && result !== undefined) {
794
923
  throw new canonicalRuntime.CanonicalRuntimeError(
795
924
  'EventAcceptanceError',
@@ -826,13 +955,25 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
826
955
  return result;
827
956
  }
828
957
 
829
- function setEffectResult(index, result) {
830
- const handle = put(result);
831
- const accepted = instance.exports.pulse_set_effect_result(Number(index), handle);
832
- if (accepted !== runtimeContract.CANONICAL_NATIVE_RESULT_STATUS.ACCEPTED) {
833
- 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() });
834
- }
835
- 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
+ });
836
977
  }
837
978
 
838
979
  function resultValue() {
@@ -872,9 +1013,11 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
872
1013
  schemaId: selected.schemaId,
873
1014
  payloadHandle
874
1015
  });
875
- return instance.exports.pulse_event_start(selected.runtimeId, payloadHandle);
1016
+ return run(() => instance.exports.pulse_event_start(selected.runtimeId, payloadHandle));
876
1017
  }
877
1018
 
1019
+ options.signal?.addEventListener('abort', close, { once: true });
1020
+ if (options.signal?.aborted) close();
878
1021
  return Object.freeze({
879
1022
  version: runtimeContract.CANONICAL_NATIVE_HOST_VERSION,
880
1023
  plan,
@@ -891,13 +1034,17 @@ function instantiateCanonicalNativeModule(compiled, options = {}) {
891
1034
  cryptoVerifier: nativeCrypto.verifier,
892
1035
  cryptoRealization: nativeCrypto.realization,
893
1036
  executionPlane,
894
- start() { requireExecutionPlane('http'); return instance.exports.pulse_start(); },
1037
+ start() { requireExecutionPlane('http'); return run(() => instance.exports.pulse_start()); },
895
1038
  startEvent,
896
1039
  eventSelection() { return activeEvent; },
897
- resume() { return instance.exports.pulse_resume(); },
1040
+ resume() { return run(() => instance.exports.pulse_resume()); },
1041
+ close,
1042
+ assertPendingEffect: invocations.assertPending,
898
1043
  pendingEffects: takePending,
899
1044
  prepareEffectResult,
1045
+ captureApplicationError,
900
1046
  setEffectResult,
1047
+ setEffectFailure,
901
1048
  resultValue,
902
1049
  response() { requireHttpSurface('HTTP completion'); return canonicalRuntime.finalResponse(resultValue(), context.ctx.req.method); },
903
1050
  programCounter() { return instance.exports.pulse_program_counter(); },
@@ -915,9 +1062,18 @@ function nativeEventCancelled(message) {
915
1062
  return new CanonicalNativeHostError(message, 'PULSE_CANONICAL_NATIVE_EVENT_CANCELLED');
916
1063
  }
917
1064
 
918
- function raceNativeEventSignal(value, signal) {
1065
+ function nativeRequestCancelled(signal) {
1066
+ if (['PULSE_REQUEST_DEADLINE_EXCEEDED', 'PULSE_REQUEST_CLOCK_INVALID'].includes(signal.reason?.code)) return signal.reason;
1067
+ return new portableKv.PulseRuntimeContractError('PULSE_RUNTIME_EFFECT_ABORTED',
1068
+ signal.reason instanceof Error ? signal.reason.message : 'Native execution was cancelled.',
1069
+ { cause: signal.reason });
1070
+ }
1071
+
1072
+ function raceNativeSignal(value, signal, eventMode = true) {
919
1073
  if (!signal) return Promise.resolve(value);
920
- if (signal.aborted) return Promise.reject(nativeEventCancelled('Native event execution was cancelled during effect dispatch.'));
1074
+ const cancelled = () => eventMode ? nativeEventCancelled('Native event execution was cancelled during effect dispatch.')
1075
+ : nativeRequestCancelled(signal);
1076
+ if (signal.aborted) return Promise.reject(cancelled());
921
1077
  return new Promise((resolve, reject) => {
922
1078
  let settled = false;
923
1079
  const finish = (callback, result) => {
@@ -926,7 +1082,7 @@ function raceNativeEventSignal(value, signal) {
926
1082
  signal.removeEventListener('abort', onAbort);
927
1083
  callback(result);
928
1084
  };
929
- const onAbort = () => finish(reject, nativeEventCancelled('Native event execution was cancelled during effect dispatch.'));
1085
+ const onAbort = () => finish(reject, cancelled());
930
1086
  signal.addEventListener('abort', onAbort, { once: true });
931
1087
  Promise.resolve(value).then(
932
1088
  (result) => finish(resolve, result),
@@ -936,6 +1092,13 @@ function raceNativeEventSignal(value, signal) {
936
1092
  }
937
1093
 
938
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
+ } };
939
1102
  const eventMode = invocation && invocation.plane === 'event';
940
1103
  const maxEffects = normalizeMaxEffects(options.maxEffects);
941
1104
  const controller = instantiateCanonicalNativeModule(compiled, { ...options, executionPlane: eventMode ? 'event' : 'http' });
@@ -955,14 +1118,17 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
955
1118
  let executionFailure;
956
1119
  let activeContinuation;
957
1120
  try {
1121
+ if (!eventMode && options.signal?.aborted) throw nativeRequestCancelled(options.signal);
958
1122
  if (eventMode && options.signal && options.signal.aborted) {
959
1123
  throw nativeEventCancelled('Native event execution was cancelled before handler entry.');
960
1124
  }
1125
+ options.requestBudget?.check();
961
1126
  status = eventMode ? controller.startEvent(invocation.frame) : controller.start();
962
1127
  while (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.SUSPENDED) {
963
1128
  if (eventMode && options.signal && options.signal.aborted) {
964
1129
  throw nativeEventCancelled('Native event execution was cancelled while suspended.');
965
1130
  }
1131
+ options.requestBudget?.check();
966
1132
  const pending = controller.pendingEffects();
967
1133
  if (pending.length !== controller.exports.pulse_pending_count()) {
968
1134
  throw new CanonicalNativeHostError('Native pending-effect count differs from host queue.', 'PULSE_CANONICAL_NATIVE_PENDING_MISMATCH', { moduleCount: controller.exports.pulse_pending_count(), hostCount: pending.length });
@@ -976,6 +1142,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
976
1142
  }
977
1143
  activeContinuation = {
978
1144
  id: continuation.id,
1145
+ invocationId: `continuation-${pending[0].ticket.invocationId}`,
979
1146
  kind: continuation.kind,
980
1147
  effectIds: Object.freeze([...pendingEffectIds]),
981
1148
  stateIndex: continuationState,
@@ -983,7 +1150,7 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
983
1150
  states: ['created', 'waiting']
984
1151
  };
985
1152
  continuations.push(activeContinuation);
986
- 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)));
987
1154
  if (effectCount + pending.length > maxEffects) {
988
1155
  throw new CanonicalNativeHostError(
989
1156
  eventMode
@@ -995,13 +1162,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
995
1162
  }
996
1163
  effectCount += pending.length;
997
1164
 
998
- const settled = await Promise.allSettled(pending.map(async (entry) => {
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.');
1166
+ const settled = await raceNativeSignal(Promise.allSettled(pending.map(async (entry) => {
1167
+ options.requestBudget?.check();
999
1168
  if (eventMode && options.signal && options.signal.aborted) {
1000
1169
  throw nativeEventCancelled('Native event execution was cancelled before effect dispatch.');
1001
1170
  }
1002
1171
  const rawEffect = { ...nativeEffect(entry.effect, entry.payload), ...entry.kvAdmission };
1003
1172
  const conditional = portableKv.isConditionalKv(rawEffect.kind);
1004
- 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) =>
1005
1174
  item.package === entry.effect.package && item.contractId === entry.effect.contractId && item.operation === entry.effect.operation
1006
1175
  && item.redaction && Object.keys(item.redaction).length > 0);
1007
1176
  const normalized = canonicalRuntime.normalizeProviderEffect(rawEffect, controller.schemaCodecs, {
@@ -1013,10 +1182,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1013
1182
  target: 'native',
1014
1183
  provider: adapter.id
1015
1184
  });
1016
- 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)));
1017
1187
  const effectExecution = {
1018
1188
  ...options,
1019
1189
  executionId,
1190
+ invocationId: entry.ticket.invocationId,
1020
1191
  metadata: compiled && compiled.plan ? compiled.plan.canonical : controller.plan.canonical,
1021
1192
  plan: controller.plan,
1022
1193
  packageArtifacts,
@@ -1030,22 +1201,40 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1030
1201
  return controller.schemaCodecs.decode(String(schemaId), value, context.source || 'package-effect');
1031
1202
  }
1032
1203
  };
1033
- 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
1034
1208
  ? await portableKv.executeConditionalKv(normalized, adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatchEffect(normalized, kvExecution)),
1035
1209
  { ...effectExecution, onKvObservation: (event) => controller.trace.push(event) }, options)
1036
- : await raceNativeEventSignal(adapter.dispatchEffect(normalized, effectExecution), eventMode ? options.signal : undefined);
1037
- const result = controller.prepareEffectResult(entry.index, rawResult);
1038
- 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)));
1210
+ : await raceNativeSignal(adapter.dispatchEffect(normalized, effectExecution), options.signal, eventMode);
1211
+ options.requestBudget?.check();
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)));
1039
1218
  resolutionOrder.push(entry.effect.id);
1040
1219
  return { entry, result };
1041
- }));
1220
+ })), options.signal, eventMode);
1042
1221
 
1043
1222
  if (eventMode && options.signal && options.signal.aborted) {
1044
1223
  throw nativeEventCancelled('Native event execution was cancelled while suspended.');
1045
1224
  }
1046
- const failed = settled.find((entry) => entry.status === 'rejected');
1047
- if (failed) throw canonicalRuntime.redactRuntimeError(failed.reason, controller.sensitiveValues);
1048
- for (const item of settled) controller.setEffectResult(item.value.entry.index, item.value.result);
1225
+ options.requestBudget?.check();
1226
+ if (!eventMode && options.signal?.aborted) throw nativeRequestCancelled(options.signal);
1227
+ const failures = settled.filter(entry => entry.status === 'rejected');
1228
+ const failed = failures.find(entry => !portableKv.isApplicationError(entry.reason)) || failures[0];
1229
+ const packageFailure = controller.plan.packages?.application?.effectFailure === 'package-completion';
1230
+ if (failed && !packageFailure && (!failures.every(entry => portableKv.isApplicationError(entry.reason))
1231
+ || !controller.captureApplicationError(failed.reason))) {
1232
+ throw canonicalRuntime.redactRuntimeError(failed.reason, controller.sensitiveValues);
1233
+ }
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
+ });
1049
1238
  const previousPc = controller.programCounter();
1050
1239
  activeContinuation.state = 'resumed';
1051
1240
  activeContinuation.states.push('resumed');
@@ -1053,9 +1242,9 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1053
1242
  if (status === runtimeContract.CANONICAL_NATIVE_RUN_STATUS.INVALID_RESUME) {
1054
1243
  throw new CanonicalNativeHostError('Native module rejected a complete effect-result set.', 'PULSE_CANONICAL_NATIVE_INVALID_RESUME', { programCounter: previousPc, errorCode: controller.lastErrorCode() });
1055
1244
  }
1056
- activeContinuation.state = 'completed';
1057
- activeContinuation.states.push('completed');
1058
- controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, continuationState }, controller.sensitiveValues)));
1245
+ activeContinuation.state = failed ? 'failed' : 'completed';
1246
+ activeContinuation.states.push(activeContinuation.state);
1247
+ controller.trace.push(Object.freeze(redactValue({ type: 'native-continuation-resumed', executionId, continuationId: continuation.id, invocationId: activeContinuation.invocationId, continuationState }, controller.sensitiveValues)));
1059
1248
  activeContinuation = undefined;
1060
1249
  }
1061
1250
 
@@ -1083,10 +1272,15 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1083
1272
  resolutionOrder: Object.freeze([...resolutionOrder]),
1084
1273
  continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
1085
1274
  trace: Object.freeze([...controller.trace]),
1086
- valueHandleCount: controller.heap.size()
1275
+ valueHandleCount: controller.heap.size(),
1276
+ ...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
1087
1277
  });
1088
1278
  }
1279
+ if (options.signal?.aborted) throw nativeRequestCancelled(options.signal);
1280
+ options.requestBudget?.check();
1281
+ options.outputExecution?.validateResult(controller.resultValue());
1089
1282
  const response = controller.response();
1283
+ responseBodyTransferred = response.bodyStream;
1090
1284
  controller.trace.push(Object.freeze(redactValue({ type: 'native-execution-completed', executionId, provider: adapter.id, status: response.status, bodyClass: response.bodyClass }, controller.sensitiveValues)));
1091
1285
  return Object.freeze({
1092
1286
  status: 'completed',
@@ -1100,10 +1294,12 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1100
1294
  wasmSha256: controller.wasmSha256,
1101
1295
  response,
1102
1296
  effectCount,
1297
+ ...((options.incomingBody || options.outputExecution) ? {guestMemoryBytes:controller.exports.memory.buffer.byteLength} : {}),
1103
1298
  resolutionOrder: Object.freeze([...resolutionOrder]),
1104
1299
  continuations: Object.freeze(continuations.map((entry) => Object.freeze({ ...entry, states: Object.freeze([...entry.states]) }))),
1105
1300
  trace: Object.freeze([...controller.trace]),
1106
- valueHandleCount: controller.heap.size()
1301
+ valueHandleCount: controller.heap.size(),
1302
+ ...(controller.heap.budget ? { memory: controller.heap.budget.snapshot() } : {})
1107
1303
  });
1108
1304
  } catch (error) {
1109
1305
  executionFailure = canonicalRuntime.redactRuntimeError(error, controller.sensitiveValues);
@@ -1120,13 +1316,21 @@ async function executeCanonicalNativeInvocation(compiled, options = {}, invocati
1120
1316
  }) });
1121
1317
  throw executionFailure;
1122
1318
  } finally {
1319
+ invocationClosed = true;
1320
+ for (const body of ownedResponseBodies) if (body !== responseBodyTransferred) cancelBody(body);
1321
+ ownedResponseBodies.clear();
1322
+ controller.close();
1123
1323
  try { adapter.disposeExecution(Object.freeze({ executionId, metadata: controller.plan.canonical, plan: controller.plan, status: executionFailure ? 'failed' : 'completed', error: executionFailure })); }
1124
1324
  catch (_) { /* provider cleanup must not mask execution */ }
1125
1325
  }
1126
1326
  }
1127
1327
 
1128
1328
  async function executeCanonicalNativeModule(compiled, options = {}) {
1129
- return executeCanonicalNativeInvocation(compiled, options, Object.freeze({ plane: 'http' }));
1329
+ const budget = portableKv.createRequestBudget(options);
1330
+ try {
1331
+ budget.check();
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' }));
1333
+ } finally { if (!options.requestBudget) budget.close(); }
1130
1334
  }
1131
1335
 
1132
1336
  function nativeEventFailure(error) {
@@ -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 };