@openwop/openwop-conformance 2.2.0 → 2.2.3
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 +78 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-edit-accept.json +45 -0
- package/fixtures.md +14 -0
- package/package.json +2 -2
- package/requirements.json +74 -10
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +15 -15
- package/src/scenarios/interrupt-approval.test.ts +80 -0
- package/src/scenarios/replay-fanout-suppression.test.ts +52 -4
- package/src/scenarios/v2-payload-vendor-hatch.test.ts +87 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,83 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.2.3] — 2026-09-16 — a payload def that v1 left open gets a conforming escape hatch
|
|
4
|
+
|
|
5
|
+
**Why a patch.** `PUBLISHING.md` §"Versioning alignment": one scenario file is
|
|
6
|
+
added, which is a minor — but `v2-payload-vendor-hatch.test.ts` is server-free
|
|
7
|
+
and additive to a corpus rule, and no existing scenario changes. Cut as a patch
|
|
8
|
+
alongside the spec edit it witnesses.
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`v2-payload-vendor-hatch.test.ts`** (server-free) — a def v1 left open
|
|
13
|
+
admits `vendor.*` / `x-` / `openwop-` keys; still **refuses** a bare
|
|
14
|
+
unmodelled key, because the hatch is an escape valve rather than a reopening;
|
|
15
|
+
and a vendor key nested inside `refineFeedback` is **refused**, since RFC 0183
|
|
16
|
+
modelled that object closed on purpose and it was never open in v1. That third
|
|
17
|
+
leg is the load-bearing one: it fails if a later edit sprays
|
|
18
|
+
`patternProperties` down the tree instead of applying it to the 53 narrowed
|
|
19
|
+
defs. Sabotage-proved both ways.
|
|
20
|
+
|
|
21
|
+
## [2.2.2] — 2026-09-16 — a registry tree that says "read-only" when it means "deliberately behind"
|
|
22
|
+
|
|
23
|
+
**Why a patch.** `PUBLISHING.md` §"Versioning alignment": no scenario file is
|
|
24
|
+
added or removed; this is spec prose plus packaging metadata.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`spec/v2/core/packs.md` §The registry tree** now says the v1 tree is
|
|
29
|
+
*"frozen through the overlap, deliberately behind this one"* rather than
|
|
30
|
+
*"served read-only through the overlap."* A tier-1 host operator read
|
|
31
|
+
"read-only" as *current, just not accepting writes*, resolved
|
|
32
|
+
`packs.openwop.dev/v1/index.json` from a major-2 host, and reported 154 of 156
|
|
33
|
+
packs drifted; a second session reproduced and confirmed it. Against
|
|
34
|
+
`registry/v2/` the drift was **zero**. The MUST that prevents this was already
|
|
35
|
+
in the next sentence — a client MUST resolve registry paths through
|
|
36
|
+
`endpoints` rather than construct one — but nothing said that constructing the
|
|
37
|
+
v1 path returns deliberately-older versions instead of failing.
|
|
38
|
+
- **`package-lock.json`** pinned the `@openwop/spec-artifacts` peer at
|
|
39
|
+
`2.0.0-rc.0` while `package.json` tracked the current release. Consumers
|
|
40
|
+
installing from the lockfile resolved a peer range two majors' worth of
|
|
41
|
+
prereleases behind the one the package declares.
|
|
42
|
+
|
|
43
|
+
## [2.2.1] — 2026-09-16 — RFC 0183's `edit-accept` arm is witnessed, and the MUST the suite already enforced is now the spec's
|
|
44
|
+
|
|
45
|
+
**Why a patch.** `PUBLISHING.md` §"Versioning alignment": new `it` blocks in an
|
|
46
|
+
existing scenario file are a patch; no scenario file is added.
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`interrupt-approval.test.ts` witnesses the `edit-accept` arm of RFC 0183
|
|
51
|
+
§A.2** — two legs on a new `conformance-approval-edit-accept` fixture: an
|
|
52
|
+
edit-accept resolve MUST round-trip `action: "edit-accept"` and the
|
|
53
|
+
`editedArtifactData` supplied, and an edit-accept resolve with no
|
|
54
|
+
`editedArtifactData` MUST be refused. Until now the refine arm was witnessed
|
|
55
|
+
and the edit-accept arm was stated in `spec/v2/core/interrupt.md` §Approval and
|
|
56
|
+
measured nowhere. Gated on the fixture being advertised, like the refine block;
|
|
57
|
+
a host that does not serve the fixture skips rather than fails.
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
- **`replay-fanout-suppression` declared ~77.5 s of poll budget inside vitest's
|
|
62
|
+
30 s wall** (openwop #1355). The second and third poll ceilings were
|
|
63
|
+
unreachable, `OPENWOP_POLL_TIMEOUT_SCALE` could not extend the wait (the
|
|
64
|
+
header claimed the opposite), and a slow host and a host that re-fired
|
|
65
|
+
fan-out on a replay fork failed with the same generic vitest timeout. The
|
|
66
|
+
test's wall is now derived from its own scaled budgets plus a 15 s margin,
|
|
67
|
+
so every declared ceiling is reachable and a failure names the predicate. No
|
|
68
|
+
assertion changed.
|
|
69
|
+
|
|
70
|
+
### Changed (spec side, no assertion change)
|
|
71
|
+
|
|
72
|
+
- **The `payload.action` assertion in the refine leg is no longer stricter than
|
|
73
|
+
the spec.** It asserted a MUST from 2.2.0 while RFC 0183 §A.1 said SHOULD —
|
|
74
|
+
a `COMPATIBILITY.md` §2.3 violation (a suite MUST NOT be stricter than the spec
|
|
75
|
+
on wire shape). The spec was raised, not the assertion lowered: RFC 0183 is now
|
|
76
|
+
`Active` and §A.1 is a MUST conditioned on approval kind, with its normative
|
|
77
|
+
home in `spec/v2/core/interrupt.md` §Events. RFC 0184 is `Active` too; suite
|
|
78
|
+
2.2.0 had shipped both RFCs' legs enforced against `Draft` headers, and the
|
|
79
|
+
corpus gate now refuses that (`check-rfc-status-coherence` rule 7).
|
|
80
|
+
|
|
3
81
|
## [2.2.0] — 2026-09-16 — the suite wipes the host's mock program store between scenario files, and a bound id travels as one segment
|
|
4
82
|
|
|
5
83
|
**Why a minor and not the 2.1.8 that was pinned for two days.** `PUBLISHING.md`
|
package/README.md
CHANGED
|
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
117
117
|
|
|
118
118
|
## What's Covered
|
|
119
119
|
|
|
120
|
-
The current suite has
|
|
120
|
+
The current suite has 519 scenario files under `src/scenarios/`.
|
|
121
121
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
122
122
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
123
123
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
|
|
|
462
462
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
463
463
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
464
464
|
|
|
465
|
-
Current source tree:
|
|
465
|
+
Current source tree: 519 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
466
466
|
|
|
467
467
|
## Remaining Gaps
|
|
468
468
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-approval-edit-accept",
|
|
3
|
+
"name": "Conformance: Approval (edit-accept)",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "Suspends on an approval gate offering accept|reject|edit-accept. Resume with {action:'edit-accept', editedArtifactData:{...}} exercises RFC 0183 \u00a7A.2's edit-accept arm \u2014 the resolved payload must carry the action and the edited artifact data.",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{
|
|
8
|
+
"id": "gate",
|
|
9
|
+
"typeId": "core.approvalGate",
|
|
10
|
+
"name": "Approval Gate",
|
|
11
|
+
"position": {
|
|
12
|
+
"x": 0,
|
|
13
|
+
"y": 0
|
|
14
|
+
},
|
|
15
|
+
"config": {
|
|
16
|
+
"title": "Conformance approval (edit-accept-capable)",
|
|
17
|
+
"description": "Conformance suite \u2014 please accept to complete the run.",
|
|
18
|
+
"actions": [
|
|
19
|
+
"accept",
|
|
20
|
+
"reject",
|
|
21
|
+
"edit-accept"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"inputs": {}
|
|
25
|
+
}
|
|
26
|
+
],
|
|
27
|
+
"edges": [],
|
|
28
|
+
"triggers": [
|
|
29
|
+
{
|
|
30
|
+
"id": "manual",
|
|
31
|
+
"type": "manual",
|
|
32
|
+
"enabled": true
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"variables": [],
|
|
36
|
+
"metadata": {
|
|
37
|
+
"tags": [
|
|
38
|
+
"conformance",
|
|
39
|
+
"hitl"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
"settings": {
|
|
43
|
+
"timeout": 0
|
|
44
|
+
}
|
|
45
|
+
}
|
package/fixtures.md
CHANGED
|
@@ -48,6 +48,7 @@ All fixtures MUST advertise:
|
|
|
48
48
|
| Failure | `conformance-failure` | Verifies error-event surface | `failed` | ≤ 5s |
|
|
49
49
|
| Approval | `conformance-approval` | Verifies HITL approval interrupt + resume | `completed` after resolve | unbounded (suspends) |
|
|
50
50
|
| Approval (refine) | `conformance-approval-refine` | RFC 0183 — refine resolution carries action + refineFeedback |
|
|
51
|
+
| Approval (edit-accept) | `conformance-approval-edit-accept` | RFC 0183 — edit-accept resolution carries action + editedArtifactData |
|
|
51
52
|
| Clarification | `conformance-clarification` | Verifies HITL clarification interrupt + resume | `completed` after resolve | unbounded (suspends) |
|
|
52
53
|
| Multi-node | `conformance-multi-node` | Verifies edge ordering + per-node events | `completed` | ≤ 10s |
|
|
53
54
|
| Idempotent | `conformance-idempotent` | Verifies `Idempotency-Key` cache | `completed` | ≤ 5s |
|
|
@@ -192,6 +193,18 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
|
|
|
192
193
|
registered workflow definition every host already serves, forcing a re-registration for a test-only widening.
|
|
193
194
|
A new id costs one catalog row and no host churn.
|
|
194
195
|
|
|
196
|
+
### `conformance-approval-edit-accept`
|
|
197
|
+
|
|
198
|
+
- **Purpose**: verify an `edit-accept` resolution round-trips its action and the edited artifact (RFC 0183 §A.2, the arm `conformance-approval-refine` does not reach).
|
|
199
|
+
- **Inputs**: none.
|
|
200
|
+
- **Behavior**:
|
|
201
|
+
1. Run starts and reaches an `approvalGate` offering `accept | reject | edit-accept`.
|
|
202
|
+
2. Run status MUST be `waiting-approval`.
|
|
203
|
+
3. Client POSTs `{action: 'edit-accept', editedArtifactData: {...}}` to the interrupt.
|
|
204
|
+
4. The resolved event payload MUST carry `action: 'edit-accept'` and the `editedArtifactData` supplied.
|
|
205
|
+
5. An `edit-accept` resolution supplying no `editedArtifactData` MUST be refused.
|
|
206
|
+
- **Why it is separate**: same reason as `conformance-approval-refine` — widening a registered fixture's `actions` forces every host to re-register a test-only change.
|
|
207
|
+
|
|
195
208
|
### `conformance-clarification`
|
|
196
209
|
|
|
197
210
|
- **Purpose**: verify HITL clarification interrupt + resume.
|
|
@@ -503,6 +516,7 @@ conformance/
|
|
|
503
516
|
conformance-failure.json
|
|
504
517
|
conformance-approval.json
|
|
505
518
|
conformance-approval-refine.json
|
|
519
|
+
conformance-approval-edit-accept.json
|
|
506
520
|
conformance-clarification.json
|
|
507
521
|
conformance-multi-node.json
|
|
508
522
|
conformance-idempotent.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
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.2.
|
|
59
|
+
"@openwop/spec-artifacts": "2.2.3"
|
|
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": 568,
|
|
6
|
+
"tests": 2162,
|
|
7
|
+
"withStableId": 2162,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
9
|
+
"explicitIds": 2117
|
|
10
10
|
},
|
|
11
11
|
"records": [
|
|
12
12
|
{
|
|
@@ -14544,7 +14544,7 @@
|
|
|
14544
14544
|
{
|
|
14545
14545
|
"id": "openwop.it.interrupt-approval.run-suspends-at-gate-accept-resolution-drives-terminal-completed",
|
|
14546
14546
|
"file": "interrupt-approval.test.ts",
|
|
14547
|
-
"line":
|
|
14547
|
+
"line": 27,
|
|
14548
14548
|
"title": "run suspends at gate, accept resolution drives terminal completed",
|
|
14549
14549
|
"explicitId": "openwop.it.interrupt-approval.run-suspends-at-gate-accept-resolution-drives-terminal-completed",
|
|
14550
14550
|
"citations": [
|
|
@@ -14565,7 +14565,7 @@
|
|
|
14565
14565
|
{
|
|
14566
14566
|
"id": "openwop.it.interrupt-approval.400-or-422-when-action-is-not-in-accept-reject",
|
|
14567
14567
|
"file": "interrupt-approval.test.ts",
|
|
14568
|
-
"line":
|
|
14568
|
+
"line": 56,
|
|
14569
14569
|
"title": "400 (or 422) when action is not in {accept, reject}",
|
|
14570
14570
|
"explicitId": "openwop.it.interrupt-approval.400-or-422-when-action-is-not-in-accept-reject",
|
|
14571
14571
|
"citations": [
|
|
@@ -14578,7 +14578,7 @@
|
|
|
14578
14578
|
{
|
|
14579
14579
|
"id": "openwop.it.interrupt-approval.400-404-when-nodeid-does-not-match-an-active-interrupt",
|
|
14580
14580
|
"file": "interrupt-approval.test.ts",
|
|
14581
|
-
"line":
|
|
14581
|
+
"line": 84,
|
|
14582
14582
|
"title": "400/404 when nodeId does not match an active interrupt",
|
|
14583
14583
|
"explicitId": "openwop.it.interrupt-approval.400-404-when-nodeid-does-not-match-an-active-interrupt",
|
|
14584
14584
|
"citations": [
|
|
@@ -14591,7 +14591,7 @@
|
|
|
14591
14591
|
{
|
|
14592
14592
|
"id": "openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires",
|
|
14593
14593
|
"file": "interrupt-approval.test.ts",
|
|
14594
|
-
"line":
|
|
14594
|
+
"line": 120,
|
|
14595
14595
|
"title": "a refine resolve round-trips `action` and the structured feedback it requires",
|
|
14596
14596
|
"explicitId": "openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires",
|
|
14597
14597
|
"citations": [
|
|
@@ -14616,7 +14616,7 @@
|
|
|
14616
14616
|
{
|
|
14617
14617
|
"id": "openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback",
|
|
14618
14618
|
"file": "interrupt-approval.test.ts",
|
|
14619
|
-
"line":
|
|
14619
|
+
"line": 170,
|
|
14620
14620
|
"title": "refuses a refine resolution that supplies no refineFeedback",
|
|
14621
14621
|
"explicitId": "openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback",
|
|
14622
14622
|
"citations": [
|
|
@@ -14626,6 +14626,44 @@
|
|
|
14626
14626
|
}
|
|
14627
14627
|
]
|
|
14628
14628
|
},
|
|
14629
|
+
{
|
|
14630
|
+
"id": "openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires",
|
|
14631
|
+
"file": "interrupt-approval.test.ts",
|
|
14632
|
+
"line": 197,
|
|
14633
|
+
"title": "an edit-accept resolve round-trips `action` and the edited artifact it requires",
|
|
14634
|
+
"explicitId": "openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires",
|
|
14635
|
+
"citations": [
|
|
14636
|
+
{
|
|
14637
|
+
"section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
|
|
14638
|
+
"requirement": "a gate offering `edit-accept` MUST accept an edit-accept resolution carrying editedArtifactData"
|
|
14639
|
+
},
|
|
14640
|
+
{
|
|
14641
|
+
"section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
|
|
14642
|
+
"requirement": "the resolution MUST be recorded as an event"
|
|
14643
|
+
},
|
|
14644
|
+
{
|
|
14645
|
+
"section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
|
|
14646
|
+
"requirement": "the resolved payload MUST carry `action: \"edit-accept\"`"
|
|
14647
|
+
},
|
|
14648
|
+
{
|
|
14649
|
+
"section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
|
|
14650
|
+
"requirement": "an edit-accept resolution MUST carry the editedArtifactData it was given"
|
|
14651
|
+
}
|
|
14652
|
+
]
|
|
14653
|
+
},
|
|
14654
|
+
{
|
|
14655
|
+
"id": "openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata",
|
|
14656
|
+
"file": "interrupt-approval.test.ts",
|
|
14657
|
+
"line": 247,
|
|
14658
|
+
"title": "refuses an edit-accept resolution that supplies no editedArtifactData",
|
|
14659
|
+
"explicitId": "openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata",
|
|
14660
|
+
"citations": [
|
|
14661
|
+
{
|
|
14662
|
+
"section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
|
|
14663
|
+
"requirement": "edit-accept without editedArtifactData MUST be refused — the edit is what the action means"
|
|
14664
|
+
}
|
|
14665
|
+
]
|
|
14666
|
+
},
|
|
14629
14667
|
{
|
|
14630
14668
|
"id": "openwop.it.interrupt-approver-routing.capabilities-schema-declares-interrupt-approverrouting",
|
|
14631
14669
|
"file": "interrupt-approver-routing.test.ts",
|
|
@@ -21658,7 +21696,7 @@
|
|
|
21658
21696
|
{
|
|
21659
21697
|
"id": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
|
|
21660
21698
|
"file": "replay-fanout-suppression.test.ts",
|
|
21661
|
-
"line":
|
|
21699
|
+
"line": 205,
|
|
21662
21700
|
"title": "delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork",
|
|
21663
21701
|
"explicitId": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
|
|
21664
21702
|
"citations": [
|
|
@@ -27682,6 +27720,32 @@
|
|
|
27682
27720
|
}
|
|
27683
27721
|
]
|
|
27684
27722
|
},
|
|
27723
|
+
{
|
|
27724
|
+
"id": "openwop.it.v2-payload-vendor-hatch.a-def-v1-left-open-carries-the-hatch-still-refuses-a-bare-key-and-a-deliberately",
|
|
27725
|
+
"file": "v2-payload-vendor-hatch.test.ts",
|
|
27726
|
+
"line": 52,
|
|
27727
|
+
"title": "a def v1 left open carries the hatch, still refuses a bare key, and a deliberately-closed def refuses both",
|
|
27728
|
+
"explicitId": "openwop.requirement.0185.payload-vendor-hatch",
|
|
27729
|
+
"citations": [
|
|
27730
|
+
{
|
|
27731
|
+
"section": "RFCS/0185-run-event-payload-closure.md §B",
|
|
27732
|
+
"requirement": null,
|
|
27733
|
+
"interpolated": true
|
|
27734
|
+
},
|
|
27735
|
+
{
|
|
27736
|
+
"section": "RFCS/0185-run-event-payload-closure.md §B",
|
|
27737
|
+
"requirement": "`variableChanged` MUST still REJECT a bare unmodelled key (`value`) — the hatch is an escape valve, not a reopening; `additionalProperties: false` stays"
|
|
27738
|
+
},
|
|
27739
|
+
{
|
|
27740
|
+
"section": "RFCS/0185-run-event-payload-closure.md §B",
|
|
27741
|
+
"requirement": "`interruptResolved` was open in v1, so its own hatch MUST admit a vendor key"
|
|
27742
|
+
},
|
|
27743
|
+
{
|
|
27744
|
+
"section": "RFCS/0185-run-event-payload-closure.md §B",
|
|
27745
|
+
"requirement": "a vendor key inside `refineFeedback` MUST be REFUSED — RFC 0183 modelled that object closed on purpose and it was never open in v1. The hatch belongs to the 53 defs that were narrowed, not to everything nested under them; this leg fails if a later edit sprays `patternProperties` down the tree"
|
|
27746
|
+
}
|
|
27747
|
+
]
|
|
27748
|
+
},
|
|
27685
27749
|
{
|
|
27686
27750
|
"id": "openwop.it.v2-peer-dependency-declared.a-peerdependencies-key-the-declaration-file-does-not-name-is-refused-with-pack-p",
|
|
27687
27751
|
"file": "v2-peer-dependency-declared.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": 519,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 76
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1361,6 +1361,9 @@
|
|
|
1361
1361
|
"v2-payload-registry-closed.test.ts": [
|
|
1362
1362
|
2
|
|
1363
1363
|
],
|
|
1364
|
+
"v2-payload-vendor-hatch.test.ts": [
|
|
1365
|
+
2
|
|
1366
|
+
],
|
|
1364
1367
|
"v2-peer-dependency-declared.test.ts": [
|
|
1365
1368
|
2
|
|
1366
1369
|
],
|
|
@@ -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.2.
|
|
5
|
-
"corpusTag": "v2.2.
|
|
4
|
+
"version": "2.2.3",
|
|
5
|
+
"corpusTag": "v2.2.3",
|
|
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": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
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": "43a0aa0f08070b9d51a1651d5edc96f936ed814617bb0af85eac2b7186711295",
|
|
13
|
+
"api/v2/asyncapi.yaml": "a32add439289805a6303b36f31ff54ff9c728dea1e9a98ab417b6b339215f025",
|
|
14
|
+
"api/v2/openapi.yaml": "8b8c18154b77775f9d11918a3182e6c4396f2a4c2065bee0dbe7056dafc775da",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -169,7 +169,7 @@
|
|
|
169
169
|
"schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
|
|
170
170
|
"schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
|
|
171
171
|
"schemas/v2/run-diff-response.schema.json": "8bdb2045c274580038bd74781f7d062f5049839797c164a89de4ed89edc53a74",
|
|
172
|
-
"schemas/v2/run-event-payloads.schema.json": "
|
|
172
|
+
"schemas/v2/run-event-payloads.schema.json": "3f6c256871970cdfa789d4b7e353bc96975cc17319e769c73d2dfd80fbc9670c",
|
|
173
173
|
"schemas/v2/run-event.schema.json": "1a9946374af9323b71635d00fed22abe02c300fa2cf3ae3e9c30f3e553e408ec",
|
|
174
174
|
"schemas/v2/run-list-response.schema.json": "c2dbc34768dcf64528914a213aa3cb55952e8e3e90a64b04a9423b2a19749d38",
|
|
175
175
|
"schemas/v2/run-options.schema.json": "f0e9f494d80086c2ed2cf9fe87df54e5b4e66601699c4a1089f4edbc7ba58867",
|
|
@@ -200,7 +200,7 @@
|
|
|
200
200
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
201
201
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
202
202
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
203
|
-
"spec/v1/core-standard-manifest.json": "
|
|
203
|
+
"spec/v1/core-standard-manifest.json": "9f5501b6c3406ace7df7c4a53501d47745480df76b65d65ac2d298045a73d41d",
|
|
204
204
|
"spec/v1/deprecations.json": "a2022c2d6eb501bf406b5d75fe2d431a3fe0158e8509125d2453985eb1bf511a",
|
|
205
205
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
206
206
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -212,20 +212,20 @@
|
|
|
212
212
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
213
213
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
214
214
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
215
|
-
"spec/v2/README.md": "
|
|
215
|
+
"spec/v2/README.md": "0f8a5c36ca9af5c6aa80b2e24ecf2d6af5ede9964bf70e88839110d9beab5693",
|
|
216
216
|
"spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
|
|
217
217
|
"spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
|
|
218
218
|
"spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
|
|
219
219
|
"spec/v2/core/errors.md": "74fdb5862f432a831cf0c2eadbe0c8cca6b2660ec578180ae33db54773a1df28",
|
|
220
|
-
"spec/v2/core/events.md": "
|
|
220
|
+
"spec/v2/core/events.md": "8162bb96730ef8a98b19eb180a0251a107fd629d993fe57345dacf81e53561ca",
|
|
221
221
|
"spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
|
|
222
|
-
"spec/v2/core/headers.md": "
|
|
222
|
+
"spec/v2/core/headers.md": "2dd67d71673539e16612f09f3f2513a384f6566052d7cd8a00885885689854c2",
|
|
223
223
|
"spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
|
|
224
224
|
"spec/v2/core/identity.md": "9d2b823eb899463807452fe5721dcf09881837c161be024175957db27870b243",
|
|
225
225
|
"spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
|
|
226
|
-
"spec/v2/core/interrupt.md": "
|
|
226
|
+
"spec/v2/core/interrupt.md": "f525479a99104655219fa2e51c4cad2a03d8e3be93ff4dac173a684675e57348",
|
|
227
227
|
"spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
|
|
228
|
-
"spec/v2/core/packs.md": "
|
|
228
|
+
"spec/v2/core/packs.md": "8b3e1587270bb1aec814befbd287181a05b7f76aeb0802787ee7ebdc85901205",
|
|
229
229
|
"spec/v2/core/persistence.md": "2373c2815b25b7b4fe29dfdf3783d3f2f3e637d3abce5085e69f019546302fb6",
|
|
230
230
|
"spec/v2/core/replay.md": "bd191a2565aa7ad0df9b35a0716f3de53be34b8722f1807c5e5b02859404a9b9",
|
|
231
231
|
"spec/v2/core/runs.md": "f7a95fc82fda5b7c0c613de6e5edd376bbd6d8ba4c665a500c94eacbe42b86f2",
|
|
@@ -268,10 +268,10 @@
|
|
|
268
268
|
"spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
|
|
269
269
|
"spec/v2/id-field-bindings.json": "eecadfd11581ba2e075ccb320d85fc033b816edfdd51edb5c5727a181e55dcb4",
|
|
270
270
|
"spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
|
|
271
|
-
"spec/v2/peer-dependency-aliases.json": "
|
|
271
|
+
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
272
272
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
273
|
-
"spec/v2/release.json": "
|
|
273
|
+
"spec/v2/release.json": "9a258b0740e7c26f74dbd56b2816407b7d0ac39cff2873b20e8171f3d8e5239b",
|
|
274
274
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
275
275
|
},
|
|
276
|
-
"corpusCommit": "
|
|
276
|
+
"corpusCommit": "f3d945720da0143ed932f50281ebe94c3162c063"
|
|
277
277
|
}
|
|
@@ -20,6 +20,9 @@ const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
|
20
20
|
const REFINE_WORKFLOW_ID = 'conformance-approval-refine';
|
|
21
21
|
const SKIP_NO_REFINE = !isFixtureAdvertised(REFINE_WORKFLOW_ID);
|
|
22
22
|
|
|
23
|
+
const EDIT_ACCEPT_WORKFLOW_ID = 'conformance-approval-edit-accept';
|
|
24
|
+
const SKIP_NO_EDIT_ACCEPT = !isFixtureAdvertised(EDIT_ACCEPT_WORKFLOW_ID);
|
|
25
|
+
|
|
23
26
|
describe.skipIf(SKIP_NO_FIXTURE)('interrupt: approval accept resumes to `completed`', () => {
|
|
24
27
|
it('run suspends at gate, accept resolution drives terminal completed', async () => {
|
|
25
28
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
@@ -183,3 +186,80 @@ describe.skipIf(SKIP_NO_REFINE)('interrupt: refine resolution carries action + r
|
|
|
183
186
|
).toBe(true);
|
|
184
187
|
});
|
|
185
188
|
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* RFC 0183 §A.2, the `edit-accept` arm — an edit-accept resolution must be
|
|
192
|
+
* recordable with the artifact the approver edited. The refine block above
|
|
193
|
+
* witnesses one of §A.2's two conditional MUSTs; until this block the other was
|
|
194
|
+
* stated in `spec/v2/core/interrupt.md` §Approval and witnessed nowhere.
|
|
195
|
+
*/
|
|
196
|
+
describe.skipIf(SKIP_NO_EDIT_ACCEPT)('interrupt: edit-accept resolution carries action + editedArtifactData (RFC 0183)', () => {
|
|
197
|
+
it('an edit-accept resolve round-trips `action` and the edited artifact it requires', async () => {
|
|
198
|
+
const create = await driver.post('/v1/runs', { workflowId: EDIT_ACCEPT_WORKFLOW_ID });
|
|
199
|
+
expect(create.status).toBe(201);
|
|
200
|
+
const runId = (create.json as { runId: string }).runId;
|
|
201
|
+
|
|
202
|
+
const suspended = await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
203
|
+
expect(suspended.currentNodeId).toBe(NODE_ID);
|
|
204
|
+
|
|
205
|
+
const editedArtifactData = { summary: 'conformance: edited by the approver' };
|
|
206
|
+
const resolve = await driver.post(
|
|
207
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
208
|
+
{ resumeValue: { action: 'edit-accept', editedArtifactData } },
|
|
209
|
+
);
|
|
210
|
+
expect(
|
|
211
|
+
resolve.status,
|
|
212
|
+
req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
|
|
213
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
|
|
214
|
+
'a gate offering `edit-accept` MUST accept an edit-accept resolution carrying editedArtifactData',
|
|
215
|
+
),
|
|
216
|
+
).toBe(200);
|
|
217
|
+
|
|
218
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
219
|
+
expect(events.status).toBe(200);
|
|
220
|
+
const rows = ((events.json as { events?: Array<Record<string, unknown>> } | undefined)?.events ?? []);
|
|
221
|
+
const resolved = rows.find((e) => String(e.type).endsWith('interrupt.resolved') || String(e.type).endsWith('approval.received'));
|
|
222
|
+
expect(
|
|
223
|
+
resolved,
|
|
224
|
+
req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
|
|
225
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
|
|
226
|
+
'the resolution MUST be recorded as an event',
|
|
227
|
+
),
|
|
228
|
+
).toBeDefined();
|
|
229
|
+
|
|
230
|
+
const payload = (resolved?.payload ?? {}) as Record<string, unknown>;
|
|
231
|
+
expect(
|
|
232
|
+
payload.action,
|
|
233
|
+
req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
|
|
234
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
|
|
235
|
+
'the resolved payload MUST carry `action: "edit-accept"`',
|
|
236
|
+
),
|
|
237
|
+
).toBe('edit-accept');
|
|
238
|
+
expect(
|
|
239
|
+
payload.editedArtifactData,
|
|
240
|
+
req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
|
|
241
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
|
|
242
|
+
'an edit-accept resolution MUST carry the editedArtifactData it was given',
|
|
243
|
+
),
|
|
244
|
+
).toEqual(editedArtifactData);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('refuses an edit-accept resolution that supplies no editedArtifactData', async () => {
|
|
248
|
+
const create = await driver.post('/v1/runs', { workflowId: EDIT_ACCEPT_WORKFLOW_ID });
|
|
249
|
+
expect(create.status).toBe(201);
|
|
250
|
+
const runId = (create.json as { runId: string }).runId;
|
|
251
|
+
await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
252
|
+
|
|
253
|
+
const resolve = await driver.post(
|
|
254
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
255
|
+
{ resumeValue: { action: 'edit-accept' } },
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
[400, 422].includes(resolve.status),
|
|
259
|
+
req('openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata',
|
|
260
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
|
|
261
|
+
'edit-accept without editedArtifactData MUST be refused — the edit is what the action means',
|
|
262
|
+
),
|
|
263
|
+
).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -36,6 +36,24 @@
|
|
|
36
36
|
* operator raising the scale on a slow host makes this scenario harder to pass,
|
|
37
37
|
* not easier.
|
|
38
38
|
*
|
|
39
|
+
* CORRECTED 2026-09-15 — the paragraph above described a property this file did
|
|
40
|
+
* not have, and argued for it at length, which is the most expensive kind of
|
|
41
|
+
* stale comment. The scale knob DID multiply every poll and quiet window, and it
|
|
42
|
+
* did NOT touch vitest's own `testTimeout` (30_000, `conformance/vitest.config.ts`).
|
|
43
|
+
* The declared budgets in this one test already summed to ~77.5 s — 10 s + 30 s +
|
|
44
|
+
* 30 s of polling plus three quiet windows — so at scale 1 the second and third
|
|
45
|
+
* poll budgets were UNREACHABLE, and raising the scale made the test die at the
|
|
46
|
+
* same 30 s wall having spent even less of the window it was told to wait.
|
|
47
|
+
*
|
|
48
|
+
* So the knob could not make this scenario harder to pass. It could only make it
|
|
49
|
+
* fail sooner, on the clock, with vitest's generic "Test timed out in 30000ms" in
|
|
50
|
+
* place of `pollUntil`'s message naming the run, the predicate and the last
|
|
51
|
+
* status. A host that genuinely re-fired on replay and a host that was merely
|
|
52
|
+
* slow produced the same output.
|
|
53
|
+
*
|
|
54
|
+
* `TEST_TIMEOUT_MS` below now derives the test's own wall clock from the same
|
|
55
|
+
* budgets and the same scale, so the paragraph above is true as written.
|
|
56
|
+
*
|
|
39
57
|
* ── Deliberately NOT in the `openwop-replay-fork` floor ──────────────────────
|
|
40
58
|
* This scenario needs the host to accept a loopback receiver. A host with an
|
|
41
59
|
* SSRF guard on `POST /v1/webhooks` correctly refuses one, and the suite's
|
|
@@ -155,6 +173,34 @@ const DELIVERY_GRACE_MS = 1_500;
|
|
|
155
173
|
*/
|
|
156
174
|
const SUPPRESSION_WINDOW_MS = 6_000;
|
|
157
175
|
|
|
176
|
+
/** Poll ceilings, named so the test's own wall clock can be derived from them
|
|
177
|
+
* rather than guessed alongside them. */
|
|
178
|
+
const SOURCE_POLL_MS = 10_000;
|
|
179
|
+
const FORK_POLL_MS = 30_000;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* THE TEST'S OWN WALL CLOCK, and it must be computed, not defaulted.
|
|
183
|
+
*
|
|
184
|
+
* Every budget in this file is a claim about how long a legitimate host may
|
|
185
|
+
* take. A `testTimeout` smaller than their sum does not tighten those claims —
|
|
186
|
+
* it DELETES them, silently, because vitest kills the test before the budget it
|
|
187
|
+
* was given can be spent. The suite default (30_000) is smaller than the first
|
|
188
|
+
* two budgets alone.
|
|
189
|
+
*
|
|
190
|
+
* It scales with `scaledTimeoutMs` for the same reason: an operator who raises
|
|
191
|
+
* `OPENWOP_POLL_TIMEOUT_SCALE` is saying "this host is slow, wait longer". A
|
|
192
|
+
* scaled poll budget under an unscaled wall clock turns that instruction into
|
|
193
|
+
* its opposite. Both move together or neither should.
|
|
194
|
+
*
|
|
195
|
+
* The flat slack covers the run submissions, receiver registration and
|
|
196
|
+
* assertions between the waits — work with no budget of its own. It is added
|
|
197
|
+
* AFTER scaling deliberately: it is fixed overhead, not host latency, so
|
|
198
|
+
* multiplying it would inflate the ceiling for a reason that is not the host's.
|
|
199
|
+
*/
|
|
200
|
+
const TEST_TIMEOUT_MS =
|
|
201
|
+
scaledTimeoutMs(SOURCE_POLL_MS + FORK_POLL_MS * 2 + DELIVERY_GRACE_MS * 2 + SUPPRESSION_WINDOW_MS)
|
|
202
|
+
+ 15_000;
|
|
203
|
+
|
|
158
204
|
describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted events', () => {
|
|
159
205
|
it('delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork', async (ctx) => {
|
|
160
206
|
const disco = await driver.get('/.well-known/openwop');
|
|
@@ -204,7 +250,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
204
250
|
const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
|
|
205
251
|
expect(create.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'webhooks.md §"Register"', 'failed to start conformance-noop')).toBe(201);
|
|
206
252
|
const sourceRunId = (create.json as { runId: string }).runId;
|
|
207
|
-
await pollUntilTerminal(sourceRunId, { timeoutMs:
|
|
253
|
+
await pollUntilTerminal(sourceRunId, { timeoutMs: SOURCE_POLL_MS });
|
|
208
254
|
await quietWindow(DELIVERY_GRACE_MS);
|
|
209
255
|
|
|
210
256
|
expect(
|
|
@@ -267,7 +313,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
267
313
|
}
|
|
268
314
|
expect(replay.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'webhooks.md §"Register"', 'replay fork should be accepted')).toBe(201);
|
|
269
315
|
const replayRunId = (replay.json as { runId: string }).runId;
|
|
270
|
-
await pollUntilTerminal(replayRunId, { timeoutMs:
|
|
316
|
+
await pollUntilTerminal(replayRunId, { timeoutMs: FORK_POLL_MS });
|
|
271
317
|
await quietWindow(SUPPRESSION_WINDOW_MS);
|
|
272
318
|
|
|
273
319
|
expect(
|
|
@@ -315,7 +361,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
315
361
|
}
|
|
316
362
|
expect(branch.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'replay.md §"Host-initiated fan-out is an external effect"', 'branch fork should be accepted')).toBe(201);
|
|
317
363
|
const branchRunId = (branch.json as { runId: string }).runId;
|
|
318
|
-
await pollUntilTerminal(branchRunId, { timeoutMs:
|
|
364
|
+
await pollUntilTerminal(branchRunId, { timeoutMs: FORK_POLL_MS });
|
|
319
365
|
await quietWindow(DELIVERY_GRACE_MS);
|
|
320
366
|
|
|
321
367
|
expect(
|
|
@@ -326,5 +372,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
|
|
|
326
372
|
+ 'means the host keyed on "is a fork" rather than on replay-ness read from the run',
|
|
327
373
|
),
|
|
328
374
|
).toBeGreaterThan(0);
|
|
329
|
-
|
|
375
|
+
// The third positional argument is the whole point of this fix: without it
|
|
376
|
+
// the suite default (30_000) silently overrides every budget above.
|
|
377
|
+
}, TEST_TIMEOUT_MS);
|
|
330
378
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/events.md` §Era-2 + RFC 0185 §B — a run-event payload def that
|
|
3
|
+
* v1 left open carries a vendor hatch, so a host has a CONFORMING place for a
|
|
4
|
+
* fact the corpus does not model (suite 2.2.3, target major 2; server-free).
|
|
5
|
+
*
|
|
6
|
+
* v1's `run-event-payloads.schema.json` declares `additionalProperties: true`.
|
|
7
|
+
* v2's declares `false` on the same defs — 53 of them — with no hatch and no
|
|
8
|
+
* migration row. Every extra key a host legitimately recorded under v1, because
|
|
9
|
+
* v1 explicitly invited them, became invalid at the cut with nowhere to go.
|
|
10
|
+
*
|
|
11
|
+
* Two production hosts lost data to that independently: one drops 32 distinct
|
|
12
|
+
* keys at the major-2 read (the conversation content among them), the other
|
|
13
|
+
* projects era-3 rows ON WRITE and lost a variable's value AT REST. Both had a
|
|
14
|
+
* validator that got happier the more they deleted.
|
|
15
|
+
*
|
|
16
|
+
* The load-bearing leg is the THIRD one. A hatch that accepts a vendor key is
|
|
17
|
+
* satisfied by spraying `patternProperties` across every def in the file; what
|
|
18
|
+
* makes it a scoped decision is that a def closed DELIBERATELY in v2 still
|
|
19
|
+
* refuses one. `refineFeedback` is that def — RFC 0183 modelled it closed on
|
|
20
|
+
* purpose — and this leg fails if a later edit hatches it by reflex.
|
|
21
|
+
*
|
|
22
|
+
* @see RFCS/0185-run-event-payload-closure.md §B
|
|
23
|
+
* @see spec/v2/core/events.md §Era-2 logs
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
30
|
+
import addFormats from 'ajv-formats';
|
|
31
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
32
|
+
import { req } from '../lib/requirement-ids.js';
|
|
33
|
+
|
|
34
|
+
const ID = 'openwop.requirement.0185.payload-vendor-hatch';
|
|
35
|
+
const DOC = 'RFCS/0185-run-event-payload-closure.md §B';
|
|
36
|
+
|
|
37
|
+
/** Compile a validator for ONE `$def` of the payload schema. */
|
|
38
|
+
function defValidator(def: string): (doc: unknown) => boolean {
|
|
39
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
40
|
+
addFormats(ajv);
|
|
41
|
+
// Register the whole v2 tree: the payload defs $ref `ids.schema.json` for
|
|
42
|
+
// their id kinds, so compiling the file alone cannot resolve.
|
|
43
|
+
const dir = join(SCHEMAS_DIR, 'v2');
|
|
44
|
+
for (const f of readdirSync(dir)) {
|
|
45
|
+
if (!f.endsWith('.schema.json') || statSync(join(dir, f)).isDirectory()) continue;
|
|
46
|
+
try { ajv.addSchema(JSON.parse(readFileSync(join(dir, f), 'utf8')) as Record<string, unknown>); } catch { /* duplicate $id */ }
|
|
47
|
+
}
|
|
48
|
+
return ajv.compile({ $ref: `https://openwop.dev/spec/v2/run-event-payloads.schema.json#/$defs/${def}` }) as unknown as (doc: unknown) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('v2 payload vendor hatch (RFC 0185 §B)', () => {
|
|
52
|
+
it('a def v1 left open carries the hatch, still refuses a bare key, and a deliberately-closed def refuses both', () => {
|
|
53
|
+
const variableChanged = defValidator('variableChanged');
|
|
54
|
+
const base = { nodeId: 'n1', name: 'total', previous: 1, next: 2 };
|
|
55
|
+
|
|
56
|
+
// 1. the hatch admits a vendor-prefixed key
|
|
57
|
+
for (const key of ['vendor.myndhyve.value', 'x-legacy-value', 'openwop-internal']) {
|
|
58
|
+
expect(
|
|
59
|
+
variableChanged({ ...base, [key]: 'carried' }),
|
|
60
|
+
req(ID, DOC, `\`variableChanged\` MUST admit the hatched property \`${key}\` — v1 was additionalProperties:true, so a host may hold keys v2 does not name and needs a conforming home for them`),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. and the closure it was made for still holds
|
|
65
|
+
expect(
|
|
66
|
+
variableChanged({ ...base, value: 2 }),
|
|
67
|
+
req(ID, DOC, '`variableChanged` MUST still REJECT a bare unmodelled key (`value`) — the hatch is an escape valve, not a reopening; `additionalProperties: false` stays'),
|
|
68
|
+
).toBe(false);
|
|
69
|
+
|
|
70
|
+
// 3. THE LOAD-BEARING LEG: the hatch is scoped to the DEF, not inherited by
|
|
71
|
+
// the objects nested inside it. `interruptResolved` was open in v1 and is
|
|
72
|
+
// hatched; `refineFeedback` sits INSIDE it, was modelled closed on purpose
|
|
73
|
+
// by RFC 0183, and was never open in v1 — so it is outside §B.
|
|
74
|
+
const interruptResolved = defValidator('interruptResolved');
|
|
75
|
+
const resolved = { interruptId: 't/0123456789abcdef', nodeId: 'n1', kind: 'approval' };
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
interruptResolved({ ...resolved, 'vendor.example.extra': 1 }),
|
|
79
|
+
req(ID, DOC, '`interruptResolved` was open in v1, so its own hatch MUST admit a vendor key'),
|
|
80
|
+
).toBe(true);
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
interruptResolved({ ...resolved, action: 'refine', refineFeedback: { scope: 'whole', 'vendor.example.extra': 1 } }),
|
|
84
|
+
req(ID, DOC, 'a vendor key inside `refineFeedback` MUST be REFUSED — RFC 0183 modelled that object closed on purpose and it was never open in v1. The hatch belongs to the 53 defs that were narrowed, not to everything nested under them; this leg fails if a later edit sprays `patternProperties` down the tree'),
|
|
85
|
+
).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|