@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 +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/workflow-chain-pack-manifest.schema.json +12 -1
- package/schemas/workflow-definition.schema.json +12 -1
- package/src/lib/workflow-chain-expansion.ts +36 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +28 -0
- package/src/scenarios/compensation-profile.test.ts +14 -0
package/package.json
CHANGED
|
@@ -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.
|
|
4
|
-
"corpusCommit": "
|
|
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: {
|
|
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
|
-
|
|
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' } }),
|