@openwop/openwop-conformance 1.129.0 → 1.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.129.0",
3
+ "version": "1.130.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.129.0",
4
- "corpusCommit": "edd6a891da09af801b04e750c0219ee7cb401137"
3
+ "suiteVersion": "1.130.0",
4
+ "corpusCommit": "6c603a19a1985f2ae05ef77504e2c189c9ea75bf"
5
5
  }
@@ -490,9 +490,20 @@
490
490
  "description": "RFC 0151 §C — an inverse action can itself be harmful (RFC 0147 R9), so it may be gated behind the same approval surface as a forward effect."
491
491
  }
492
492
  }
493
+ },
494
+ "irreversibleEffect": {
495
+ "type": "boolean",
496
+ "description": "RFC 0151 §B UQ4 (resolved 2026-08-16), mirrored into chain fragments per RFC 0157 — the fragment author states that this node's committed effect HAS NO INVERSE. Mutually exclusive with `compensation` (host MUST reject both at pack install / chain expansion); expansion copies it onto the expanded `WorkflowNode` unchanged. See `workflow-definition.schema.json` `WorkflowNode.irreversibleEffect`."
493
497
  }
494
498
  },
495
- "additionalProperties": false
499
+ "additionalProperties": false,
500
+ "if": {
501
+ "properties": { "irreversibleEffect": { "const": true } },
502
+ "required": ["irreversibleEffect"]
503
+ },
504
+ "then": {
505
+ "not": { "required": ["compensation"] }
506
+ }
496
507
  },
497
508
  "FragmentEdge": {
498
509
  "type": "object",
@@ -293,9 +293,20 @@
293
293
  "description": "RFC 0151 \u00a7C \u2014 an inverse action can itself be harmful (RFC 0147 R9), so it may be gated behind the same approval surface as a forward effect."
294
294
  }
295
295
  }
296
+ },
297
+ "irreversibleEffect": {
298
+ "type": "boolean",
299
+ "description": "RFC 0151 \u00a7B (UQ4, resolved 2026-08-16) \u2014 the author states that this node's committed effect HAS NO INVERSE. OPTIONAL; absent or false means nothing (an undeclared compensator is still not implied). Mutually exclusive with `compensation`: a node declaring both is contradictory and a host MUST reject it at registration (`validation_error`). When true and the node's effect committed before an unwind starts, the plan records the node as `irreversible` (never completed) so the \u00a7D rollup caps at `partial` \u2014 a reader can no longer infer a full undo from a `completed` that quietly skipped it."
296
300
  }
297
301
  },
298
- "additionalProperties": false
302
+ "additionalProperties": false,
303
+ "if": {
304
+ "properties": { "irreversibleEffect": { "const": true } },
305
+ "required": ["irreversibleEffect"]
306
+ },
307
+ "then": {
308
+ "not": { "required": ["compensation"] }
309
+ }
299
310
  },
