@openwop/openwop-conformance 2.47.0 → 2.48.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/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-compensate-unwind.json +70 -0
- package/fixtures.md +17 -0
- package/package.json +2 -2
- package/requirements.json +207 -4
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +16 -16
- package/src/lib/compensation-unwind.ts +173 -0
- package/src/scenarios/v2-compensation-unwind.test.ts +244 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## [2.48.0] — 2026-11-13 — Compensation unwind and derived adoption
|
|
2
|
+
|
|
3
|
+
- **RFC 0265.** `v2-compensation-unwind`: five host legs gated on `compensation` and the `conformance-compensate-unwind` fixture (legs 3 and 5 also on `idempotency`), reading only the event log, `GET /runs/{runId}/compensation`, `GET /runs/{runId}/effects` and a replay fork. The judges live in `lib/compensation-unwind.ts`, with a self-test that includes sabotage cases.
|
|
4
|
+
- **RFC 0264.** `v2-core-adoption-derived` (coherence) drives `scripts/derive-core-adoption.mjs` on fixture trees: a derived value against a hand edit, missing, stale and overdue review rows, and a renewal that only changes the date.
|
|
5
|
+
|
|
1
6
|
## [2.47.0] — 2026-11-13 — Cache-key recipe 2, telemetry export, sabotage ledger
|
|
2
7
|
|
|
3
8
|
- **RFC 0260.** `v2-replay-llm-cache-key`: a server-free shape leg, an unaided implies-idempotency leg, and four host legs on `GET /runs/{runId}/effects` (key recorded, parameters in the key, stable, portable), gated on `replay.llmCacheKeyRecipe` and the `conformance-llm-cache-key` fixture. Judges in `lib/llm-cache-key-2.ts`.
|
package/README.md
CHANGED
|
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
|
|
|
137
137
|
|
|
138
138
|
## What's Covered
|
|
139
139
|
|
|
140
|
-
The current suite has
|
|
140
|
+
The current suite has 671 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 231 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
|
|
141
141
|
|
|
142
142
|
The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
|
|
143
143
|
|
|
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
|
|
|
166
166
|
- **Host** checks drive a live host over HTTP.
|
|
167
167
|
- **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
|
|
168
168
|
|
|
169
|
-
Current source tree:
|
|
169
|
+
Current source tree: 671 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
|
|
170
170
|
|
|
171
171
|
## How the 2.x suite is built
|
|
172
172
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-compensate-unwind",
|
|
3
|
+
"name": "Conformance: Compensate Unwind",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "RFC 0265 fixture for `v2-compensation-unwind.test.ts`: the condition an unwind exists for, caused by the suite unaided. Three effect nodes run in sequence (`e1` → `e2` → `e3`) and succeed; the last node, `fail` (`core.fail`, the always-fails node `conformance-failure` uses), then fails deterministically, so the run has committed effects and a `node-failure` trigger. Each effect node declares a node-level `compensation` whose inverse is the same reserved type with `inputMapping.inverse: true`, and `settings.compensation` asks for a `reverse-completion` unwind on `node-failure`. `core.conformance.compensable-effect` is a conformance-RESERVED typeId (same pattern as `core.conformance.side-effect`): a host that advertises `compensation` and serves this fixture MUST map it to a node it records on the Layer-2 effect ledger (`GET /runs/{runId}/effects`) as exactly one effect per execution, forward and inverse alike, and MUST run it as the inverse action when its resolved inputs carry `inverse: true`. What the effect does is irrelevant; that it is recorded once per execution is the whole contract. The suite reads the unwind through normative paths only: the event log (`compensation.requested` before `compensation.started`), `GET /runs/{runId}/compensation` (attempts in the order `e3, e2, e1`, the reverse of forward completion) and the effect ledger (three inverse records, none added by a `mode: \"replay\"` fork). Serving the fixture is OPTIONAL; a host that does not list it in discovery `fixtures[]` is `inapplicable` for every leg.",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "e1",
|
|
9
|
+
"typeId": "core.conformance.compensable-effect",
|
|
10
|
+
"name": "Effect 1",
|
|
11
|
+
"position": { "x": 0, "y": 0 },
|
|
12
|
+
"config": {},
|
|
13
|
+
"inputs": {},
|
|
14
|
+
"compensation": {
|
|
15
|
+
"nodeTypeId": "core.conformance.compensable-effect",
|
|
16
|
+
"inputMapping": { "inverse": true }
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "e2",
|
|
21
|
+
"typeId": "core.conformance.compensable-effect",
|
|
22
|
+
"name": "Effect 2",
|
|
23
|
+
"position": { "x": 200, "y": 0 },
|
|
24
|
+
"config": {},
|
|
25
|
+
"inputs": {},
|
|
26
|
+
"compensation": {
|
|
27
|
+
"nodeTypeId": "core.conformance.compensable-effect",
|
|
28
|
+
"inputMapping": { "inverse": true }
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"id": "e3",
|
|
33
|
+
"typeId": "core.conformance.compensable-effect",
|
|
34
|
+
"name": "Effect 3",
|
|
35
|
+
"position": { "x": 400, "y": 0 },
|
|
36
|
+
"config": {},
|
|
37
|
+
"inputs": {},
|
|
38
|
+
"compensation": {
|
|
39
|
+
"nodeTypeId": "core.conformance.compensable-effect",
|
|
40
|
+
"inputMapping": { "inverse": true }
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"id": "fail",
|
|
45
|
+
"typeId": "core.fail",
|
|
46
|
+
"name": "Fail",
|
|
47
|
+
"position": { "x": 600, "y": 0 },
|
|
48
|
+
"config": {},
|
|
49
|
+
"inputs": {}
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
"edges": [
|
|
53
|
+
{ "id": "e1-e2", "sourceNodeId": "e1", "targetNodeId": "e2" },
|
|
54
|
+
{ "id": "e2-e3", "sourceNodeId": "e2", "targetNodeId": "e3" },
|
|
55
|
+
{ "id": "e3-fail", "sourceNodeId": "e3", "targetNodeId": "fail" }
|
|
56
|
+
],
|
|
57
|
+
"triggers": [
|
|
58
|
+
{ "id": "manual", "type": "manual", "enabled": true }
|
|
59
|
+
],
|
|
60
|
+
"variables": [],
|
|
61
|
+
"metadata": { "tags": ["conformance"] },
|
|
62
|
+
"settings": {
|
|
63
|
+
"timeout": 30000,
|
|
64
|
+
"maxRetries": 0,
|
|
65
|
+
"compensation": {
|
|
66
|
+
"orderingModel": "reverse-completion",
|
|
67
|
+
"triggers": ["node-failure"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
package/fixtures.md
CHANGED
|
@@ -151,6 +151,7 @@ All fixtures MUST advertise:
|
|
|
151
151
|
| v2 tableStorage Schema Probe | `conformance-table-schema-probe` | `storage.md` §`tableStorage` — one `core.conformance.table-schema-probe` node (conformance-RESERVED) creates a table `{ k: string, n: number }` with the host's own `ctx.storage.table`, then inserts a well-typed row (`control`), a row whose `n` is a string (`insert-mistyped`), or updates `n` to a string (`update-mistyped`). See §"The tableStorage schema probe fixture" | `completed` for `control`; `failed` (`validation_error`, `details.service: tableStorage`) for a mistyped write | ≤ 30s |
|
|
152
152
|
| v2 ctx.callAI Attempt Accounting | `conformance-callai-attempt-accounting` | RFC 0248 — one `conformance.callai.echo-attempts` node (conformance-RESERVED) that makes one `ctx.callAI` call through the host's conformance mock, serves the run's `script`, and echoes the result's `attempts` (`v2-callai-attempt-accounting`) | each case run completes or fails as its script dictates | ≤ 30s |
|
|
153
153
|
| v2 LLM Cache Key | `conformance-llm-cache-key` | RFC 0260 — one `conformance.callai.cache-key` node (conformance-RESERVED) making one `ctx.callAI` call through the suite's mock provider with the run's prompt and params unchanged (`v2-replay-llm-cache-key`) | completes; the call's effect record carries `cacheKey` | ≤ 30s |
|
|
154
|
+
| v2 Compensate Unwind | `conformance-compensate-unwind` | RFC 0265 — three `core.conformance.compensable-effect` nodes (conformance-RESERVED) `e1 → e2 → e3`, each declaring itself as its own inverse (`inputMapping.inverse: true`), then `core.fail`; `settings.compensation` is `reverse-completion` on `node-failure` (`v2-compensation-unwind`). See §"The compensate-unwind fixture" | `failed`, then an unwind: `compensation.requested` → `compensation.started` → `compensation.completed`; three inverse records on the effect ledger | ≤ 30s |
|
|
154
155
|
| v2 safeFetch SSRF Probe | `conformance-safefetch-probe` | `host-services.md` §`httpClient` — one `core.conformance.safefetch-probe` node (conformance-RESERVED) calls the host's own `ctx.http.safeFetch(url)` with the run's `url`, unchanged; completes with the response `status` only; a rejection fails the node with its code and details. See §"The safeFetch probe fixture" | `failed` (`egress_denied`, `details.reason: ssrf-blocked`) for a refused target | ≤ 60s |
|
|
155
156
|
| A2A Task Roundtrip | `conformance-a2a-task-roundtrip` | Track 6 — host consumes the conformance suite's synthetic A2A peer; covers drift points #3 (`AUTH_REQUIRED`) and #4 (`REJECTED`). Node `core.conformance.a2a-invoke` is a **conformance-RESERVED** typeId (renamed 2026-08-16 from `core.a2a.invoke`, a multi-segment `core.*` id no host shipped and the corpus never defined): a host that consumes A2A peers MUST map it to its A2A bridge node (a host-extension node — `a2a-integration.md` §"State projection"); a host that does not consume A2A MUST NOT advertise this fixture | `failed` or `waiting-input` (per `driftScenario` input) | ≤ 30s |
|
|
156
157
|
| WASM Pack Roundtrip | `conformance-wasm-pack-roundtrip` | RFC 0008 — invokes `vendor.openwop.rust-hello.greet` (loaded WASM pack); exercises required exports + at least one import | `completed` | ≤ 10s |
|
|
@@ -851,6 +852,21 @@ The scope leg sends a promote with `OPENWOP_TEST_LOW_SCOPE_KEY`, a key with no `
|
|
|
851
852
|
- **Gate**: `v2-interrupt-form-rules` runs each leg only when its fixtures are advertised. No capability or seam.
|
|
852
853
|
- **Timing**: the positive suspends; the probes settle within 10 s.
|
|
853
854
|
|
|
855
|
+
## The compensate-unwind fixture (RFC 0265)
|
|
856
|
+
|
|
857
|
+
`conformance-compensate-unwind` is what `v2-compensation-unwind` drives. Its effect node, `core.conformance.compensable-effect`, is conformance-RESERVED. A host that advertises `compensation` and serves the fixture maps it to a node it records on the effect ledger (`GET /runs/{runId}/effects`) as exactly one effect per execution, forward and inverse alike, and runs it as the inverse action when its resolved inputs carry `inverse: true`. What the effect does is irrelevant. A host that does not advertise `compensation` MUST NOT advertise the fixture: it would refuse the workflow's `settings.compensation` with `capability_required`.
|
|
858
|
+
|
|
859
|
+
- **Shape**: `e1` → `e2` → `e3` (`core.conformance.compensable-effect`, each with node-level `compensation.nodeTypeId` = the same type and `inputMapping: { inverse: true }`) → `fail` (`core.fail`). `settings.compensation`: `orderingModel: "reverse-completion"`, `triggers: ["node-failure"]`.
|
|
860
|
+
- **Inputs**: none.
|
|
861
|
+
- **Expected behavior**:
|
|
862
|
+
1. `e1`, `e2` and `e3` complete in order; `fail` fails; the run ends `failed`.
|
|
863
|
+
2. The log holds `compensation.requested` before the first `compensation.started`, and ends the unwind with `compensation.completed`.
|
|
864
|
+
3. `GET /runs/{runId}/compensation` reads `status: "completed"`, `plan[]` naming `e1`, `e2` and `e3`, and `attempts[]` in the order `e3, e2, e1`.
|
|
865
|
+
4. The effect ledger holds one forward record per effect node and exactly three inverse records.
|
|
866
|
+
5. A `mode: "replay"` fork records no effect the source run did not make.
|
|
867
|
+
- **Advertise only if** the host advertises `compensation`. Legs 3 and 5 also need `idempotency` (the ledger); leg 5 needs `replay.modes ∋ replay`.
|
|
868
|
+
- **Terminal status**: `failed`.
|
|
869
|
+
|
|
854
870
|
## NodeModule registration
|
|
855
871
|
|
|
856
872
|
The fixtures reference these typeIds:
|
|
@@ -858,6 +874,7 @@ The fixtures reference these typeIds:
|
|
|
858
874
|
| typeId | Required by | Behavior |
|
|
859
875
|
| ----------------------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
860
876
|
| `core.noop` | noop, multi-node, approval, clarification, cancellable | Immediate completion, no output |
|
|
877
|
+
| `core.conformance.compensable-effect` | compensate-unwind | Conformance-RESERVED (RFC 0265). Performs one effect the host records on the Layer-2 effect ledger, one record per execution; with `inverse: true` in its resolved inputs it is the inverse action. Completes with no output. |
|
|
861
878
|
| `core.identity` | identity | Echo `input.payload` to `output.payload` |
|
|
862
879
|
| `core.delay` | delay, cancellable | Sleep `config.delayMs` ms |
|
|
863
880
|
| `core.fail` | failure | Throw with `code: "example.conformance_failure"` (a vendor code under the registered `example` org; errors.md §The registry, openwop#1698), message: "Intentional conformance failure" |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.48.0",
|
|
4
4
|
"description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.48.0"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 756,
|
|
6
|
+
"tests": 2911,
|
|
7
|
+
"withStableId": 2911,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2755
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -29625,6 +29625,52 @@
|
|
|
29625
29625
|
}
|
|
29626
29626
|
]
|
|
29627
29627
|
},
|
|
29628
|
+
{
|
|
29629
|
+
"id": "openwop.it.v2-compensation-unwind.compensation-requested-precedes-the-first-compensation-started",
|
|
29630
|
+
"file": "v2-compensation-unwind.test.ts",
|
|
29631
|
+
"line": 162,
|
|
29632
|
+
"title": "compensation.requested precedes the first compensation.started",
|
|
29633
|
+
"explicitId": "openwop.requirement.0265.plan-before-inverse",
|
|
29634
|
+
"citations": []
|
|
29635
|
+
},
|
|
29636
|
+
{
|
|
29637
|
+
"id": "openwop.it.v2-compensation-unwind.the-projection-attempts-the-effect-nodes-in-the-reverse-of-their-forward-complet",
|
|
29638
|
+
"file": "v2-compensation-unwind.test.ts",
|
|
29639
|
+
"line": 170,
|
|
29640
|
+
"title": "the projection attempts the effect nodes in the reverse of their forward completion order",
|
|
29641
|
+
"explicitId": "openwop.requirement.0265.reverse-completion",
|
|
29642
|
+
"citations": []
|
|
29643
|
+
},
|
|
29644
|
+
{
|
|
29645
|
+
"id": "openwop.it.v2-compensation-unwind.the-effect-ledger-holds-exactly-one-inverse-record-per-forward-effect",
|
|
29646
|
+
"file": "v2-compensation-unwind.test.ts",
|
|
29647
|
+
"line": 185,
|
|
29648
|
+
"title": "the effect ledger holds exactly one inverse record per forward effect",
|
|
29649
|
+
"explicitId": "openwop.requirement.0265.inverse-once",
|
|
29650
|
+
"citations": []
|
|
29651
|
+
},
|
|
29652
|
+
{
|
|
29653
|
+
"id": "openwop.it.v2-compensation-unwind.the-projection-status-and-plan-agree-with-the-event-log",
|
|
29654
|
+
"file": "v2-compensation-unwind.test.ts",
|
|
29655
|
+
"line": 199,
|
|
29656
|
+
"title": "the projection status and plan agree with the event log",
|
|
29657
|
+
"explicitId": "openwop.requirement.0265.projection-agrees",
|
|
29658
|
+
"citations": []
|
|
29659
|
+
},
|
|
29660
|
+
{
|
|
29661
|
+
"id": "openwop.it.v2-compensation-unwind.a-replay-fork-of-the-unwound-run-performs-no-inverse-again",
|
|
29662
|
+
"file": "v2-compensation-unwind.test.ts",
|
|
29663
|
+
"line": 209,
|
|
29664
|
+
"title": "a replay fork of the unwound run performs no inverse again",
|
|
29665
|
+
"explicitId": "openwop.requirement.0265.replay-no-refire",
|
|
29666
|
+
"citations": [
|
|
29667
|
+
{
|
|
29668
|
+
"section": "replay.md §Suppression; invariant compensation-replay-no-refire (RFC 0265 §B.5)",
|
|
29669
|
+
"requirement": null,
|
|
29670
|
+
"interpolated": true
|
|
29671
|
+
}
|
|
29672
|
+
]
|
|
29673
|
+
},
|
|
29628
29674
|
{
|
|
29629
29675
|
"id": "openwop.it.v2-configurable-closed.the-standalone-schema-rejects-a-dotted-key-and-an-unknown-root-key-and-accepts-t",
|
|
29630
29676
|
"file": "v2-configurable-closed.test.ts",
|
|
@@ -48408,6 +48454,163 @@
|
|
|
48408
48454
|
}
|
|
48409
48455
|
]
|
|
48410
48456
|
},
|
|
48457
|
+
{
|
|
48458
|
+
"id": "openwop.it.v2-core-adoption-derived.a-1-a-core-row-s-adoption-equals-the-value-the-bundles-derive",
|
|
48459
|
+
"file": "v2-core-adoption-derived.test.ts",
|
|
48460
|
+
"line": 123,
|
|
48461
|
+
"title": "§A.1 a core row's adoption equals the value the bundles derive",
|
|
48462
|
+
"explicitId": "openwop.requirement.0264.adoption-derived",
|
|
48463
|
+
"citations": [
|
|
48464
|
+
{
|
|
48465
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48466
|
+
"requirement": null,
|
|
48467
|
+
"interpolated": true
|
|
48468
|
+
},
|
|
48469
|
+
{
|
|
48470
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48471
|
+
"requirement": "the base MUST derive one multi-witness, one single-witness and one none family"
|
|
48472
|
+
},
|
|
48473
|
+
{
|
|
48474
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48475
|
+
"requirement": "a hand-edited adoption value MUST fail"
|
|
48476
|
+
},
|
|
48477
|
+
{
|
|
48478
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48479
|
+
"requirement": "the refusal MUST name the family and the derived value"
|
|
48480
|
+
},
|
|
48481
|
+
{
|
|
48482
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48483
|
+
"requirement": "claiming a witness the exclusions remove (old bundle, companion, unlisted origin, no record, citation) MUST fail"
|
|
48484
|
+
},
|
|
48485
|
+
{
|
|
48486
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48487
|
+
"requirement": "gamma MUST derive none"
|
|
48488
|
+
},
|
|
48489
|
+
{
|
|
48490
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48491
|
+
"requirement": "a tier-3 witness MUST make the row independent"
|
|
48492
|
+
},
|
|
48493
|
+
{
|
|
48494
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48495
|
+
"requirement": "the refusal MUST name independent"
|
|
48496
|
+
},
|
|
48497
|
+
{
|
|
48498
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48499
|
+
"requirement": "--write MUST succeed"
|
|
48500
|
+
},
|
|
48501
|
+
{
|
|
48502
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48503
|
+
"requirement": "--write MUST set the core rows and leave the ext row"
|
|
48504
|
+
},
|
|
48505
|
+
{
|
|
48506
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48507
|
+
"requirement": "the tree --write produced MUST pass --check"
|
|
48508
|
+
},
|
|
48509
|
+
{
|
|
48510
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48511
|
+
"requirement": null,
|
|
48512
|
+
"interpolated": true
|
|
48513
|
+
}
|
|
48514
|
+
]
|
|
48515
|
+
},
|
|
48516
|
+
{
|
|
48517
|
+
"id": "openwop.it.v2-core-adoption-derived.b-2-a-family-at-none-has-a-current-review-row-and-no-other-family-has-one",
|
|
48518
|
+
"file": "v2-core-adoption-derived.test.ts",
|
|
48519
|
+
"line": 154,
|
|
48520
|
+
"title": "§B.2 a family at none has a current review row, and no other family has one",
|
|
48521
|
+
"explicitId": "openwop.requirement.0264.review-row-present",
|
|
48522
|
+
"citations": [
|
|
48523
|
+
{
|
|
48524
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48525
|
+
"requirement": "the base tree with one keep row for gamma MUST pass"
|
|
48526
|
+
},
|
|
48527
|
+
{
|
|
48528
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48529
|
+
"requirement": "a none family with no row MUST fail"
|
|
48530
|
+
},
|
|
48531
|
+
{
|
|
48532
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48533
|
+
"requirement": "the refusal MUST name the family"
|
|
48534
|
+
},
|
|
48535
|
+
{
|
|
48536
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48537
|
+
"requirement": "a missing review file MUST fail while a family is at none"
|
|
48538
|
+
},
|
|
48539
|
+
{
|
|
48540
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48541
|
+
"requirement": "a row for a witnessed family MUST fail"
|
|
48542
|
+
},
|
|
48543
|
+
{
|
|
48544
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48545
|
+
"requirement": "the refusal MUST call it stale"
|
|
48546
|
+
},
|
|
48547
|
+
{
|
|
48548
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48549
|
+
"requirement": "a keep row below the release minor MUST fail"
|
|
48550
|
+
},
|
|
48551
|
+
{
|
|
48552
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48553
|
+
"requirement": "the refusal MUST call it overdue"
|
|
48554
|
+
},
|
|
48555
|
+
{
|
|
48556
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48557
|
+
"requirement": "a keep row due this minor is not yet overdue"
|
|
48558
|
+
},
|
|
48559
|
+
{
|
|
48560
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48561
|
+
"requirement": "an unfilled skeleton row MUST fail"
|
|
48562
|
+
},
|
|
48563
|
+
{
|
|
48564
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48565
|
+
"requirement": "a retire row without a correction MUST fail"
|
|
48566
|
+
},
|
|
48567
|
+
{
|
|
48568
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48569
|
+
"requirement": "the refusal MUST name §B.4"
|
|
48570
|
+
},
|
|
48571
|
+
{
|
|
48572
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48573
|
+
"requirement": "a retire row naming an unknown correction MUST fail"
|
|
48574
|
+
},
|
|
48575
|
+
{
|
|
48576
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48577
|
+
"requirement": "a retire row naming a correction row MUST pass, past its reviewBy too"
|
|
48578
|
+
}
|
|
48579
|
+
]
|
|
48580
|
+
},
|
|
48581
|
+
{
|
|
48582
|
+
"id": "openwop.it.v2-core-adoption-derived.b-3-renewing-a-keep-row-changes-its-reason-or-plan-not-only-its-date",
|
|
48583
|
+
"file": "v2-core-adoption-derived.test.ts",
|
|
48584
|
+
"line": 180,
|
|
48585
|
+
"title": "§B.3 renewing a keep row changes its reason or plan, not only its date",
|
|
48586
|
+
"explicitId": "openwop.requirement.0264.renewal-not-date-only",
|
|
48587
|
+
"citations": [
|
|
48588
|
+
{
|
|
48589
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48590
|
+
"requirement": "an unchanged row MUST pass"
|
|
48591
|
+
},
|
|
48592
|
+
{
|
|
48593
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48594
|
+
"requirement": "a renewal that changes only reviewBy MUST fail"
|
|
48595
|
+
},
|
|
48596
|
+
{
|
|
48597
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48598
|
+
"requirement": "the refusal MUST name §B.3"
|
|
48599
|
+
},
|
|
48600
|
+
{
|
|
48601
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48602
|
+
"requirement": "a renewal with a new reason MUST pass"
|
|
48603
|
+
},
|
|
48604
|
+
{
|
|
48605
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48606
|
+
"requirement": "a renewal with a new plan MUST pass"
|
|
48607
|
+
},
|
|
48608
|
+
{
|
|
48609
|
+
"section": "RFC 0264 · scripts/derive-core-adoption.mjs",
|
|
48610
|
+
"requirement": "a row with no baseline row is new, not a renewal"
|
|
48611
|
+
}
|
|
48612
|
+
]
|
|
48613
|
+
},
|
|
48411
48614
|
{
|
|
48412
48615
|
"id": "openwop.it.v2-corpus-tag-pinned.every-vendoring-consumer-in-the-cross-repo-inventory-records-a-published-corpus",
|
|
48413
48616
|
"file": "v2-corpus-tag-pinned.test.ts",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 671,
|
|
5
5
|
"v1": 455,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 231
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1392,6 +1392,9 @@
|
|
|
1392
1392
|
"v2-compensation-read-projection.test.ts": [
|
|
1393
1393
|
2
|
|
1394
1394
|
],
|
|
1395
|
+
"v2-compensation-unwind.test.ts": [
|
|
1396
|
+
2
|
|
1397
|
+
],
|
|
1395
1398
|
"v2-configurable-closed.test.ts": [
|
|
1396
1399
|
2
|
|
1397
1400
|
],
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.48.0",
|
|
5
|
+
"corpusTag": "v2.48.0",
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
8
8
|
"api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "61037c7c1d7f9859c9f39c34d6c8e2c65865036dab2d8bd4c5d8e5ff45bbb260",
|
|
13
|
+
"api/v2/asyncapi.yaml": "0d893e632e7dd72c11e17b38ec19907652265b88bc023a79ac4cbc3992ae8aea",
|
|
14
|
+
"api/v2/openapi.yaml": "fb704e969745a8886682c532f7de52f6abce67fa4bf1e58ac56a36c5e1bcd935",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -115,14 +115,14 @@
|
|
|
115
115
|
"schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
|
|
118
|
-
"schemas/v2/capabilities.schema.json": "
|
|
119
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
118
|
+
"schemas/v2/capabilities.schema.json": "afae0c548cf91a915c2badca77b7c89cd0c657f100c2cd1db64de896c8da2bf0",
|
|
119
|
+
"schemas/v2/certification-bundle.schema.json": "f9e233dc3584c3cef78809d518749ce37cd1ee79ebbfe58163dc67eeac7802b1",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
122
122
|
"schemas/v2/chat-card-pack-manifest.schema.json": "9cf235f5d0fe662f34f7e72bfbf2a82e0743046059a9d5af7aec948e228fdb0e",
|
|
123
123
|
"schemas/v2/compact-tool-descriptor.schema.json": "b5c418d72082d5b713bf318212a83397e5be98e56d25ca056302b41b8dd8273b",
|
|
124
124
|
"schemas/v2/compensation-policy.schema.json": "b3c1142637f5bf980f2b0bb67adb47e8cb7abb99a0405a4b6816ccf5d9c089d6",
|
|
125
|
-
"schemas/v2/compensation-projection.schema.json": "
|
|
125
|
+
"schemas/v2/compensation-projection.schema.json": "6acb2385fab26f172eb69cdca4e0ae6a618c003cfabd89783d2d5cf028a9270e",
|
|
126
126
|
"schemas/v2/configurable.schema.json": "33a6884047e2490f723733bf8b74d3b9164e00374cfea6dc3eac5a70dc368861",
|
|
127
127
|
"schemas/v2/connection-pack-manifest.schema.json": "8d019de6bd0c5b14ea3b227580734ec792521493b396a850f7f9217aa7a6a547",
|
|
128
128
|
"schemas/v2/connection-provider-registry.schema.json": "5b404da328a95c45808929e233ee766ebd635cf0df6f5d15d4c19354dfd7e75d",
|
|
@@ -203,14 +203,14 @@
|
|
|
203
203
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
204
204
|
"spec/v1/alias-detectors.json": "202a443678e592a515e33aad7f6ab679a07d8408809f2d4b0f59d156c84ffc65",
|
|
205
205
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
206
|
-
"spec/v1/core-standard-manifest.json": "
|
|
206
|
+
"spec/v1/core-standard-manifest.json": "b7c589705ad3361c40620c3b796d8a4fe4e45a25776e736b046c33029a9dd55b",
|
|
207
207
|
"spec/v1/deprecations.json": "901fdab0a7212627f1838cbb06ae7b7204f0dff528299e56d3d938deecea27c1",
|
|
208
208
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
209
209
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "e2b4a5721aefd6502137b16b55cd1a4edadd37e51029f25e455a44b7e29b3e06",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
"spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
|
|
219
219
|
"spec/v2/README.md": "c58acd84bb67c69043d9f777cbaa16d03e4f0f0a8158d2984526b69981067a14",
|
|
220
220
|
"spec/v2/core/artifact-type-packs.md": "ba5a258ac93b1aa4d74a4464c63a8f72dbbc5d24fd56f34a2c2ce9f464892799",
|
|
221
|
-
"spec/v2/core/capabilities.md": "
|
|
221
|
+
"spec/v2/core/capabilities.md": "2d94e45537693802032bd0750d05682f5a3f1e189fee2064f8fa969907d74271",
|
|
222
222
|
"spec/v2/core/conformance.md": "2e12b5fceb9cbd1bbf4a536310fe92a4cd44947e48d0466013327c5de202a2d9",
|
|
223
223
|
"spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
|
|
224
224
|
"spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
|
|
@@ -241,7 +241,7 @@
|
|
|
241
241
|
"spec/v2/core/portability.md": "93b9cb8bb85448669df716effb7627236b2bb069f8c5ca7306e8971b9355abfd",
|
|
242
242
|
"spec/v2/core/replay.md": "de705ad72e4878def9a0e1d215df9020c129eabae5f224b8c9b3330bd3b48c47",
|
|
243
243
|
"spec/v2/core/runs.md": "f9c26490e52fa69a10c8e98c8a489e3bc8dfbe3effe334bb1793bc9b575f5efe",
|
|
244
|
-
"spec/v2/core/security-defaults.md": "
|
|
244
|
+
"spec/v2/core/security-defaults.md": "3ca0c9f973232521536024165de8815f80ad9774df5e5210c8e5bfdd4373b670",
|
|
245
245
|
"spec/v2/core/storage.md": "bfcc74526d0f24539188b1c5d1567d8510e80d0b6c39fdc4d19204fcd6fd1f85",
|
|
246
246
|
"spec/v2/core/telemetry.md": "5e2f30919c51195b57566a2a277663e72dffd1a56a68c19fb8b0d3260629ff7f",
|
|
247
247
|
"spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
"spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
|
|
251
251
|
"spec/v2/corrections.json": "5ee76d1ee87677a86d5d9ee2f477b88fc29f9f9d37611040b9ccb08f8f30fb2d",
|
|
252
252
|
"spec/v2/corrections.schema.json": "02f6a0a9531e657693be311af0f59d6432f3efcf645f56ccbf39e5b141e6b641",
|
|
253
|
-
"spec/v2/declaration.json": "
|
|
253
|
+
"spec/v2/declaration.json": "2d6415ee3b9a801d6a7c4de5ec1ab4d62441dded7b5a44ceb42be80f0a258ce9",
|
|
254
254
|
"spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
|
|
255
255
|
"spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
|
|
256
256
|
"spec/v2/errors.json": "8b4d2ebab569b26f608323ca4c9adaf805ab515286d26532db23fcb97541787f",
|
|
@@ -309,9 +309,9 @@
|
|
|
309
309
|
"spec/v2/path-manifest.json": "3720e502614f301efc7556e0bfcbfa7b7ad8a37c579256ba6bca82417a2728f1",
|
|
310
310
|
"spec/v2/peer-dependency-aliases.json": "cbf62f551f9295b0ac7ba6cc0466abb153563354a57b0622f3ec6e6689cba47e",
|
|
311
311
|
"spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
|
|
312
|
-
"spec/v2/release.json": "
|
|
312
|
+
"spec/v2/release.json": "8dfa992e0c12d7efda3d0e1c6a63f66790c7c1b29fbe6bb5ae81ba7321f98c39",
|
|
313
313
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
314
|
-
"spec/v2/surface-baseline.json": "
|
|
314
|
+
"spec/v2/surface-baseline.json": "f1638b1acc7f7408f208c3ada6bd982ed61f9825a6e0e515fba9873808b48192"
|
|
315
315
|
},
|
|
316
|
-
"corpusCommit": "
|
|
316
|
+
"corpusCommit": "37c05c6cdf6b15dded360ad23455e066e2fc2b99"
|
|
317
317
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0265 — pure judges for `v2-compensation-unwind.test.ts`. The scenario runs
|
|
3
|
+
* the `conformance-compensate-unwind` fixture (three compensable effects, then a
|
|
4
|
+
* node that fails) and records the run's event log, its compensation projection
|
|
5
|
+
* and its effect ledger; these functions decide each leg from those reads, so
|
|
6
|
+
* the self-tests can fail every wrong host answer without a host.
|
|
7
|
+
*
|
|
8
|
+
* Attribution of effect-ledger rows. The ledger projection carries no
|
|
9
|
+
* forward/inverse marker (`schemas/v2/effect-ledger-projection.schema.json`:
|
|
10
|
+
* `effectId, nodeId, attempt, keying, state, at`), and a host may record an
|
|
11
|
+
* inverse under the forward node's id or under one of its own. So a row is
|
|
12
|
+
* attributed by elimination, using only what the fixture fixes:
|
|
13
|
+
* - the FORWARD record of each effect node is that node's earliest row by `at`
|
|
14
|
+
* (the forward runs once, deterministically, before the failure);
|
|
15
|
+
* - rows for the failing node are neither (it performs no effect);
|
|
16
|
+
* - every other row is an INVERSE record — the fixture has nothing else that
|
|
17
|
+
* could put a row on the ledger.
|
|
18
|
+
* A ledger with no row for some effect node never recorded its forward effect,
|
|
19
|
+
* so the inverse count is not measurable there (RFC 0265 risk R1: the host did
|
|
20
|
+
* not map the reserved node to a ledgered effect). That is `blocked`, never a
|
|
21
|
+
* failure.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0265-compensation-unwind-witness.md
|
|
24
|
+
* @see spec/v2/core/security-defaults.md §Compensation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { refiredAttempts, type EffectRow } from './effect-refire.js';
|
|
28
|
+
|
|
29
|
+
export interface Finding { readonly ok: boolean; readonly message: string }
|
|
30
|
+
export interface Ev { readonly type?: unknown; readonly nodeId?: unknown; readonly sequence?: unknown; readonly payload?: unknown }
|
|
31
|
+
export interface LedgerRow extends EffectRow { readonly effectId?: unknown; readonly state?: unknown }
|
|
32
|
+
export interface Attempt { readonly nodeId?: unknown; readonly attempt?: unknown; readonly outcome?: unknown; readonly at?: unknown }
|
|
33
|
+
export interface Projection { readonly status?: unknown; readonly plan?: unknown; readonly attempts?: unknown }
|
|
34
|
+
|
|
35
|
+
/** The fixture's three compensable effect nodes, in forward order, and its failing node. */
|
|
36
|
+
export const EFFECT_NODES: readonly string[] = ['e1', 'e2', 'e3'];
|
|
37
|
+
export const FAIL_NODE = 'fail';
|
|
38
|
+
|
|
39
|
+
type Rec = Record<string, unknown>;
|
|
40
|
+
const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
41
|
+
const f = (ok: boolean, message: string): Finding => ({ ok, message });
|
|
42
|
+
const seqOf = (e: Ev): number => (typeof e.sequence === 'number' ? e.sequence : Number.NaN);
|
|
43
|
+
const nodeOf = (e: Ev): unknown => e.nodeId ?? (isRec(e.payload) ? e.payload['nodeId'] : undefined);
|
|
44
|
+
const atOf = (r: LedgerRow): number => (typeof r.at === 'string' ? Date.parse(r.at) : Number.NaN);
|
|
45
|
+
const bySeq = (log: readonly Ev[]): Ev[] => log.filter((e) => Number.isFinite(seqOf(e))).sort((a, b) => seqOf(a) - seqOf(b));
|
|
46
|
+
|
|
47
|
+
/** Compensation events that end an unwind (asyncapi `compensation.*`). */
|
|
48
|
+
export const COMPENSATION_TERMINAL = new Set(['compensation.completed', 'compensation.failed', 'compensation.manual-intervention-required']);
|
|
49
|
+
/** Projection statuses an unwind can rest in (compensation-projection.schema.json). */
|
|
50
|
+
export const PROJECTION_SETTLED = new Set(['completed', 'partial', 'failed', 'manual-intervention']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The order the effect nodes FORWARD-completed: the first `node.completed` per
|
|
54
|
+
* node, by log sequence. An inverse executed under the same node id may emit a
|
|
55
|
+
* later `node.completed`; the first one is the forward.
|
|
56
|
+
*/
|
|
57
|
+
export function forwardCompletionOrder(log: readonly Ev[], nodes: readonly string[] = EFFECT_NODES): string[] {
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
const out: string[] = [];
|
|
60
|
+
for (const e of bySeq(log)) {
|
|
61
|
+
const n = nodeOf(e);
|
|
62
|
+
if (e.type === 'node.completed' && typeof n === 'string' && nodes.includes(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Leg 1 — `compensation.requested` precedes the first `compensation.started`, and both are present. */
|
|
68
|
+
export function judgePlanBeforeInverse(log: readonly Ev[]): Finding[] {
|
|
69
|
+
const ordered = bySeq(log);
|
|
70
|
+
const requested = ordered.find((e) => e.type === 'compensation.requested');
|
|
71
|
+
const started = ordered.find((e) => e.type === 'compensation.started');
|
|
72
|
+
return [
|
|
73
|
+
f(started !== undefined, 'the fixture commits three effects and then fails on a `node-failure` trigger, so an unwind is owed: the log MUST hold `compensation.started` — it holds none, so no inverse action ever began'),
|
|
74
|
+
f(requested !== undefined, 'the log MUST hold `compensation.requested` (the plan is durable) before any inverse action executes — it holds none'),
|
|
75
|
+
f(requested !== undefined && started !== undefined && seqOf(requested) < seqOf(started),
|
|
76
|
+
`\`compensation.started\` MUST follow \`compensation.requested\` (asyncapi): requested at sequence ${requested === undefined ? 'none' : seqOf(requested)}, first started at ${started === undefined ? 'none' : seqOf(started)}`),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Leg 2 — the projection's `attempts[]` names the effect nodes in the reverse of
|
|
82
|
+
* their forward completion order. Judged on the array order of each node's
|
|
83
|
+
* first attempt; a retried inverse (a second attempt for the same node) does
|
|
84
|
+
* not reorder the plan.
|
|
85
|
+
*/
|
|
86
|
+
export function judgeReverseCompletion(forwardOrder: readonly string[], attempts: readonly Attempt[], nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
87
|
+
const expected = [...forwardOrder].reverse();
|
|
88
|
+
const actual: string[] = [];
|
|
89
|
+
for (const a of attempts) {
|
|
90
|
+
if (typeof a.nodeId === 'string' && nodes.includes(a.nodeId) && !actual.includes(a.nodeId)) actual.push(a.nodeId);
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
f(actual.length === nodes.length, `attempts[] MUST name every compensable node (${nodes.join(', ')}) — it names ${actual.length === 0 ? 'none' : actual.join(', ')}`),
|
|
94
|
+
f(JSON.stringify(actual) === JSON.stringify(expected), `orderingModel reverse-completion: attempts[] MUST run in the reverse of forward completion — forward completed ${forwardOrder.join(' → ')}, so attempts MUST read ${expected.join(', ')}; they read ${actual.join(', ') || '(none)'}`),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LedgerSplit {
|
|
99
|
+
/** Effect nodes with no ledger row at all (forward effect not recorded). */
|
|
100
|
+
readonly missingForward: readonly string[];
|
|
101
|
+
readonly forward: ReadonlyMap<string, LedgerRow>;
|
|
102
|
+
readonly inverse: readonly LedgerRow[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Attribute ledger rows to forward and inverse records (see the module comment). */
|
|
106
|
+
export function splitLedger(rows: readonly LedgerRow[], nodes: readonly string[] = EFFECT_NODES, failNode: string = FAIL_NODE): LedgerSplit {
|
|
107
|
+
const forward = new Map<string, LedgerRow>();
|
|
108
|
+
for (const n of nodes) {
|
|
109
|
+
const mine = rows.filter((r) => r.nodeId === n).sort((a, b) => atOf(a) - atOf(b));
|
|
110
|
+
const first = mine[0];
|
|
111
|
+
if (first !== undefined) forward.set(n, first);
|
|
112
|
+
}
|
|
113
|
+
const forwardRows = new Set<LedgerRow>(forward.values());
|
|
114
|
+
return {
|
|
115
|
+
missingForward: nodes.filter((n) => !forward.has(n)),
|
|
116
|
+
forward,
|
|
117
|
+
inverse: rows.filter((r) => !forwardRows.has(r) && r.nodeId !== failNode),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Leg 3 — exactly one inverse record per forward effect, each performed once.
|
|
123
|
+
* Call only when `split.missingForward` is empty (otherwise the leg is blocked).
|
|
124
|
+
* A second record for one inverse (an unstable identity, or a re-execution)
|
|
125
|
+
* shows as a fourth row or a repeated effect; both fail.
|
|
126
|
+
*/
|
|
127
|
+
export function judgeInverseOnce(split: LedgerSplit, nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
128
|
+
const inv = split.inverse;
|
|
129
|
+
const ids = new Set(inv.map((r) => String(r.effectId)));
|
|
130
|
+
const out: Finding[] = [
|
|
131
|
+
f(inv.length === nodes.length, `the effect ledger MUST hold exactly ${nodes.length} inverse records, one per forward effect — it holds ${inv.length} beyond the forward records: ${JSON.stringify(inv).slice(0, 300)}`),
|
|
132
|
+
f(ids.size === inv.length, `each inverse effect MUST be performed once — ${inv.length} inverse record(s) carry ${ids.size} distinct effectId(s), so an inverse was recorded more than once`),
|
|
133
|
+
];
|
|
134
|
+
// Where the host records inverses under the forward node's id, the per-node
|
|
135
|
+
// distribution is observable too; under its own ids it is not, and the count
|
|
136
|
+
// above is the whole witness.
|
|
137
|
+
const attributable = inv.filter((r) => typeof r.nodeId === 'string' && nodes.includes(r.nodeId));
|
|
138
|
+
if (attributable.length === inv.length && inv.length > 0) {
|
|
139
|
+
for (const n of nodes) {
|
|
140
|
+
const c = attributable.filter((r) => r.nodeId === n).length;
|
|
141
|
+
out.push(f(c === 1, `node ${n}'s forward effect MUST have exactly one inverse record — it has ${c}`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Leg 4 — the projection agrees with the log: `completed` iff `compensation.completed`; `plan[]` names the effect nodes. */
|
|
148
|
+
export function judgeProjectionAgrees(projection: Projection, log: readonly Ev[], nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
149
|
+
const logged = log.some((e) => e.type === 'compensation.completed');
|
|
150
|
+
const status = projection.status;
|
|
151
|
+
const plan = Array.isArray(projection.plan) ? (projection.plan as unknown[]) : [];
|
|
152
|
+
const planned = [...new Set(plan.map((p) => (isRec(p) ? p['nodeId'] : undefined)).filter((n): n is string => typeof n === 'string'))].sort();
|
|
153
|
+
const want = [...nodes].sort();
|
|
154
|
+
return [
|
|
155
|
+
f((status === 'completed') === logged, `the projection's status MUST be "completed" exactly when the log holds compensation.completed — status is ${JSON.stringify(status)}, the log ${logged ? 'holds' : 'does not hold'} compensation.completed`),
|
|
156
|
+
f(JSON.stringify(planned) === JSON.stringify(want), `plan[] MUST name the three compensable nodes (${want.join(', ')}) — it names ${planned.length === 0 ? 'none' : planned.join(', ')}`),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Leg 5 — the records a replay fork's ledger holds that the parent's never did.
|
|
162
|
+
* Every such row is newly performed work, and on a replay fork of this fixture
|
|
163
|
+
* that can only be a re-fired forward or a re-fired inverse. The two are not
|
|
164
|
+
* separable on the wire: a fork ledger keyed per run, re-firing only the
|
|
165
|
+
* inverses under the forward nodes' ids, looks exactly like one that re-fired
|
|
166
|
+
* the forwards. So the judge counts every fresh row; a re-fired forward breaks
|
|
167
|
+
* the same suppression rule (replay.md §Suppression rule 1) and is not a pass
|
|
168
|
+
* either. `refiredAttempts` treats a fork row that repeats a parent attempt's
|
|
169
|
+
* `(nodeId, attempt, at)` as inherited history.
|
|
170
|
+
*/
|
|
171
|
+
export function refiredOnFork(parent: readonly LedgerRow[], fork: readonly LedgerRow[]): LedgerRow[] {
|
|
172
|
+
return refiredAttempts(parent, fork);
|
|
173
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — compensation behavior, witnessed through a failing fixture (RFC 0265;
|
|
3
|
+
* the rules are `spec/v2/core/security-defaults.md` §Compensation, the
|
|
4
|
+
* `compensation.*` events in `api/v2/asyncapi.yaml` and
|
|
5
|
+
* `schemas/v2/compensation-projection.schema.json`).
|
|
6
|
+
*
|
|
7
|
+
* `v2-compensation-read-projection` reads the projection on a run with nothing
|
|
8
|
+
* to unwind. This scenario makes a host unwind. The `conformance-compensate-unwind`
|
|
9
|
+
* fixture runs three compensable effects (`e1 → e2 → e3`, the conformance-RESERVED
|
|
10
|
+
* `core.conformance.compensable-effect`) and then a node that fails, under a
|
|
11
|
+
* `reverse-completion` policy triggered on `node-failure`. The suite causes that
|
|
12
|
+
* condition unaided and reads the result through normative paths only — the
|
|
13
|
+
* run's event log, `GET /runs/{runId}/compensation` and `GET /runs/{runId}/effects`.
|
|
14
|
+
* No seam is involved: the v1 legs (`compensation-behavior`, `compensation-recovery`)
|
|
15
|
+
* asked a host seam how many effects re-fired, and a host that never compensates
|
|
16
|
+
* can answer 0.
|
|
17
|
+
*
|
|
18
|
+
* One run is shared by every leg (a module-level lazy promise); judges are in
|
|
19
|
+
* `lib/compensation-unwind.ts`.
|
|
20
|
+
* 1. `0265.plan-before-inverse` — `compensation.requested` precedes the first
|
|
21
|
+
* `compensation.started` in the log (asyncapi: "MUST follow").
|
|
22
|
+
* 2. `0265.reverse-completion` — the projection's `attempts[]` names `e3, e2, e1`,
|
|
23
|
+
* the reverse of the forward `node.completed` order.
|
|
24
|
+
* 3. `0265.inverse-once` — the effect ledger holds exactly three inverse
|
|
25
|
+
* records, one per forward effect, each performed once.
|
|
26
|
+
* 4. `0265.projection-agrees` — the projection's `status` is `completed` exactly
|
|
27
|
+
* when the log holds `compensation.completed`, and `plan[]` names the three
|
|
28
|
+
* effect nodes.
|
|
29
|
+
* 5. `0265.replay-no-refire` — a `mode: "replay"` fork adds no newly performed
|
|
30
|
+
* effect to the fork's ledger (invariant `compensation-replay-no-refire`).
|
|
31
|
+
*
|
|
32
|
+
* Dispositions: `compensation` not advertised, or the fixture not listed ⇒
|
|
33
|
+
* `inapplicable` (every leg). Legs 3 and 5 also need the effect ledger, which
|
|
34
|
+
* idempotency.md §Witness owes only under `idempotency`; leg 5 needs
|
|
35
|
+
* `replay.modes ∋ replay` — absent ⇒ `inapplicable`. A run that cannot be
|
|
36
|
+
* created or finished, a fixture whose effects did not all complete or whose
|
|
37
|
+
* run did not fail, an unreadable projection or ledger, or a ledger with no
|
|
38
|
+
* forward record for an effect node (RFC 0265 R1: the host did not map the
|
|
39
|
+
* reserved node to a ledgered effect) ⇒ `blocked`.
|
|
40
|
+
*
|
|
41
|
+
* @see RFCS/0265-compensation-unwind-witness.md
|
|
42
|
+
* @see spec/v2/core/security-defaults.md §Compensation
|
|
43
|
+
* @see spec/v2/core/replay.md §Suppression
|
|
44
|
+
* @see conformance/fixtures.md `conformance-compensate-unwind`
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import { describe, it, expect } from 'vitest';
|
|
48
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
49
|
+
import { v2Discovery, gateFamily, familyAdvertised } from '../lib/v2.js';
|
|
50
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
51
|
+
import { projectBoundId } from '../lib/bound-id.js';
|
|
52
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
53
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
54
|
+
import { req } from '../lib/requirement-ids.js';
|
|
55
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
56
|
+
import {
|
|
57
|
+
EFFECT_NODES, COMPENSATION_TERMINAL,
|
|
58
|
+
forwardCompletionOrder, judgePlanBeforeInverse, judgeReverseCompletion, splitLedger,
|
|
59
|
+
judgeInverseOnce, judgeProjectionAgrees, refiredOnFork,
|
|
60
|
+
type Attempt, type Ev, type Finding, type LedgerRow, type Projection,
|
|
61
|
+
} from '../lib/compensation-unwind.js';
|
|
62
|
+
|
|
63
|
+
const FIXTURE = 'conformance-compensate-unwind';
|
|
64
|
+
const DOC = 'security-defaults.md §Compensation; asyncapi compensation.*; compensation-projection.schema.json (RFC 0265)';
|
|
65
|
+
const ID_PLAN = 'openwop.requirement.0265.plan-before-inverse';
|
|
66
|
+
const ID_ORDER = 'openwop.requirement.0265.reverse-completion';
|
|
67
|
+
const ID_ONCE = 'openwop.requirement.0265.inverse-once';
|
|
68
|
+
const ID_AGREES = 'openwop.requirement.0265.projection-agrees';
|
|
69
|
+
const ID_REPLAY = 'openwop.requirement.0265.replay-no-refire';
|
|
70
|
+
const RUN_TERMINAL = /^run\.(completed|failed|cancelled)$/;
|
|
71
|
+
|
|
72
|
+
type Skip = readonly ['inapplicable' | 'blocked', string];
|
|
73
|
+
type Rec = Record<string, unknown>;
|
|
74
|
+
const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
75
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
|
|
76
|
+
const assertAll = (id: string, fs: readonly Finding[]): void => { for (const x of fs) expect(x.ok, req(id, DOC, x.message)).toBe(true); };
|
|
77
|
+
const seqOf = (e: Ev): number => (typeof e.sequence === 'number' ? e.sequence : Number.NaN);
|
|
78
|
+
|
|
79
|
+
async function logOf(runId: string): Promise<Ev[]> {
|
|
80
|
+
const res = await http(() => driver.get(`/runs/${projectBoundId(runId)}/events/poll?timeout=1`));
|
|
81
|
+
const body = res?.json;
|
|
82
|
+
const events = isRec(body) ? body['events'] : undefined;
|
|
83
|
+
return Array.isArray(events) ? (events as Ev[]).filter((e) => Number.isFinite(seqOf(e))).sort((a, b) => seqOf(a) - seqOf(b)) : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Poll until the run has a terminal run event and, if an unwind began (or, for
|
|
88
|
+
* the source run, is owed), a compensation terminal event. The compensation
|
|
89
|
+
* tail MAY follow the run's terminal event (replay.md §Refusals), so the run
|
|
90
|
+
* event alone is not the end. On the deadline the log is returned as it stands:
|
|
91
|
+
* an unwind that never finishes is for the legs to judge, not a reason to skip.
|
|
92
|
+
*/
|
|
93
|
+
async function settle(runId: string, unwindOwed: boolean): Promise<{ log: Ev[] } | { reason: string }> {
|
|
94
|
+
const deadline = Date.now() + scaledTimeoutMs(30_000);
|
|
95
|
+
for (;;) {
|
|
96
|
+
const log = await logOf(runId);
|
|
97
|
+
const ended = log.some((e) => typeof e.type === 'string' && RUN_TERMINAL.test(e.type));
|
|
98
|
+
const begun = unwindOwed || log.some((e) => e.type === 'compensation.requested' || e.type === 'compensation.started');
|
|
99
|
+
const unwound = log.some((e) => typeof e.type === 'string' && COMPENSATION_TERMINAL.has(e.type));
|
|
100
|
+
if (ended && (!begun || unwound)) return { log };
|
|
101
|
+
if (Date.now() > deadline) return ended ? { log } : { reason: `run ${runId} logged no terminal run event within the bound (${log.length} event(s))` };
|
|
102
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let disc: Promise<Rec | null> | undefined;
|
|
107
|
+
const discovery = (): Promise<Rec | null> => (disc ??= v2Discovery().catch(() => null));
|
|
108
|
+
|
|
109
|
+
/** The two gates every leg shares: the `compensation` record and the fixture. */
|
|
110
|
+
async function gate(): Promise<Skip | null> {
|
|
111
|
+
if (!(await discovery())) return ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'];
|
|
112
|
+
if (!(await gateFamily('compensation'))) return ['inapplicable', 'compensation family not advertised — no obligation (gate recorded under openwop.family.compensation)'];
|
|
113
|
+
if (!isFixtureAdvertised(FIXTURE)) return ['inapplicable', `the host does not advertise fixture ${FIXTURE} (serving it is OPTIONAL, RFC 0265 §A)`];
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
async function ledgerGate(): Promise<Skip | null> {
|
|
117
|
+
if (!(await familyAdvertised('idempotency'))) return ['inapplicable', 'the host does not advertise idempotency, so it owes no effect ledger (idempotency.md §Witness) and the inverse records cannot be read'];
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Unwind { readonly runId: string; readonly log: Ev[]; readonly projection: OpenWOPResponse | null }
|
|
122
|
+
|
|
123
|
+
/** The shared source run: start the fixture, wait out the run and its unwind, read the projection. Asserts nothing. */
|
|
124
|
+
async function drive(): Promise<{ skip: Skip } | { u: Unwind }> {
|
|
125
|
+
const created = await http(() => driver.post('/runs', { workflowId: FIXTURE }));
|
|
126
|
+
const createdBody = created?.json;
|
|
127
|
+
const runId = isRec(createdBody) ? createdBody['runId'] : undefined;
|
|
128
|
+
if (created === null || created.status !== 201 || typeof runId !== 'string') return { skip: ['blocked', `POST /runs {workflowId: ${FIXTURE}} answered ${created?.status ?? 'nothing'} ${readErrorCode(created?.json) ?? ''}`.trim()] };
|
|
129
|
+
const s = await settle(runId, true);
|
|
130
|
+
if ('reason' in s) return { skip: ['blocked', s.reason] };
|
|
131
|
+
if (!s.log.some((e) => e.type === 'run.failed')) {
|
|
132
|
+
return { skip: ['blocked', `the ${FIXTURE} run did not end run.failed (its terminal node is core.fail), so the condition an unwind exists for was not caused — the host does not serve the fixture as fixtures.md specifies`] };
|
|
133
|
+
}
|
|
134
|
+
const projection = await http(() => driver.get(`/runs/${projectBoundId(runId)}/compensation`));
|
|
135
|
+
return { u: { runId, log: s.log, projection } };
|
|
136
|
+
}
|
|
137
|
+
let source: Promise<{ skip: Skip } | { u: Unwind }> | undefined;
|
|
138
|
+
const once = (): Promise<{ skip: Skip } | { u: Unwind }> => (source ??= drive());
|
|
139
|
+
|
|
140
|
+
/** The shared source run's effect ledger, read once after the unwind. */
|
|
141
|
+
let ledger: Promise<{ skip: Skip } | { rows: LedgerRow[] }> | undefined;
|
|
142
|
+
const sourceLedger = (runId: string): Promise<{ skip: Skip } | { rows: LedgerRow[] }> => (ledger ??= (async () => {
|
|
143
|
+
const res = await http(() => driver.get(`/runs/${projectBoundId(runId)}/effects`));
|
|
144
|
+
const body = res?.json;
|
|
145
|
+
const effects = isRec(body) ? body['effects'] : undefined;
|
|
146
|
+
if (res === null || res.status !== 200 || !Array.isArray(effects)) {
|
|
147
|
+
return { skip: ['blocked', `GET /runs/{runId}/effects answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — idempotency.md §Witness owns that contract`.trim()] as const };
|
|
148
|
+
}
|
|
149
|
+
return { rows: effects as LedgerRow[] };
|
|
150
|
+
})());
|
|
151
|
+
|
|
152
|
+
function projectionOf(u: Unwind): { body: Projection } | { skip: Skip } {
|
|
153
|
+
const p = u.projection;
|
|
154
|
+
const body = p?.json;
|
|
155
|
+
if (p === null || p.status !== 200 || !isRec(body)) {
|
|
156
|
+
return { skip: ['blocked', `GET /runs/{runId}/compensation answered ${p?.status ?? 'nothing'} ${readErrorCode(body) ?? ''} on the unwound run — v2-compensation-read-projection owns that obligation`.trim()] };
|
|
157
|
+
}
|
|
158
|
+
return { body };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('v2 RFC 0265 — compensation unwind through a failing fixture (host)', () => {
|
|
162
|
+
it('compensation.requested precedes the first compensation.started', async () => {
|
|
163
|
+
const g = await gate();
|
|
164
|
+
if (g) return softSkip(...g);
|
|
165
|
+
const s = await once();
|
|
166
|
+
if ('skip' in s) return softSkip(...s.skip);
|
|
167
|
+
assertAll(ID_PLAN, judgePlanBeforeInverse(s.u.log));
|
|
168
|
+
}, 120_000);
|
|
169
|
+
|
|
170
|
+
it('the projection attempts the effect nodes in the reverse of their forward completion order', async () => {
|
|
171
|
+
const g = await gate();
|
|
172
|
+
if (g) return softSkip(...g);
|
|
173
|
+
const s = await once();
|
|
174
|
+
if ('skip' in s) return softSkip(...s.skip);
|
|
175
|
+
const forward = forwardCompletionOrder(s.u.log);
|
|
176
|
+
if (forward.length !== EFFECT_NODES.length) {
|
|
177
|
+
return softSkip('blocked', `the log holds a forward node.completed for ${forward.join(', ') || 'none'} of ${EFFECT_NODES.join(', ')}, so there is no forward completion order to reverse`);
|
|
178
|
+
}
|
|
179
|
+
const p = projectionOf(s.u);
|
|
180
|
+
if ('skip' in p) return softSkip(...p.skip);
|
|
181
|
+
const attempts = Array.isArray(p.body.attempts) ? (p.body.attempts as Attempt[]) : [];
|
|
182
|
+
assertAll(ID_ORDER, judgeReverseCompletion(forward, attempts));
|
|
183
|
+
}, 120_000);
|
|
184
|
+
|
|
185
|
+
it('the effect ledger holds exactly one inverse record per forward effect', async () => {
|
|
186
|
+
const g = (await gate()) ?? (await ledgerGate());
|
|
187
|
+
if (g) return softSkip(...g);
|
|
188
|
+
const s = await once();
|
|
189
|
+
if ('skip' in s) return softSkip(...s.skip);
|
|
190
|
+
const l = await sourceLedger(s.u.runId);
|
|
191
|
+
if ('skip' in l) return softSkip(...l.skip);
|
|
192
|
+
const split = splitLedger(l.rows);
|
|
193
|
+
if (split.missingForward.length > 0) {
|
|
194
|
+
return softSkip('blocked', `the effect ledger has no record for ${split.missingForward.join(', ')}: the forward effects are absent too, so the host did not map core.conformance.compensable-effect to a ledgered effect (RFC 0265 §A, risk R1) and the inverse count cannot be measured`);
|
|
195
|
+
}
|
|
196
|
+
assertAll(ID_ONCE, judgeInverseOnce(split));
|
|
197
|
+
}, 120_000);
|
|
198
|
+
|
|
199
|
+
it('the projection status and plan agree with the event log', async () => {
|
|
200
|
+
const g = await gate();
|
|
201
|
+
if (g) return softSkip(...g);
|
|
202
|
+
const s = await once();
|
|
203
|
+
if ('skip' in s) return softSkip(...s.skip);
|
|
204
|
+
const p = projectionOf(s.u);
|
|
205
|
+
if ('skip' in p) return softSkip(...p.skip);
|
|
206
|
+
assertAll(ID_AGREES, judgeProjectionAgrees(p.body, s.u.log));
|
|
207
|
+
}, 120_000);
|
|
208
|
+
|
|
209
|
+
it('a replay fork of the unwound run performs no inverse again', async () => {
|
|
210
|
+
const g = (await gate()) ?? (await ledgerGate());
|
|
211
|
+
if (g) return softSkip(...g);
|
|
212
|
+
const replay = await familyAdvertised('replay');
|
|
213
|
+
if (!replay || !(Array.isArray(replay['modes']) && replay['modes'].includes('replay'))) return softSkip('inapplicable', 'replay.modes does not list replay, so no replay fork can be requested');
|
|
214
|
+
const s = await once();
|
|
215
|
+
if ('skip' in s) return softSkip(...s.skip);
|
|
216
|
+
const l = await sourceLedger(s.u.runId);
|
|
217
|
+
if ('skip' in l) return softSkip(...l.skip);
|
|
218
|
+
const split = splitLedger(l.rows);
|
|
219
|
+
if (split.missingForward.length > 0 || split.inverse.length === 0) {
|
|
220
|
+
return softSkip('blocked', `the source run's ledger records ${split.missingForward.length > 0 ? `no forward effect for ${split.missingForward.join(', ')}` : 'no inverse effect'}, so a fork that also records none would compare nothing against nothing (RFC 0265 R1)`);
|
|
221
|
+
}
|
|
222
|
+
const first = s.u.log[0];
|
|
223
|
+
if (first === undefined) return softSkip('blocked', 'the source run logged no event to fork from');
|
|
224
|
+
const forked = await http(() => driver.post(`/runs/${projectBoundId(s.u.runId)}:fork`, { mode: 'replay', fromSeq: first.sequence }));
|
|
225
|
+
const forkedBody = forked?.json;
|
|
226
|
+
const forkId = isRec(forkedBody) ? forkedBody['runId'] : undefined;
|
|
227
|
+
if (forked === null || forked.status !== 201 || typeof forkId !== 'string') {
|
|
228
|
+
return softSkip('blocked', `POST /runs/{runId}:fork {mode: replay} answered ${forked?.status ?? 'nothing'} ${readErrorCode(forked?.json) ?? ''} on the unwound run`.trim());
|
|
229
|
+
}
|
|
230
|
+
const settled = await settle(forkId, false);
|
|
231
|
+
if ('reason' in settled) return softSkip('blocked', settled.reason);
|
|
232
|
+
const forkLedger = await http(() => driver.get(`/runs/${projectBoundId(forkId)}/effects`));
|
|
233
|
+
const forkLedgerBody = forkLedger?.json;
|
|
234
|
+
const forkRows = isRec(forkLedgerBody) ? forkLedgerBody['effects'] : undefined;
|
|
235
|
+
if (forkLedger === null || forkLedger.status !== 200 || !Array.isArray(forkRows)) {
|
|
236
|
+
return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkLedger?.status ?? 'nothing'} on the replay fork`);
|
|
237
|
+
}
|
|
238
|
+
const refired = refiredOnFork(l.rows, forkRows as LedgerRow[]);
|
|
239
|
+
expect(
|
|
240
|
+
refired.length,
|
|
241
|
+
req(ID_REPLAY, 'replay.md §Suppression; invariant compensation-replay-no-refire (RFC 0265 §B.5)', `a mode: "replay" fork MUST NOT perform an inverse action again — the fork's effect ledger records ${refired.length} effect(s) the source run never made: ${JSON.stringify(refired).slice(0, 300)}`),
|
|
242
|
+
).toBe(0);
|
|
243
|
+
}, 180_000);
|
|
244
|
+
});
|