@openwop/openwop-conformance 1.128.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.128.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.128.0",
4
- "corpusCommit": "04f59e91eb096ce1782b16722599a9d2cfa7aae1"
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' } }),
@@ -0,0 +1,195 @@
1
+ /**
2
+ * RFC 0151 §C / §E / §B+§F — the compensation RECOVERY witness: retry-stable
3
+ * identity, tenant-bound operator authority, recorded-facts-only inputs.
4
+ *
5
+ * `compensation-behavior.test.ts` proves the happy unwind: plan before effect,
6
+ * reverse order, replay does not re-fire, content-free events, rollup ⇄ events.
7
+ * RFC 0151 §G names three further invariants that file cannot see, and
8
+ * `SECURITY/threat-model-compensation.md` §7 said so and named the seam each
9
+ * one needs. This file is that seam's consumer (`host-sample-test-seams.md` §21
10
+ * "Recovery extension"):
11
+ *
12
+ * - **`compensation-effect-id-retry-stable`** (§C). `unwind` with
13
+ * `failFirstInverseAttempts: 2`: the first inverse action fails twice then
14
+ * succeeds. The response's `inverseActions[]` MUST show one entry for that
15
+ * ordinal with `attempts: 3` and a SINGLE distinct `downstreamKeys` value —
16
+ * one obligation, three attempts, the same idempotency key at the
17
+ * downstream every time. Two keys is two refunds.
18
+ * - **`compensation-tenant-authority-bound`** (§E). `unwind` with `hold: true`
19
+ * leaves a held plan (`compensationStatus: manual`); the `operator` seam is
20
+ * then driven three times — a cross-tenant actor (404: RFC 0132 §A.2,
21
+ * neutralize, do not reveal), a same-tenant non-operator (403, audited),
22
+ * the operator (200, `audited: true`). A seam that answers 200 to all
23
+ * three consults nothing.
24
+ * - **`compensation-input-recorded-facts-only`** (§B/§F). `replay` reports the
25
+ * inverse actions of the source run and of the replay with the input each
26
+ * executed with; they MUST deep-equal while `refiredEffects` stays 0. An
27
+ * inverse built from a re-derived value is not the inverse of what was
28
+ * done, and replay is where re-derivation would happen.
29
+ *
30
+ * Every leg is capability-gated on `compensation.supported` via `behaviorGate`
31
+ * (soft-skips without the advert, HARD-FAILS under `OPENWOP_REQUIRE_BEHAVIOR=true`)
32
+ * and records its RFC 0148 §A disposition. The three sub-features are
33
+ * INDEPENDENTLY optional: a host that wires the base seams but not this
34
+ * extension is `blocked` here (named as such via `seamAbsent`) and keeps its
35
+ * `compensation-behavior` witness — the base and the extension are different
36
+ * claims and are recorded separately.
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { driver } from '../lib/driver.js';
41
+ import { behaviorGate } from '../lib/behavior-gate.js';
42
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
43
+ import { seamAbsent } from '../lib/soft-skip.js';
44
+ import { readErrorCode, readRetriable } from '../lib/error-envelope.js';
45
+
46
+ const PROFILE = 'openwop-compensation';
47
+ const UNWIND = '/v1/host/sample/test/compensation/unwind';
48
+ const REPLAY = '/v1/host/sample/test/compensation/replay';
49
+ const OPERATOR = '/v1/host/sample/test/compensation/operator';
50
+
51
+ interface InverseAction {
52
+ readonly ordinal?: number;
53
+ readonly effectId?: string;
54
+ readonly attempts?: number;
55
+ readonly outcome?: string;
56
+ readonly downstreamKeys?: readonly string[];
57
+ readonly input?: unknown;
58
+ }
59
+
60
+ interface UnwindResponse {
61
+ readonly runId?: string;
62
+ readonly events?: ReadonlyArray<{ type?: string; payload?: Record<string, unknown> }>;
63
+ readonly compensatedOrder?: readonly number[];
64
+ readonly inverseActions?: readonly InverseAction[];
65
+ }
66
+
67
+ interface ReplayResponse {
68
+ readonly runId?: string;
69
+ readonly refiredEffects?: number;
70
+ readonly source?: readonly InverseAction[];
71
+ readonly replayed?: readonly InverseAction[];
72
+ }
73
+
74
+ async function advertised(): Promise<boolean> {
75
+ const disco = await driver.get('/.well-known/openwop');
76
+ return capabilityFamily<{ supported?: boolean }>(disco.json, 'compensation')?.supported === true;
77
+ }
78
+
79
+ async function post(path: string, body: Record<string, unknown>): Promise<{ status: number; json: unknown } | null> {
80
+ const r = await driver.post(path, body);
81
+ if (r.status === 404 && path !== OPERATOR) {
82
+ seamAbsent(`host advertises \`compensation.supported: true\` but ${path} answered 404 — the RFC 0151 recovery requirements are unobservable (host-sample-test-seams.md §21)`);
83
+ return null;
84
+ }
85
+ return { status: r.status, json: r.json };
86
+ }
87
+
88
+ /** The recovery extension is optional; a base seam that ignores the new fields is `blocked` here, not failed. */
89
+ function extensionAbsent(what: string): undefined {
90
+ return seamAbsent(`the §21 recovery extension is not wired: ${what} (host-sample-test-seams.md §21 "Recovery extension") — retry-stability / operator authority / recorded-facts are unobservable`);
91
+ }
92
+
93
+ describe('RFC 0151 §C — inverse-action identity is retry-stable (capability-gated behavior)', () => {
94
+ it('a transient inverse failure retries under the SAME identity and the same downstream key', async () => {
95
+ if (!behaviorGate(PROFILE, await advertised())) return;
96
+ const r = await post(UNWIND, { nodes: 2, failFirstInverseAttempts: 2 });
97
+ if (r === null) return;
98
+ expect(r.status, driver.describe('spec/v1/host-sample-test-seams.md §21', 'unwind seam answers 200')).toBe(200);
99
+ const body = r.json as UnwindResponse;
100
+ if (!Array.isArray(body.inverseActions)) return extensionAbsent('`unwind` did not return `inverseActions[]`');
101
+ // Under reverse-completion the first inverse to run is the highest forward ordinal (2).
102
+ const first = body.inverseActions.find((a) => a.ordinal === 2) ?? body.inverseActions[0];
103
+ expect(first, driver.describe('spec/v1/host-sample-test-seams.md §21', 'one entry per plan entry')).toBeDefined();
104
+ const a = first as InverseAction;
105
+ expect(a.attempts, driver.describe('RFCS/0151 §C', 'two transient failures then success is THREE attempts of one obligation')).toBe(3);
106
+ expect(a.outcome, driver.describe('RFCS/0151 §C', 'a transient failure MUST be retryable — the action completes')).toBe('completed');
107
+ expect(Array.isArray(a.downstreamKeys) ? a.downstreamKeys.length : -1, driver.describe('spec/v1/host-sample-test-seams.md §21', '`downstreamKeys` has one entry per attempt')).toBe(3);
108
+ const distinct = new Set(a.downstreamKeys ?? []);
109
+ expect(
110
+ distinct.size,
111
+ driver.describe(
112
+ 'RFCS/0151 §C + RFC 0150 §B',
113
+ 'a retry re-presents the SAME inverse-action identity as its idempotency key — a second key at the downstream is a second obligation (two refunds). `attempt` is outside the identity for exactly this reason.',
114
+ ),
115
+ ).toBe(1);
116
+ // One obligation per ordinal: no duplicate plan entries.
117
+ const ordinals = body.inverseActions.map((x) => x.ordinal);
118
+ expect(new Set(ordinals).size, driver.describe('RFCS/0151 §C', 'exactly one plan entry per forward ordinal')).toBe(ordinals.length);
119
+ expect(typeof a.effectId === 'string' && a.effectId.length > 0, driver.describe('RFCS/0151 §D', 'the identity is carried as an opaque `effectId`')).toBe(true);
120
+ });
121
+ });
122
+
123
+ describe('RFC 0151 §E — operator authority is bound to the plan\'s tenant (capability-gated behavior)', () => {
124
+ it('cross-tenant is neutralized (404), same-tenant non-operator is refused and audited (403), the operator acts (200)', async () => {
125
+ if (!behaviorGate(PROFILE, await advertised())) return;
126
+ const held = await post(UNWIND, { nodes: 2, hold: true });
127
+ if (held === null) return;
128
+ const body = held.json as UnwindResponse;
129
+ const runId = body.runId;
130
+ if (typeof runId !== 'string') return extensionAbsent('`unwind` did not return a `runId`');
131
+ const heldEntry = (body.inverseActions ?? []).find((x) => x.outcome === 'held');
132
+ const manual = (body.events ?? []).some((e) => e.type === 'compensation.manual_intervention_required');
133
+ if (heldEntry === undefined && !manual) return extensionAbsent('`unwind` with `hold: true` produced neither a held plan entry nor `compensation.manual_intervention_required`');
134
+ // Snapshot must read `manual` while held (§D fold).
135
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
136
+ expect(snap.status, driver.describe('rest-endpoints.md', 'GET /v1/runs/{runId} for the held run')).toBe(200);
137
+ expect((snap.json as { compensationStatus?: string }).compensationStatus, driver.describe('spec/v1/compensation.md §D', 'a held plan reads `manual`')).toBe('manual');
138
+
139
+ const op = async (actor: Record<string, unknown>) =>
140
+ driver.post(OPERATOR, { runId, action: 'retry', actor });
141
+ // 1. Cross-tenant actor: neutralize to the actor's tenant, do not reveal.
142
+ const cross = await op({ tenantId: 'conformance-other-tenant', principalId: 'conformance-operator', operator: true });
143
+ if (cross.status === 404 && readErrorCode(cross.json) === undefined && (cross.json === null || cross.json === undefined || Object.keys(cross.json as object).length === 0)) {
144
+ return extensionAbsent(`${OPERATOR} answered a bodiless 404 — the operator seam is absent (an absent seam and a neutralized cross-tenant plan must be told apart by the envelope: RFC 0132 §A.2 answers \`not_found\` in the canonical envelope)`);
145
+ }
146
+ expect(cross.status, driver.describe('RFCS/0151 §E + RFC 0132 §A.2', 'an actor from another tenant MUST NOT learn the plan exists — 404, not 403')).toBe(404);
147
+ expect(readErrorCode(cross.json), driver.describe('rest-endpoints.md §Error codes', '`not_found` — do not leak existence')).toBe('not_found');
148
+ // 2. Same tenant, no operator authority: refused AND audited.
149
+ const plain = await op({ tenantId: 'default', principalId: 'conformance-bystander', operator: false });
150
+ expect(plain.status, driver.describe('RFCS/0151 §E', 'a principal without operator authority in the plan\'s tenant is refused')).toBe(403);
151
+ expect(readErrorCode(plain.json), driver.describe('rest-endpoints.md §Error codes', '`forbidden`')).toBe('forbidden');
152
+ expect(readRetriable(plain.json), driver.describe('spec/v1/host-sample-test-seams.md §21', 'a refused override is not retriable')).toBe(false);
153
+ // 3. The operator: acts, audited, rollup moves off `manual`.
154
+ const ok = await op({ tenantId: 'default', principalId: 'conformance-operator', operator: true });
155
+ expect(ok.status, driver.describe('RFCS/0151 §E', 'an authorized operator MAY retry a held inverse action')).toBe(200);
156
+ const res = ok.json as { compensationStatus?: string; audited?: boolean; planVersion?: unknown };
157
+ expect(res.audited, driver.describe('RFCS/0151 §E', 'every override MUST be audited (`authorization.decided`)')).toBe(true);
158
+ expect(
159
+ ['completed', 'partial', 'failed', 'running'],
160
+ driver.describe('spec/v1/compensation.md §D', 'after the operator retries, the rollup is whatever the recorded outcomes yield — never still `manual` for a resolved hold'),
161
+ ).toContain(res.compensationStatus);
162
+ // The refusal in step 2 was audited too: the plan's events carry a reason.
163
+ const after = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
164
+ if (after.status === 200) {
165
+ const evs = (after.json as { events?: Array<{ type?: string; payload?: Record<string, unknown> }> }).events ?? [];
166
+ const decided = evs.filter((e) => e.type === 'authorization.decided');
167
+ expect(decided.length, driver.describe('RFCS/0151 §E + RFC 0049', 'both the refusal and the override are `authorization.decided` records')).toBeGreaterThanOrEqual(2);
168
+ }
169
+ });
170
+ });
171
+
172
+ describe('RFC 0151 §B/§F — inverse inputs are recorded facts, not re-derived (capability-gated behavior)', () => {
173
+ it('a replay executes the same inverse actions with the same inputs, and re-fires nothing', async () => {
174
+ if (!behaviorGate(PROFILE, await advertised())) return;
175
+ const r = await post(REPLAY, {});
176
+ if (r === null) return;
177
+ expect(r.status, driver.describe('spec/v1/host-sample-test-seams.md §21', 'replay seam answers 200')).toBe(200);
178
+ const body = r.json as ReplayResponse;
179
+ if (!Array.isArray(body.source) || !Array.isArray(body.replayed)) return extensionAbsent('`replay` did not return `source[]` / `replayed[]`');
180
+ expect(body.refiredEffects, driver.describe('RFCS/0151 §F', 'replay MUST NOT re-fire inverse effects')).toBe(0);
181
+ expect(body.source.length, driver.describe('spec/v1/host-sample-test-seams.md §21', 'the source run had inverse actions to compare')).toBeGreaterThan(0);
182
+ const norm = (xs: readonly InverseAction[]) =>
183
+ [...xs].sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0)).map((x) => ({ ordinal: x.ordinal, effectId: x.effectId, input: x.input }));
184
+ expect(
185
+ norm(body.replayed),
186
+ driver.describe(
187
+ 'RFCS/0151 §B + §F',
188
+ 'a replay uses the RECORDED inverse inputs and identities — an inverse built from a re-derived (prompt / model / live) value is not the inverse of what was actually done',
189
+ ),
190
+ ).toEqual(norm(body.source));
191
+ for (const x of body.source) {
192
+ expect(x.input, driver.describe('RFCS/0151 §B', 'each inverse action executed with a recorded input')).toBeDefined();
193
+ }
194
+ });
195
+ });