@openwop/openwop-conformance 1.129.0 → 1.131.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.131.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.131.0",
4
+ "corpusCommit": "b2a25cf7013d21838b8b5abeaea7dff12355bfc5"
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' } }),
@@ -34,6 +34,14 @@
34
34
  * extension is `blocked` here (named as such via `seamAbsent`) and keeps its
35
35
  * `compensation-behavior` witness — the base and the extension are different
36
36
  * claims and are recorded separately.
37
+ *
38
+ * Mode matters, and the first host to advertise pointed it out: `seamAbsent`
39
+ * records `blocked` in DEFAULT mode but THROWS under
40
+ * `OPENWOP_REQUIRE_BEHAVIOR=true` (RFC 0148 §B — an advertised capability
41
+ * whose witness seam is missing is a failure in strict certification, not a
42
+ * disposition). So a host that flips `compensation.supported` on without the
43
+ * §21 recovery extension is honest-red here in strict runs. Wire the extension
44
+ * with the advert, or run default mode and read the `blocked` rows.
37
45
  */
38
46
 
39
47
  import { describe, it, expect } from 'vitest';
@@ -55,6 +55,15 @@ describe('org-position-no-authority-escalation (RFC 0087 §B, behavioral)', () =
55
55
  const chart = await getOrgChart();
56
56
  if (chart === null) return seamAbsent('host advertises org-position authority but GET /v1/agents/org-chart is not served');
57
57
 
58
+ // An EMPTY chart has nothing that could carry authority: the invariant is
59
+ // unobservable, and RFC 0148 §A says unobservable is `blocked`, not a
60
+ // zero-assertion pass (which is what this leg recorded on such a host).
61
+ if ((chart.members ?? []).length === 0 && (chart.departments ?? []).length === 0) {
62
+ return seamAbsent('host advertises org-position authority but the org chart is EMPTY — no member or department to check; the invariant is unobservable until the host seeds one');
63
+ }
64
+ // The chart root is a wire object too.
65
+ expectNoAuthority(chart as unknown as Record<string, unknown>, 'the org-chart root');
66
+
58
67
  for (const m of chart.members ?? []) {
59
68
  expectNoAuthority(m as Record<string, unknown>, 'an org-chart member');
60
69
  }
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { describe, it, expect } from 'vitest';
24
24
  import { driver } from '../lib/driver.js';
25
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
25
26
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
26
27
 
27
28
  interface DiscoveryWorkspace {
@@ -49,7 +50,7 @@ interface WorkspaceFile {
49
50
 
50
51
  describe('workspace-behavior: §C CRUD round-trip (RFC 0059)', () => {
51
52
  it('PUT creates v1, GET returns it, list omits content, DELETE removes it', async () => {
52
- if (!(await workspaceCap())) return;
53
+ if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
53
54
  const path = 'behavior/CRUD.md';
54
55
 
55
56
  const created = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'first body' });
@@ -78,7 +79,7 @@ describe('workspace-behavior: §C CRUD round-trip (RFC 0059)', () => {
78
79
 
79
80
  describe('workspace-behavior: §C optimistic concurrency (RFC 0059)', () => {
80
81
  it('stale If-Match → 409 workspace_conflict; matching If-Match bumps version', async () => {
81
- if (!(await workspaceCap())) return;
82
+ if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
82
83
  const path = 'behavior/ETAG.md';
83
84
 
84
85
  const v1 = await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'v1' });
@@ -101,7 +102,7 @@ describe('workspace-behavior: §C optimistic concurrency (RFC 0059)', () => {
101
102
  describe('workspace-behavior: §C size ceiling (RFC 0059)', () => {
102
103
  it('content beyond maxFileBytes returns workspace_too_large', async () => {
103
104
  const cap = await workspaceCap();
104
- if (cap === null) return;
105
+ if (cap === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
105
106
  const max = typeof cap.maxFileBytes === 'number' ? cap.maxFileBytes : 1_048_576;
106
107
  const tooBig = 'x'.repeat(max + 1);
107
108
  const res = await driver.put(`${FILES}/${encodeURIComponent('behavior/TOOBIG.md')}`, { content: tooBig });
@@ -112,14 +113,14 @@ describe('workspace-behavior: §C size ceiling (RFC 0059)', () => {
112
113
 
113
114
  describe('workspace-behavior: §D run-start snapshot (RFC 0059)', () => {
114
115
  it('a run started after a write exposes the workspace snapshot', async () => {
115
- if (!(await workspaceCap())) return;
116
+ if (!(await workspaceCap())) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
116
117
  const path = 'behavior/SNAPSHOT.md';
117
118
  await driver.put(`${FILES}/${encodeURIComponent(path)}`, { content: 'snapshot body' });
118
119
 
119
120
  const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
120
- if (create.status !== 201 && create.status !== 200) return; // host lacks the noop fixture skip
121
+ if (create.status !== 201 && create.status !== 200) return seamAbsent(`host advertises workspace but the noop fixture run could not be created (POST /v1/runs → ${create.status})`);
121
122
  const runId = (create.json as { runId?: string }).runId;
122
- if (runId === undefined) return;
123
+ if (runId === undefined) return seamAbsent('host advertises workspace but the created run carried no runId');
123
124
 
124
125
  const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
125
126
  const ws = (snap.json as { workspace?: Array<{ path: string }> }).workspace;
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { describe, it, expect } from 'vitest';
25
25
  import { driver } from '../lib/driver.js';
26
+ import { softSkip } from '../lib/soft-skip.js';
26
27
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
27
28
 
28
29
  interface DiscoveryWorkspace {
@@ -48,7 +49,7 @@ const POSITIVE_INT_FIELDS = ['maxFileBytes', 'maxFiles', 'maxVersions'] as const
48
49
  describe('workspace-capability-shape: advertisement shape (RFC 0059 §A)', () => {
49
50
  it('capabilities.workspace is either absent or well-formed', async () => {
50
51
  const ws = await readWorkspace();
51
- if (ws === null) return; // host doesn't advertise workspace at all
52
+ if (ws === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
52
53
  expect(
53
54
  typeof ws.supported,
54
55
  driver.describe(
@@ -60,7 +61,7 @@ describe('workspace-capability-shape: advertisement shape (RFC 0059 §A)', () =>
60
61
 
61
62
  it('maxFileBytes / maxFiles / maxVersions are positive integers when present', async () => {
62
63
  const ws = await readWorkspace();
63
- if (ws === null) return;
64
+ if (ws === null) return softSkip('inapplicable', 'optional advertisement — `capabilities.workspace` not advertised by this host (RFC 0059 §A)');
64
65
  for (const field of POSITIVE_INT_FIELDS) {
65
66
  const v = ws[field];
66
67
  if (v === undefined) continue;