@openwop/openwop-conformance 2.1.6 → 2.2.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 +98 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-refine.json +45 -0
- package/fixtures/conformance-envelope-nl-to-format-engaged.json +1 -1
- package/fixtures/conformance-envelope-recovery-applied.json +2 -2
- package/fixtures/conformance-envelope-refusal.json +1 -1
- package/fixtures/conformance-envelope-retry-attempted.json +2 -2
- package/fixtures/conformance-envelope-retry-exhausted.json +1 -1
- package/fixtures/conformance-envelope-truncated.json +1 -1
- package/fixtures/conformance-envelope-truncation-cap-exhaustion.json +1 -1
- package/fixtures/conformance-phase4-nondet-tool.json +2 -2
- package/fixtures/conformance-phase4-replay-divergence.json +2 -2
- package/fixtures.md +16 -0
- package/package.json +2 -2
- package/requirements.json +93 -12
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +21 -21
- package/src/lib/bound-id.ts +69 -0
- package/src/scenarios/envelope-completion-distinguishes-truncation.test.ts +20 -11
- package/src/scenarios/envelope-nl-to-format-engaged.test.ts +1 -1
- package/src/scenarios/envelope-recovery-applied.test.ts +1 -1
- package/src/scenarios/envelope-refusal-shape.test.ts +1 -1
- package/src/scenarios/envelope-retry-attempted.test.ts +1 -1
- package/src/scenarios/envelope-retry-exhausted.test.ts +1 -1
- package/src/scenarios/envelope-truncated.test.ts +1 -1
- package/src/scenarios/envelope-truncation-cap-exhaustion.test.ts +1 -1
- package/src/scenarios/interrupt-approval.test.ts +87 -0
- package/src/scenarios/replay-divergence-at-refusal.test.ts +2 -2
- package/src/scenarios/v2-bound-id-path-projection.test.ts +121 -0
- package/src/scenarios/v2-created-run-readable.test.ts +1 -1
- package/src/scenarios/v2-id-grammar.test.ts +1 -1
- package/src/scenarios/v2-stream-sse-projection.test.ts +1 -1
- package/src/setup.ts +38 -0
|
@@ -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.2.0",
|
|
5
|
+
"corpusTag": "v2.2.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": "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": "5d074016ed82dda66ec00016a06aef595f6688e47d95690d1eb395e3bf217c56",
|
|
13
|
+
"api/v2/asyncapi.yaml": "11107def567fc6188b29c267a2f5488fd92c59fb3433fd4124fdf63f9aca4981",
|
|
14
|
+
"api/v2/openapi.yaml": "303752410cc4b0be42206a678c08d7aeb14333ab9e354e2e021356d468e8c3e7",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"schemas/residency.schema.json": "b45fdc91ae88091251914dbd6787a1e2ce687580b91d31a759217b7a1f0010c0",
|
|
83
83
|
"schemas/run-ancestry-response.schema.json": "963a5763b03216aa72fc2854560c778e53994f8cb06ea9a4053e541273c87f5c",
|
|
84
84
|
"schemas/run-diff-response.schema.json": "d9d196730b5d5d8a04a591d21a8fc6b44be7fa134f11827ff7e4f1a86d47e83d",
|
|
85
|
-
"schemas/run-event-payloads.schema.json": "
|
|
85
|
+
"schemas/run-event-payloads.schema.json": "2f2a5192a6262758bbffb358d2044ebe4ad234b388ddcd47b818602897efb93f",
|
|
86
86
|
"schemas/run-event.schema.json": "c70c06932b9831295042836213e617c8e946f20ddd49cf4e889b74d89d2175a8",
|
|
87
87
|
"schemas/run-options.schema.json": "f8241ece6ddc6527de9160d811d42c231b4f77b935f2e107b3c05608545c59ab",
|
|
88
88
|
"schemas/run-orchestrator-decided-event.schema.json": "17a43a3c44680a24d411141357a867b3caed6107d56de88fe2e062eaf93fa2c1",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"schemas/v2/goal.schema.json": "146c4182e36d3538778f947e668a92cdb69ec42ee0820104b00063b035e342d7",
|
|
150
150
|
"schemas/v2/heartbeat-evaluated.schema.json": "4086b1ba1d0547c08b038dfb263ac89416a3e406430db1250156fb2f9b74f8d6",
|
|
151
151
|
"schemas/v2/heartbeat-state-changed.schema.json": "eb68d0082aa996650e3a6b8ccf32457f959cebc7e6728cc7c331b9d171a4bef6",
|
|
152
|
-
"schemas/v2/ids.schema.json": "
|
|
152
|
+
"schemas/v2/ids.schema.json": "6481cab16c9b89fab168627502dcbf2ee7489b5a636a4080ea8cfd2670d7b17e",
|
|
153
153
|
"schemas/v2/localized-content-language-settings.schema.json": "92240f19a2b4b2f05ab41a797f703485a28813848035478e72f395edd04c1d35",
|
|
154
154
|
"schemas/v2/localized-content-page-response.schema.json": "ef48329749758afb21e4d3cbc3f017abb911f75726819457f35b9f1390332c75",
|
|
155
155
|
"schemas/v2/localized-content-page.schema.json": "e0f97660121955d6705d935f34e748adb739ac70f8536a009cd8d6f303735151",
|
|
@@ -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": "38dd47929f0245c865e4850aab453e073bb717bc81590d68a91bd7b490536d63",
|
|
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,11 +200,11 @@
|
|
|
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": "9220156eee1d465b371af344f19fe7ad151e492da3be066f43032d7bd451b0a0",
|
|
204
204
|
"spec/v1/deprecations.json": "a2022c2d6eb501bf406b5d75fe2d431a3fe0158e8509125d2453985eb1bf511a",
|
|
205
205
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
206
206
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
207
|
-
"spec/v1/event-codemap.schema.json": "
|
|
207
|
+
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
208
208
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
209
209
|
"spec/v1/gaps.json": "96ca051cb20f1a669b355d67a89421c56fef28aa5e6710c9519f592f26af33a3",
|
|
210
210
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
@@ -212,16 +212,16 @@
|
|
|
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": "e2a3e769eec448a50b41d910805d31ccc173bbebf79b2418a7734d70d2ad828e",
|
|
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": "3b7d1ad34f9e438c9825d60c0114b9a21c82a468f15ccbd7567022641a4537ad",
|
|
221
221
|
"spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
|
|
222
|
-
"spec/v2/core/headers.md": "
|
|
222
|
+
"spec/v2/core/headers.md": "f8dd0d285e73906059cd815f18ecc068a0f1339a3755cf6319f0ee5d8da607d2",
|
|
223
223
|
"spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
|
|
224
|
-
"spec/v2/core/identity.md": "
|
|
224
|
+
"spec/v2/core/identity.md": "9d2b823eb899463807452fe5721dcf09881837c161be024175957db27870b243",
|
|
225
225
|
"spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
|
|
226
226
|
"spec/v2/core/interrupt.md": "d872e0652a938594eeb2a19e86cf64e64e29b234f5d9c4fbe2970ab5fb191b9c",
|
|
227
227
|
"spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
|
|
@@ -233,11 +233,11 @@
|
|
|
233
233
|
"spec/v2/core/versioning.md": "d731782eeabb953919c7c7057340b7f9c2916b6186790e6b04c7d8ea60767189",
|
|
234
234
|
"spec/v2/core/webhooks.md": "d360da78336afa837bddf515e289e445808bca02e1e4b493f81fa51ce4a545c4",
|
|
235
235
|
"spec/v2/core/workflow-chain-packs.md": "ef2b07d2d99334eaec0cc370314e2ad2f84ea58deb699e5d92e6e52d6056613f",
|
|
236
|
-
"spec/v2/declaration.json": "
|
|
237
|
-
"spec/v2/declaration.schema.json": "
|
|
236
|
+
"spec/v2/declaration.json": "a09bac61c1b825e4a77b5abc0f86f1f8c20c381e0650eb08c2f6d0ef0aaec1dc",
|
|
237
|
+
"spec/v2/declaration.schema.json": "c86e3083fb9789c1958ecb4576cc6f71aa90e6f339521139238543ed08211dd4",
|
|
238
238
|
"spec/v2/errors.json": "f179414a92f30b5dadf26e9e57699649a67324bd7186d5287647d17605d137d3",
|
|
239
239
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
240
|
-
"spec/v2/event-codemap.schema.json": "
|
|
240
|
+
"spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
|
|
241
241
|
"spec/v2/ext/a2uiSurface/README.md": "176cb0542e1c236ffcd6d682536ba0081242684444e2dec790abd480a44a571e",
|
|
242
242
|
"spec/v2/ext/brand/README.md": "1a80102a9cd766dcb6098c999e552af88f5b83c88951acde18d17cc0b29aa09d",
|
|
243
243
|
"spec/v2/ext/canvas/README.md": "a2d5f28523753e8e85cc9042db39faf9207a2eebce1a8553be22e341f7ea58d9",
|
|
@@ -266,12 +266,12 @@
|
|
|
266
266
|
"spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
|
|
267
267
|
"spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
|
|
268
268
|
"spec/v2/facets/webhooks.schema.json": "0b766033ef4b4c6c2ac6f9979a19843ba024d3a1d1576972dcb55aaba52f1b9a",
|
|
269
|
-
"spec/v2/id-field-bindings.json": "
|
|
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": "10fcab53c65305e9b6bae4a40d30176091dca68739ac8a78291510c77b50ca6b",
|
|
272
272
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
273
|
-
"spec/v2/release.json": "
|
|
273
|
+
"spec/v2/release.json": "ce7a6bb9f4337e3063147edcd609c28c12cc26599ee4ffcfe32e816d85ddc399",
|
|
274
274
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
|
|
275
275
|
},
|
|
276
|
-
"corpusCommit": "
|
|
276
|
+
"corpusCommit": "e2db4047ee81b8f31ffee84238ebdd551d65b34c"
|
|
277
277
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bound-id path projection (`spec/v2/core/identity.md` §5, RFC 0184 §A.1).
|
|
3
|
+
*
|
|
4
|
+
* A tenant-bound id is TWO segments joined by `/`; a URL path parameter is ONE
|
|
5
|
+
* segment. Something has to carry the separator across, and the corpus used to
|
|
6
|
+
* say `%2F`. That is what a tier-1 host's front door decoded back to `/` before
|
|
7
|
+
* forwarding, leaving every bound id unreachable at its own origin
|
|
8
|
+
* (`v2-created-run-readable.test.ts` records the measurement).
|
|
9
|
+
*
|
|
10
|
+
* So the escape marker is `~`, which RFC 3986 §2.3 lists as UNRESERVED. An
|
|
11
|
+
* intermediary has no license to rewrite an unreserved character, so `~2F`
|
|
12
|
+
* arrives byte-for-byte. `%2F` has no such protection in practice: handling it
|
|
13
|
+
* correctly requires a front door to distinguish a percent-encoded RESERVED
|
|
14
|
+
* octet (§6.2.2.2: MUST NOT decode) from an unreserved one (SHOULD decode), and
|
|
15
|
+
* deployed front doors do not.
|
|
16
|
+
*
|
|
17
|
+
* The codec is TOTAL over bytes rather than conditional on the current id
|
|
18
|
+
* grammar. That is deliberate: a conditional encoding is only unambiguous while
|
|
19
|
+
* the grammar holds still, and this grammar has already moved once (the `anon:`
|
|
20
|
+
* tenant prefix). Encoding every non-passthrough byte means a later widening
|
|
21
|
+
* cannot invalidate a projection already on the wire.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** RFC 3986 unreserved MINUS `~`, which is reserved here as the escape marker. */
|
|
25
|
+
const PASSTHROUGH = /^[A-Za-z0-9._-]$/;
|
|
26
|
+
|
|
27
|
+
/** Encode a bound id into exactly one path segment. Identity on already-safe input. */
|
|
28
|
+
export function projectBoundId(id: string): string {
|
|
29
|
+
let out = '';
|
|
30
|
+
for (const byte of new TextEncoder().encode(id)) {
|
|
31
|
+
const ch = String.fromCharCode(byte);
|
|
32
|
+
out += byte < 0x80 && PASSTHROUGH.test(ch) ? ch : `~${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decode one path segment back to the bound id.
|
|
39
|
+
*
|
|
40
|
+
* Throws on a `~` that does not introduce two hex digits — the wire rule is
|
|
41
|
+
* `400 validation_error`, and a decoder that silently passed a lone `~` through
|
|
42
|
+
* would make the codec non-injective in exactly the direction that matters.
|
|
43
|
+
*/
|
|
44
|
+
export function unprojectBoundId(segment: string): string {
|
|
45
|
+
for (let i = 0; i < segment.length; i++) {
|
|
46
|
+
if (segment[i] !== '~') continue;
|
|
47
|
+
if (!/^[0-9A-Fa-f]{2}$/.test(segment.slice(i + 1, i + 3))) {
|
|
48
|
+
throw new Error(`bound-id projection: '~' at index ${i} is not followed by two hex digits`);
|
|
49
|
+
}
|
|
50
|
+
i += 2;
|
|
51
|
+
}
|
|
52
|
+
const bytes: number[] = [];
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < segment.length) {
|
|
55
|
+
if (segment[i] === '~') { bytes.push(parseInt(segment.slice(i + 1, i + 3), 16)); i += 3; }
|
|
56
|
+
else { bytes.push(...new TextEncoder().encode(segment[i]!)); i += 1; }
|
|
57
|
+
}
|
|
58
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(bytes));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The tenant-bound id grammar, mirrored from `ids.schema.json` `$defs.runId`.
|
|
63
|
+
*
|
|
64
|
+
* Exported because four scenarios each kept their own copy, and copies drift:
|
|
65
|
+
* all four still spelled the tenant segment `[A-Za-z0-9._~-]{1,128}` after
|
|
66
|
+
* `tenantId` grew its `anon:` prefix, so every one of them would have rejected
|
|
67
|
+
* a runId the schema accepts (RFC 0184 §A.3).
|
|
68
|
+
*/
|
|
69
|
+
export const BOUND_ID = /^(anon:)?[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
@@ -36,7 +36,16 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
36
36
|
import { softSkip } from '../lib/soft-skip.js';
|
|
37
37
|
|
|
38
38
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The mock-AI program seam is keyed by `nodeId` alone, so a node id is the
|
|
41
|
+
* unit of isolation between scenarios — see `host-sample-test-seams.md` §5.
|
|
42
|
+
* This file drives two fixtures, so it resolves the node per fixture rather
|
|
43
|
+
* than holding one shared id.
|
|
44
|
+
*/
|
|
45
|
+
const NODE_OF: Record<string, string> = {
|
|
46
|
+
'conformance-envelope-truncated': 'truncated-structured-call',
|
|
47
|
+
'conformance-envelope-retry-attempted': 'retry-attempted-structured-call',
|
|
48
|
+
};
|
|
40
49
|
|
|
41
50
|
interface DiscoveryDoc {
|
|
42
51
|
capabilities?: {
|
|
@@ -68,8 +77,8 @@ async function readDiscovery(): Promise<DiscoveryDoc | null> {
|
|
|
68
77
|
}
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
async function programMock(program: Array<Record<string, unknown>>): Promise<{ status: number }> {
|
|
72
|
-
const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId:
|
|
80
|
+
async function programMock(fixture: string, program: Array<Record<string, unknown>>): Promise<{ status: number }> {
|
|
81
|
+
const res = await driver.post('/v1/host/sample/test/mock-ai/program', { nodeId: NODE_OF[fixture], program });
|
|
73
82
|
return { status: res.status };
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -84,8 +93,8 @@ async function startRunAndRead(workflowId: string): Promise<{ events: RunEvent[]
|
|
|
84
93
|
return { events, terminal };
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
async function lastBudget(): Promise<number | null> {
|
|
88
|
-
const res = await driver.get(`/v1/host/sample/test/mock-ai/last-dispatch-budget?nodeId=${encodeURIComponent(
|
|
96
|
+
async function lastBudget(fixture: string): Promise<number | null> {
|
|
97
|
+
const res = await driver.get(`/v1/host/sample/test/mock-ai/last-dispatch-budget?nodeId=${encodeURIComponent(NODE_OF[fixture] as string)}`);
|
|
89
98
|
if (res.status !== 200) return null;
|
|
90
99
|
return (res.json as { maxTokens?: number | null }).maxTokens ?? null;
|
|
91
100
|
}
|
|
@@ -118,7 +127,7 @@ describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: trunca
|
|
|
118
127
|
if (!isFixtureAdvertised(TRUNCATED_FIXTURE)) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(TRUNCATED_FIXTURE)` returned early');
|
|
119
128
|
const d = await readDiscovery();
|
|
120
129
|
if (capabilityFamily<{ reasoning?: Record<string, unknown>; tierOneSubsetCompliance?: unknown; reliability?: { completion?: Record<string, unknown> } & Record<string, unknown> }>(d, 'envelopes')?.reliability?.completion?.distinguishesTruncation !== true) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `capabilityFamily<{ reasoning?: Record<string, unknown>; tierOneSubsetCompliance?: unknown; reliability?: { completion?: Record<string, unknown> } & Record<stri…');
|
|
121
|
-
const seed = await programMock([
|
|
130
|
+
const seed = await programMock(TRUNCATED_FIXTURE, [
|
|
122
131
|
{ stopReason: 'max_tokens', content: '{"partial' },
|
|
123
132
|
{ stopReason: 'end_turn', content: '{"valid":true}' },
|
|
124
133
|
]);
|
|
@@ -143,14 +152,14 @@ describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: trunca
|
|
|
143
152
|
if (!isFixtureAdvertised(TRUNCATED_FIXTURE)) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(TRUNCATED_FIXTURE)` returned early');
|
|
144
153
|
const d = await readDiscovery();
|
|
145
154
|
if (capabilityFamily<{ reasoning?: Record<string, unknown>; tierOneSubsetCompliance?: unknown; reliability?: { completion?: Record<string, unknown> } & Record<string, unknown> }>(d, 'envelopes')?.reliability?.completion?.distinguishesTruncation !== true) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `capabilityFamily<{ reasoning?: Record<string, unknown>; tierOneSubsetCompliance?: unknown; reliability?: { completion?: Record<string, unknown> } & Record<stri…');
|
|
146
|
-
const seed = await programMock([
|
|
155
|
+
const seed = await programMock(TRUNCATED_FIXTURE, [
|
|
147
156
|
{ stopReason: 'max_tokens', content: '{"partial' },
|
|
148
157
|
{ stopReason: 'end_turn', content: '{"valid":true}' },
|
|
149
158
|
]);
|
|
150
159
|
if (seed.status === 404) return softSkip('blocked', 'precondition not met — `seed.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
151
160
|
|
|
152
161
|
await startRunAndRead(TRUNCATED_FIXTURE);
|
|
153
|
-
const budget = await lastBudget();
|
|
162
|
+
const budget = await lastBudget(TRUNCATED_FIXTURE);
|
|
154
163
|
if (budget === null) return softSkip('blocked', 'precondition not met — `budget === null` returned early (seam, prior step, or fixture unavailable)');
|
|
155
164
|
expect(
|
|
156
165
|
budget,
|
|
@@ -165,7 +174,7 @@ describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: trunca
|
|
|
165
174
|
describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: schema-violation path (RFC 0033 §C)', () => {
|
|
166
175
|
it('schema-violation: NO envelope.truncated; envelope.retry.attempted reason ∈ {schema-violation, parse-error}', async () => {
|
|
167
176
|
if (!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)` returned early');
|
|
168
|
-
const seed = await programMock([
|
|
177
|
+
const seed = await programMock(SCHEMA_VIOLATION_FIXTURE, [
|
|
169
178
|
{ content: 'not valid json' },
|
|
170
179
|
{ content: '{"valid":true}' },
|
|
171
180
|
]);
|
|
@@ -195,14 +204,14 @@ describe.skipIf(HTTP_SKIP)('envelope-completion-distinguishes-truncation: schema
|
|
|
195
204
|
|
|
196
205
|
it('schema-violation: retry budget UNCHANGED from initial (no budget multiplication on this path)', async () => {
|
|
197
206
|
if (!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!isFixtureAdvertised(SCHEMA_VIOLATION_FIXTURE)` returned early');
|
|
198
|
-
const seed = await programMock([
|
|
207
|
+
const seed = await programMock(SCHEMA_VIOLATION_FIXTURE, [
|
|
199
208
|
{ content: 'not valid json' },
|
|
200
209
|
{ content: '{"valid":true}' },
|
|
201
210
|
]);
|
|
202
211
|
if (seed.status === 404) return softSkip('blocked', 'precondition not met — `seed.status === 404` returned early (seam, prior step, or fixture unavailable)');
|
|
203
212
|
|
|
204
213
|
await startRunAndRead(SCHEMA_VIOLATION_FIXTURE);
|
|
205
|
-
const budget = await lastBudget();
|
|
214
|
+
const budget = await lastBudget(SCHEMA_VIOLATION_FIXTURE);
|
|
206
215
|
if (budget === null) return softSkip('blocked', 'precondition not met — `budget === null` returned early (seam, prior step, or fixture unavailable)');
|
|
207
216
|
// The schema-violation fixture doesn't set maxTokens explicitly →
|
|
208
217
|
// budget snapshots whatever the host's default is on each call.
|
|
@@ -33,7 +33,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
33
33
|
|
|
34
34
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
35
35
|
const FIXTURE = 'conformance-envelope-nl-to-format-engaged';
|
|
36
|
-
const NODE_ID = 'structured-call';
|
|
36
|
+
const NODE_ID = 'nl-to-format-structured-call';
|
|
37
37
|
|
|
38
38
|
interface RunEvent {
|
|
39
39
|
type: string;
|
|
@@ -126,7 +126,7 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
126
126
|
import { softSkip } from '../lib/soft-skip.js';
|
|
127
127
|
|
|
128
128
|
const RECOVERY_FIXTURE = 'conformance-envelope-recovery-applied';
|
|
129
|
-
const RECOVERY_NODE_ID = 'structured-call';
|
|
129
|
+
const RECOVERY_NODE_ID = 'recovery-applied-structured-call';
|
|
130
130
|
|
|
131
131
|
interface ProgrammedRunEvent {
|
|
132
132
|
type: string;
|
|
@@ -196,7 +196,7 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
196
196
|
import { softSkip } from '../lib/soft-skip.js';
|
|
197
197
|
|
|
198
198
|
const E2E_FIXTURE = 'conformance-envelope-refusal';
|
|
199
|
-
const E2E_NODE_ID = 'structured-call';
|
|
199
|
+
const E2E_NODE_ID = 'refusal-structured-call';
|
|
200
200
|
|
|
201
201
|
interface E2eEvent {
|
|
202
202
|
type: string;
|
|
@@ -119,7 +119,7 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
119
119
|
import { softSkip } from '../lib/soft-skip.js';
|
|
120
120
|
|
|
121
121
|
const FIXTURE = 'conformance-envelope-retry-attempted';
|
|
122
|
-
const NODE_ID = 'structured-call';
|
|
122
|
+
const NODE_ID = 'retry-attempted-structured-call';
|
|
123
123
|
|
|
124
124
|
const RFC_0032_REASONS = new Set([
|
|
125
125
|
'schema-violation',
|
|
@@ -33,7 +33,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
33
33
|
|
|
34
34
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
35
35
|
const FIXTURE = 'conformance-envelope-retry-exhausted';
|
|
36
|
-
const NODE_ID = 'structured-call';
|
|
36
|
+
const NODE_ID = 'retry-exhausted-structured-call';
|
|
37
37
|
|
|
38
38
|
const RFC_0032_REASONS = new Set([
|
|
39
39
|
'schema-violation',
|
|
@@ -25,7 +25,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
25
25
|
|
|
26
26
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
27
27
|
const FIXTURE = 'conformance-envelope-truncated';
|
|
28
|
-
const NODE_ID = 'structured-call';
|
|
28
|
+
const NODE_ID = 'truncated-structured-call';
|
|
29
29
|
|
|
30
30
|
interface RunEvent {
|
|
31
31
|
type: string;
|
|
@@ -24,7 +24,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
24
24
|
|
|
25
25
|
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
26
26
|
const FIXTURE = 'conformance-envelope-truncation-cap-exhaustion';
|
|
27
|
-
const NODE_ID = 'structured-call';
|
|
27
|
+
const NODE_ID = 'truncation-cap-structured-call';
|
|
28
28
|
|
|
29
29
|
interface RunEvent {
|
|
30
30
|
type: string;
|
|
@@ -17,6 +17,9 @@ const WORKFLOW_ID = 'conformance-approval';
|
|
|
17
17
|
const NODE_ID = 'gate';
|
|
18
18
|
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
19
19
|
|
|
20
|
+
const REFINE_WORKFLOW_ID = 'conformance-approval-refine';
|
|
21
|
+
const SKIP_NO_REFINE = !isFixtureAdvertised(REFINE_WORKFLOW_ID);
|
|
22
|
+
|
|
20
23
|
describe.skipIf(SKIP_NO_FIXTURE)('interrupt: approval accept resumes to `completed`', () => {
|
|
21
24
|
it('run suspends at gate, accept resolution drives terminal completed', async () => {
|
|
22
25
|
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
@@ -96,3 +99,87 @@ describe.skipIf(SKIP_NO_FIXTURE)('interrupt: resolving an unknown interrupt retu
|
|
|
96
99
|
});
|
|
97
100
|
});
|
|
98
101
|
});
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* RFC 0183 §A.1/§A.2 — a `refine` resolution must be recordable.
|
|
105
|
+
*
|
|
106
|
+
* Before RFC 0183, `interruptResolved` was `additionalProperties: false` with no
|
|
107
|
+
* seat for the resume action and no seat for the feedback a refine REQUIRES, and
|
|
108
|
+
* `decision` was an unconstrained string whose documented vocabulary is a
|
|
109
|
+
* different axis (governance: granted|rejected|overridden). A host resolving by
|
|
110
|
+
* refine could record neither what the approver did nor the feedback they gave.
|
|
111
|
+
*
|
|
112
|
+
* Uses its own fixture rather than widening `conformance-approval`, because
|
|
113
|
+
* adding `refine` to that fixture's `actions` would change a registered workflow
|
|
114
|
+
* definition every host already serves.
|
|
115
|
+
*/
|
|
116
|
+
describe.skipIf(SKIP_NO_REFINE)('interrupt: refine resolution carries action + refineFeedback (RFC 0183)', () => {
|
|
117
|
+
it('a refine resolve round-trips `action` and the structured feedback it requires', async () => {
|
|
118
|
+
const create = await driver.post('/v1/runs', { workflowId: REFINE_WORKFLOW_ID });
|
|
119
|
+
expect(create.status).toBe(201);
|
|
120
|
+
const runId = (create.json as { runId: string }).runId;
|
|
121
|
+
|
|
122
|
+
const suspended = await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
123
|
+
expect(suspended.currentNodeId).toBe(NODE_ID);
|
|
124
|
+
|
|
125
|
+
const refineFeedback = { scope: 'whole', text: 'conformance: please tighten the summary' };
|
|
126
|
+
const resolve = await driver.post(
|
|
127
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
128
|
+
{ resumeValue: { action: 'refine', refineFeedback } },
|
|
129
|
+
);
|
|
130
|
+
expect(
|
|
131
|
+
resolve.status,
|
|
132
|
+
req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
|
|
133
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
|
|
134
|
+
'a gate offering `refine` MUST accept a refine resolution carrying refineFeedback',
|
|
135
|
+
),
|
|
136
|
+
).toBe(200);
|
|
137
|
+
|
|
138
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
139
|
+
expect(events.status).toBe(200);
|
|
140
|
+
const rows = ((events.json as { events?: Array<Record<string, unknown>> } | undefined)?.events ?? []);
|
|
141
|
+
const resolved = rows.find((e) => String(e.type).endsWith('interrupt.resolved') || String(e.type).endsWith('approval.received'));
|
|
142
|
+
expect(
|
|
143
|
+
resolved,
|
|
144
|
+
req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
|
|
145
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
|
|
146
|
+
'the resolution MUST be recorded as an event',
|
|
147
|
+
),
|
|
148
|
+
).toBeDefined();
|
|
149
|
+
|
|
150
|
+
const payload = (resolved?.payload ?? {}) as Record<string, unknown>;
|
|
151
|
+
expect(
|
|
152
|
+
payload.action,
|
|
153
|
+
req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
|
|
154
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
|
|
155
|
+
'the resolved payload MUST carry `action: "refine"` — the seat RFC 0183 adds',
|
|
156
|
+
),
|
|
157
|
+
).toBe('refine');
|
|
158
|
+
expect(
|
|
159
|
+
payload.refineFeedback,
|
|
160
|
+
req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
|
|
161
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
|
|
162
|
+
'a refine resolution MUST carry the refineFeedback it was given — an action whose meaning is incomplete without it',
|
|
163
|
+
),
|
|
164
|
+
).toBeTruthy();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('refuses a refine resolution that supplies no refineFeedback', async () => {
|
|
168
|
+
const create = await driver.post('/v1/runs', { workflowId: REFINE_WORKFLOW_ID });
|
|
169
|
+
expect(create.status).toBe(201);
|
|
170
|
+
const runId = (create.json as { runId: string }).runId;
|
|
171
|
+
await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
|
|
172
|
+
|
|
173
|
+
const resolve = await driver.post(
|
|
174
|
+
`/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
|
|
175
|
+
{ resumeValue: { action: 'refine' } },
|
|
176
|
+
);
|
|
177
|
+
expect(
|
|
178
|
+
[400, 422].includes(resolve.status),
|
|
179
|
+
req('openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback',
|
|
180
|
+
'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
|
|
181
|
+
'refine without refineFeedback MUST be refused — the feedback is what the action means',
|
|
182
|
+
),
|
|
183
|
+
).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -227,7 +227,7 @@ describe.skipIf(HTTP_SKIP)('replay-divergence-at-refusal: behavioral (RFC 0041
|
|
|
227
227
|
it('Phase 4 host MUST emit replay.divergedAtRefusal + fail with replay_diverged_at_refusal when original=valid + replay=refusal', async (ctx) => {
|
|
228
228
|
if (!(await gateOnPhase4(ctx))) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!(await gateOnPhase4(ctx))` returned early');
|
|
229
229
|
|
|
230
|
-
const NODE_ID = 'structured-call';
|
|
230
|
+
const NODE_ID = 'divergence-structured-call';
|
|
231
231
|
// Original program: valid envelope. Replay program (set after the
|
|
232
232
|
// original completes): refusal. Programming twice is the spec-canonical
|
|
233
233
|
// pattern — see spec/v1/host-sample-test-seams.md §5.
|
|
@@ -320,7 +320,7 @@ describe.skipIf(HTTP_SKIP)('replay-divergence-at-refusal: behavioral (RFC 0041
|
|
|
320
320
|
it('Phase 4 host MUST emit replay.divergedAtRefusal + fail with replay_diverged_at_refusal when original=refusal + replay=valid (symmetric case)', async (ctx) => {
|
|
321
321
|
if (!(await gateOnPhase4(ctx))) return softSkip('inapplicable', 'capability or profile not advertised by this host — gate `!(await gateOnPhase4(ctx))` returned early');
|
|
322
322
|
|
|
323
|
-
const NODE_ID = 'structured-call';
|
|
323
|
+
const NODE_ID = 'divergence-structured-call';
|
|
324
324
|
// Symmetric: original=refusal, replay=valid.
|
|
325
325
|
const programStatus = await programMock(NODE_ID, [
|
|
326
326
|
{ content: 'safety-refused-for-conformance', stopReason: 'safety' as const, refusalText: 'safety-refused-for-conformance' },
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/identity.md` §5 + RFC 0184 §A.1 — a tenant-bound id travels as
|
|
3
|
+
* ONE path segment under the `~`-escape projection (suite 2.2.0, target major
|
|
4
|
+
* 2; unaided; creates one run).
|
|
5
|
+
*
|
|
6
|
+
* A tenant-bound id is two segments joined by `/`. A path parameter is one. The
|
|
7
|
+
* corpus used to say `%2F` carries the separator across, and
|
|
8
|
+
* `v2-created-run-readable.test.ts` records what that cost: a tier-1 host's
|
|
9
|
+
* hosting layer decoded `%2F` back to `/` before forwarding, the backend
|
|
10
|
+
* correctly had no route for a literal slash, and EVERY bound id was
|
|
11
|
+
* unreachable through the host's own front door. One hop behind, the direct
|
|
12
|
+
* service URL answered 200.
|
|
13
|
+
*
|
|
14
|
+
* The projection escapes with `~`, which RFC 3986 §2.3 lists as UNRESERVED — an
|
|
15
|
+
* intermediary has no license to rewrite it, so `~2F` reaches the origin
|
|
16
|
+
* byte-for-byte. That is the whole argument: `%2F` needs a front door to tell a
|
|
17
|
+
* percent-encoded RESERVED octet (§6.2.2.2 says MUST NOT decode) from an
|
|
18
|
+
* unreserved one (SHOULD decode), and deployed front doors do not.
|
|
19
|
+
*
|
|
20
|
+
* **What this file does NOT assert.** The percent-encoded form still MUST work
|
|
21
|
+
* — `identity.md` §5 keeps it, removing it would be breaking, and
|
|
22
|
+
* `v2-created-run-readable` already witnesses it. Duplicating it here would red
|
|
23
|
+
* two rows for one defect and tell a bundle reader nothing new.
|
|
24
|
+
*
|
|
25
|
+
* The codec's own edge cases (marker escaping, UTF-8 vs UTF-16, malformed
|
|
26
|
+
* decode) are a unit concern and live in `src/lib/bound-id.test.ts`, which is
|
|
27
|
+
* sabotage-checked. This file asserts only what needs a HOST to answer.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/core/identity.md §5
|
|
30
|
+
* @see RFCS/0184-bound-id-path-projection.md §A.1
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
35
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
36
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
38
|
+
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { projectBoundId } from '../lib/bound-id.js';
|
|
40
|
+
import { BOUND_ID as BOUND } from '../lib/bound-id.js';
|
|
41
|
+
|
|
42
|
+
const ID = 'openwop.requirement.0184.bound-id-path-projection';
|
|
43
|
+
const DOC = 'spec/v2/core/identity.md §5';
|
|
44
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
45
|
+
|
|
46
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
47
|
+
try { return await fn(); } catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('v2 bound-id path projection (identity.md §5)', () => {
|
|
51
|
+
it('a tenant-bound id is readable at its ~-escaped path segment, links carry that form, and a malformed escape is refused', async () => {
|
|
52
|
+
try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
|
|
53
|
+
|
|
54
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
55
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
56
|
+
if (created.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
|
|
57
|
+
const body = (created.json ?? {}) as { runId?: unknown; eventsUrl?: unknown };
|
|
58
|
+
if (created.status !== 201 || typeof body.runId !== 'string') {
|
|
59
|
+
return softSkip('blocked', `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim());
|
|
60
|
+
}
|
|
61
|
+
const runId = body.runId;
|
|
62
|
+
if (!BOUND.test(runId)) return softSkip('blocked', `the created runId is not tenant-bound (${runId}) — nothing to project`);
|
|
63
|
+
|
|
64
|
+
const projected = projectBoundId(runId);
|
|
65
|
+
|
|
66
|
+
// The projection MUST leave nothing for an intermediary to decode. If this
|
|
67
|
+
// fails the encoder is wrong, not the host — assert it before blaming a 404.
|
|
68
|
+
expect(
|
|
69
|
+
encodeURIComponent(projected),
|
|
70
|
+
req(ID, DOC, `the projection MUST contain only RFC 3986 unreserved characters, so no intermediary can rewrite it (${projected})`),
|
|
71
|
+
).toBe(projected);
|
|
72
|
+
|
|
73
|
+
// ACCEPT SIDE (the new MUST). A 404 here means the host has not implemented
|
|
74
|
+
// the projection: it is not a routing accident, because the segment that
|
|
75
|
+
// reached it is byte-identical to the one sent.
|
|
76
|
+
const read = await http(() => driver.get(`/runs/${projected}`));
|
|
77
|
+
expect(
|
|
78
|
+
read?.status ?? null,
|
|
79
|
+
req(ID, DOC, `GET /runs/{projected} MUST answer 200 — the host MUST accept a tenant-bound id as one ~-escaped segment. Got ${read?.status ?? 'no response'} ${readErrorCode(read?.json) ?? ''} for ${projected} (runId ${runId})`.trim()),
|
|
80
|
+
).toBe(200);
|
|
81
|
+
expect(
|
|
82
|
+
(read?.json as { runId?: unknown } | undefined)?.runId,
|
|
83
|
+
req(ID, DOC, 'the run read at the projected segment MUST be the run that was created — a host that decodes the escape to a DIFFERENT id has a non-injective decoder'),
|
|
84
|
+
).toBe(runId);
|
|
85
|
+
|
|
86
|
+
// EMIT SIDE. A host MUST hand back the form it wants clients to use; a link
|
|
87
|
+
// still spelling %2F re-creates the front-door defect for every follower.
|
|
88
|
+
if (typeof body.eventsUrl === 'string' && body.eventsUrl.includes(runId.split('/')[1]!)) {
|
|
89
|
+
expect(
|
|
90
|
+
body.eventsUrl.includes('%2F') || body.eventsUrl.includes('%2f'),
|
|
91
|
+
req(ID, DOC, `a link carrying a tenant-bound id MUST use the ~-escaped projection, not %2F — a front door may decode %2F and strand every client that follows this link (eventsUrl ${body.eventsUrl})`),
|
|
92
|
+
).toBe(false);
|
|
93
|
+
expect(
|
|
94
|
+
body.eventsUrl.includes(projected),
|
|
95
|
+
req(ID, DOC, `eventsUrl MUST carry the runId in its projected form ${projected} (got ${body.eventsUrl})`),
|
|
96
|
+
).toBe(true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// APPLY-ONCE. The codec is deliberately NOT idempotent — escaping the
|
|
100
|
+
// marker is what makes it injective — so a host that projects twice breaks
|
|
101
|
+
// its OWN links. Both reporting hosts found they already compose payload
|
|
102
|
+
// projections on a single read path, so this is a live shape, not a
|
|
103
|
+
// thought experiment. Asserting 404 (not merely "not 200") keeps the leg
|
|
104
|
+
// from passing on a 500.
|
|
105
|
+
const twice = projectBoundId(projected);
|
|
106
|
+
const doubled = await http(() => driver.get(`/runs/${twice}`));
|
|
107
|
+
expect(
|
|
108
|
+
doubled?.status ?? null,
|
|
109
|
+
req(ID, DOC, `a DOUBLE-projected segment MUST NOT resolve — the codec is not idempotent, so projecting twice yields a different id and a host that does it strands its own links. Expected 404 for ${twice}, got ${doubled?.status ?? 'no response'}`),
|
|
110
|
+
).toBe(404);
|
|
111
|
+
|
|
112
|
+
// DECODER RULE. A `~` not introducing two hex digits is malformed input, so
|
|
113
|
+
// 400 — not 404, which would say "no such run" about a request that never
|
|
114
|
+
// named one.
|
|
115
|
+
const malformed = await http(() => driver.get(`/runs/${runId.split('/')[0]}~2`));
|
|
116
|
+
expect(
|
|
117
|
+
malformed?.status ?? null,
|
|
118
|
+
req(ID, DOC, `a path segment whose '~' is not followed by two hex digits MUST be refused 400 validation_error — got ${malformed?.status ?? 'no response'} ${readErrorCode(malformed?.json) ?? ''}`.trim()),
|
|
119
|
+
).toBe(400);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -38,11 +38,11 @@ import { v2Discovery } from '../lib/v2.js';
|
|
|
38
38
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
39
39
|
import { softSkip } from '../lib/soft-skip.js';
|
|
40
40
|
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
import { BOUND_ID as RUN_ID } from '../lib/bound-id.js';
|
|
41
42
|
|
|
42
43
|
const ID = 'openwop.requirement.0172.created-run-readable';
|
|
43
44
|
const DOC = 'spec/v2/core/runs.md §Create';
|
|
44
45
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
45
|
-
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
46
46
|
|
|
47
47
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
48
48
|
try { return await fn(); } catch { return null; }
|
|
@@ -27,10 +27,10 @@ import { v2Discovery, v2Validator } from '../lib/v2.js';
|
|
|
27
27
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
28
28
|
import { softSkip } from '../lib/soft-skip.js';
|
|
29
29
|
import { req } from '../lib/requirement-ids.js';
|
|
30
|
+
import { BOUND_ID as RUN_ID } from '../lib/bound-id.js';
|
|
30
31
|
|
|
31
32
|
const DOC = 'spec/v2/core/identity.md §5';
|
|
32
33
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
33
|
-
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
34
34
|
const FOREIGN_RUN_ID = 'openwop-conformance-foreign-tenant/foreignopaque0123456789abcdef';
|
|
35
35
|
|
|
36
36
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
@@ -29,11 +29,11 @@ import { streamEvents } from '../lib/era2-seed.js';
|
|
|
29
29
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
30
30
|
import { softSkip } from '../lib/soft-skip.js';
|
|
31
31
|
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { BOUND_ID as RUN_ID } from '../lib/bound-id.js';
|
|
32
33
|
|
|
33
34
|
const ID = 'openwop.requirement.0171.stream-sse-projection';
|
|
34
35
|
const DOC = 'spec/v2/core/events.md §SSE frames';
|
|
35
36
|
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
36
|
-
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
37
37
|
|
|
38
38
|
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
39
39
|
try { return await fn(); } catch { return null; }
|