300
311
  "WorkflowEdge": {
301
312
  "type": "object",
@@ -353,10 +353,31 @@ export interface ChainCompensationPolicy {
353
353
  /** A chain as RFC 0157 sees it: the RFC 0013 shape plus the two optional
354
354
  * compensation surfaces. */
355
355
  export type WorkflowChainWithCompensation = Omit<WorkflowChain, 'dag'> & {
356
- dag: { nodes: ReadonlyArray<FragmentNode & { compensation?: FragmentNodeCompensation }>; edges?: ReadonlyArray<FragmentEdge> };
356
+ dag: {
357
+ nodes: ReadonlyArray<FragmentNode & { compensation?: FragmentNodeCompensation; irreversibleEffect?: boolean }>;
358
+ edges?: ReadonlyArray<FragmentEdge>;
359
+ };
357
360
  compensation?: ChainCompensationPolicy;
358
361
  };
359
362
 
363
+ /** Thrown when a fragment node declares BOTH `irreversibleEffect: true` and a
364
+ * `compensation` (RFC 0151 UQ4 / `compensation.md` §B): a contradiction the
365
+ * schema also rejects; expansion refuses it fail-closed rather than pick one.
366
+ * Wire code `chain_irreversible_with_compensation`. */
367
+ export class ChainIrreversibleWithCompensationError extends Error {
368
+ readonly code = 'chain_irreversible_with_compensation' as const;
369
+ constructor(
370
+ public readonly nodeId: string,
371
+ public readonly chainId: string,
372
+ ) {
373
+ super(
374
+ `chain_irreversible_with_compensation: fragment node "${nodeId}" in chain "${chainId}" declares both ` +
375
+ 'irreversibleEffect: true and a compensation — an effect cannot both have and lack an inverse',
376
+ );
377
+ this.name = 'ChainIrreversibleWithCompensationError';
378
+ }
379
+ }
380
+
360
381
  /** Thrown when the parent already carries a `settings.compensation` policy that
361
382
  * is not deep-equal to the chain's. Wire code
362
383
  * `chain_compensation_policy_conflict` (`workflow-chain-packs.md` §"Error
@@ -412,8 +433,9 @@ function canonicalJson(v: unknown): string {
412
433
 
413
434
  export interface CarriedCompensation {
414
435
  /** The expanded fragment with `compensation` carried onto each node that
415
- * declared one (typeIds validated, params substituted, id refs rewritten). */
416
- nodes: ReadonlyArray<ExpandedFragment['nodes'][number] & { compensation?: FragmentNodeCompensation }>;
436
+ * declared one (typeIds validated, params substituted, id refs rewritten)
437
+ * and `irreversibleEffect` copied verbatim where declared. */
438
+ nodes: ReadonlyArray<ExpandedFragment['nodes'][number] & { compensation?: FragmentNodeCompensation; irreversibleEffect?: boolean }>;
417
439
  /** The `settings.compensation` the registered definition MUST carry after
418
440
  * this expansion: the parent's when the chain declares none; the chain's
419
441
  * when the parent had none; the (equal) shared policy when both agree.
@@ -433,11 +455,15 @@ export interface CarriedCompensation {
433
455
  * literals; the recorded-facts rule is unaffected);
434
456
  * 6b. fragment node-id references inside `inputMapping` are rewritten with
435
457
  * the expansion prefix, exactly as edge refs are;
458
+ * 6c. `irreversibleEffect: true` (RFC 0151 UQ4) is copied onto the expanded
459
+ * node unchanged; a fragment node declaring both it and a `compensation`
460
+ * is refused (`chain_irreversible_with_compensation`) — the schema rejects
461
+ * the shape too, and expansion does not pick a side;
436
462
  * 9b. the chain-level policy becomes the definition's `settings.compensation`
437
463
  * — copied when the parent has none, accepted when equal, otherwise
438
464
  * `chain_compensation_policy_conflict`.
439
465
  *
440
- * @throws ChainUnresolvableTypeIdError, ChainCompensationPolicyConflictError
466
+ * @throws ChainUnresolvableTypeIdError, ChainCompensationPolicyConflictError, ChainIrreversibleWithCompensationError
441
467
  */
442
468
  export function carryCompensation(
443
469
  chain: WorkflowChainWithCompensation,
@@ -450,17 +476,21 @@ export function carryCompensation(
450
476
  const fragmentNodeIds = new Set(srcNodes.map((n) => n.id));
451
477
  const byOriginalId = new Map(srcNodes.map((n) => [n.id, n] as const));
452
478
 
453
- // 3b
479
+ // 3b (+ 6c's contradiction check, before any node is emitted)
454
480
  for (const n of srcNodes) {
481
+ if (n.irreversibleEffect === true && n.compensation !== undefined) {
482
+ throw new ChainIrreversibleWithCompensationError(n.id, chain.chainId);
483
+ }
455
484
  if (n.compensation !== undefined && !ctx.isTypeIdResolvable(n.compensation.nodeTypeId)) {
456
485
  throw new ChainUnresolvableTypeIdError(n.compensation.nodeTypeId, chain.chainId);
457
486
  }
458
487
  }
459
488
 
460
- // 5b + 6b
489
+ // 5b + 6b + 6c
461
490
  const nodes = expanded.nodes.map((en) => {
462
491
  const originalId = en.id.startsWith(prefix) ? en.id.slice(prefix.length) : en.id;
463
492
  const src = byOriginalId.get(originalId);
493
+ if (src?.irreversibleEffect === true) return { ...en, irreversibleEffect: true };
464
494
  if (src?.compensation === undefined) return en;
465
495
  const c: FragmentNodeCompensation = { nodeTypeId: src.compensation.nodeTypeId };
466
496
  if (src.compensation.inputMapping !== undefined) {
@@ -49,6 +49,7 @@ import {
49
49
  expandChainWithCompensation,
50
50
  ChainUnresolvableTypeIdError,
51
51
  ChainCompensationPolicyConflictError,
52
+ ChainIrreversibleWithCompensationError,
52
53
  type WorkflowChainWithCompensation,
53
54
  type ChainCompensationPolicy,
54
55
  type FragmentNodeCompensation,
@@ -162,6 +163,33 @@ describe('RFC 0157 — schema: the chain manifest mirrors the compensation shape
162
163
  noTriggers.compensation = {};
163
164
  expect(validate(manifestWith(noTriggers)), 'a policy without triggers is not a policy').toBe(false);
164
165
  });
166
+
167
+ it('FragmentNode.irreversibleEffect (RFC 0151 UQ4) is a sibling boolean, mutually exclusive with compensation — in the manifest and in expansion', () => {
168
+ const validate = manifestValidator();
169
+ // The `notify` node declares no compensation; stating its effect is irreversible is valid.
170
+ const irreversible = structuredClone(CHAIN) as unknown as { dag: { nodes: Array<Record<string, unknown>> } };
171
+ const notifyIdx = irreversible.dag.nodes.findIndex((n) => n['id'] === 'notify');
172
+ expect(notifyIdx).toBeGreaterThanOrEqual(0);
173
+ irreversible.dag.nodes[notifyIdx]!['irreversibleEffect'] = true;
174
+ expect(validate(manifestWith(irreversible)), JSON.stringify(validate.errors)).toBe(true);
175
+ // Expansion copies it verbatim onto the expanded node (6c) and touches nothing else.
176
+ const out = expandChainWithCompensation(irreversible as unknown as typeof CHAIN, CTX);
177
+ const notify = out.nodes.find((n) => n.id.endsWith('_notify')) as { irreversibleEffect?: boolean; compensation?: unknown } | undefined;
178
+ expect(notify?.irreversibleEffect).toBe(true);
179
+ expect(notify?.compensation).toBeUndefined();
180
+ // Both on one node: the schema rejects it AND expansion refuses fail-closed before emitting.
181
+ const both = structuredClone(CHAIN) as unknown as { dag: { nodes: Array<Record<string, unknown>> } };
182
+ both.dag.nodes[0]!['irreversibleEffect'] = true; // node 0 (`reserve`) declares a compensation
183
+ expect(validate(manifestWith(both)), 'compensation.md §B: irreversibleEffect: true + compensation is contradictory').toBe(false);
184
+ let thrown: unknown;
185
+ try {
186
+ expandChainWithCompensation(both as unknown as typeof CHAIN, CTX);
187
+ } catch (e) {
188
+ thrown = e;
189
+ }
190
+ expect(thrown).toBeInstanceOf(ChainIrreversibleWithCompensationError);
191
+ expect((thrown as ChainIrreversibleWithCompensationError).code).toBe('chain_irreversible_with_compensation');
192
+ });
165
193
  });
166
194
 
167
195
  describe('RFC 0157 — expansion carries the declaration and the policy', () => {
@@ -130,6 +130,20 @@ describe('RFC 0151 §B — node compensation declaration', () => {
130
130
  expect(validate({ ...base }), JSON.stringify(validate.errors)).toBe(true);
131
131
  });
132
132
 
133
+ it('irreversibleEffect (RFC 0151 UQ4) is a sibling boolean, mutually exclusive with a compensation declaration', () => {
134
+ // A statement that the effect HAS NO INVERSE. Sibling of `compensation`, so
135
+ // `nodeTypeId` stays required and COMPATIBILITY §2.2 is not engaged.
136
+ expect(validate({ ...base, irreversibleEffect: true }), JSON.stringify(validate.errors)).toBe(true);
137
+ expect(validate({ ...base, irreversibleEffect: false, compensation: { nodeTypeId: 'vendor.shop.release' } }), JSON.stringify(validate.errors)).toBe(true);
138
+ // Both = an effect that both has and lacks an inverse. The schema rejects it
139
+ // (`if irreversibleEffect === true then not required compensation`).
140
+ expect(
141
+ validate({ ...base, irreversibleEffect: true, compensation: { nodeTypeId: 'vendor.shop.release' } }),
142
+ 'compensation.md §B: a node declaring both irreversibleEffect: true and compensation is contradictory and MUST be rejected',
143
+ ).toBe(false);
144
+ expect(validate({ ...base, irreversibleEffect: 'yes' }), 'irreversibleEffect is a boolean').toBe(false);
145
+ });
146
+
133
147
  it('the declaration is closed — an unknown key is rejected', () => {
134
148
  expect(
135
149
  validate({ ...base, compensation: { nodeTypeId: 'x', onFailure: 'ignore' } }),