@fabricorg/platform-host 7.1.0 → 7.1.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 7.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 91f7d7e: A repeated completion was judged identical by outcome alone, so a second `completed` callback carrying a different result was absorbed as a replay and its evidence discarded. A recorded completion now carries a canonical digest of provider, reference, outcome, result, error, and evidence, with observation time left out because a redelivery carries a new one. A repeat with the same digest is absorbed; the same outcome with a different digest is a contradiction and is handled as one: reconciliation, the first outcome kept, the second logged, `ExternalOperationContradicted` emitted.
8
+
3
9
  ## 7.1.0
4
10
 
5
11
  ### Minor Changes
package/README.md CHANGED
@@ -265,7 +265,9 @@ await host.completeExternalInvocation(actionInvocationId, tenantId, spaceId, {
265
265
  ```
266
266
 
267
267
  Matching is by reference: a completion naming a reference the invocation is not waiting on is
268
- refused. A repeated identical completion is absorbed. A contradictory one moves the invocation to
268
+ refused. A repeated identical completion is absorbed, identity being a digest of provider,
269
+ reference, outcome, result, error, and evidence. A contradictory one, a flipped outcome or the same
270
+ outcome with different evidence, moves the invocation to
269
271
  `reconciliation_required`, keeps the first outcome on record, logs the second, and emits
270
272
  `ExternalOperationContradicted`. Under an atomic store the read and write happen under one lock. The
271
273
  callback's result is validated against the action's result schema and stripped of private fields.
package/dist/index.cjs CHANGED
@@ -1352,11 +1352,12 @@ function createGovernedActionHost(options) {
1352
1352
  if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
1353
1353
  throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
1354
1354
  }
1355
- if (recorded.outcome === completion.outcome) {
1355
+ const incomingDigest = completionDigest(completion, recorded.provider ?? completion.provider);
1356
+ if (recorded.digest === incomingDigest) {
1356
1357
  return withConsistency(actionResult(invocation), invocation.actionId);
1357
1358
  }
1358
- const message = `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
1359
- await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${completion.outcome}` }, message);
1359
+ const message = recorded.outcome === completion.outcome ? `External operation "${completion.externalReference}" reported ${completion.outcome} again with different evidence.` : `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
1360
+ await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${incomingDigest.slice(0, 16)}` }, message);
1360
1361
  await appendEvent(invocation, {
1361
1362
  eventType: "ExternalOperationContradicted",
1362
1363
  subjectType: "AdapterInvocation",
@@ -1386,7 +1387,8 @@ function createGovernedActionHost(options) {
1386
1387
  ...completion,
1387
1388
  provider: completion.provider ?? pending.provider,
1388
1389
  observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
1389
- recordedAt
1390
+ recordedAt,
1391
+ digest: completionDigest(completion, completion.provider ?? pending.provider)
1390
1392
  };
1391
1393
  const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
1392
1394
  const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
@@ -1425,6 +1427,16 @@ function createGovernedActionHost(options) {
1425
1427
  if (settles) emitInvocationStatusTelemetry(invocation, "failed", options.telemetry, now(), invocation.status);
1426
1428
  return withConsistency(actionResult({ ...invocation, ...patch }), invocation.actionId);
1427
1429
  }
1430
+ function completionDigest(completion, provider) {
1431
+ return crypto.createHash("sha256").update(canonicalJson({
1432
+ provider: provider ?? null,
1433
+ externalReference: completion.externalReference,
1434
+ outcome: completion.outcome,
1435
+ result: completion.result ?? null,
1436
+ error: completion.error ?? null,
1437
+ evidenceReferences: completion.evidenceReferences ?? null
1438
+ })).digest("hex");
1439
+ }
1428
1440
  async function recordCompletionReconciliation(invocation, completion, reason) {
1429
1441
  const governanceStore = asGovernanceStore(options.store);
1430
1442
  if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");