@openwop/openwop-conformance 2.45.16 → 2.45.17

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.17] — 2026-10-04 — ETag rules: discovery's 304 gains a major-2 witness, the prompt-template 304 becomes advisory
4
+
5
+ - **New `v2-discovery-etag`.** `capabilities.md` §1 makes both halves MUSTs at v2: emit an `ETag` on the discovery document, and answer a matching `If-None-Match` with `304` (no body, `OpenWOP-Version` present; a non-matching one gets `200`). The only witness was the v1 `discovery.test.ts` leg, so nothing checked it at major 2. openwop-app's public origin answers `200` because the hosting rewrite drops `If-None-Match` (direct to the origin: `304`), and its 2.45.16 cut certified anyway.
6
+ - **Both 304 witnesses revalidate under every client spelling.** `v2-discovery-etag` and `v2-run-snapshot-etag` now send `OpenWOP-Version: 2.0`, then `2`, then `2` with a wildcard `Accept`; versioning.md §1 says a host MUST accept both version spellings. The snapshot leg had passed on openwop-app's public origin, which answers `304` to `2.0` (the suite's only spelling until now) and `200` to bare `2`.
7
+ - **`openwop.requirement.prompts.etag-revalidation` is advisory.** v2 says only that `getPromptTemplate` SHOULD send an `ETag`, and `headers.md` scopes the `304` MUST to discovery and the run snapshot. The leg rested on RFC 9110 alone. It now passes on a `304` and records `inapplicable` otherwise, never failed (the `tool-catalog-projection` precedent).
8
+ - **Proof:** the new scenario was run against a double in four modes. A conforming double passes. No ETag fails the emitted leg. A dropped `If-None-Match` fails the revalidation leg, and so does a host answering `304` to every conditional request. A double that drops it only for bare `2`, or only for `2` with a wildcard `Accept`, fails both 304 witnesses.
9
+
3
10
  ## [2.45.16] — 2026-10-04 — witness waves 2 and 3: every core family gains a major-2 witness
4
11
 
5
12
  - **Advertisement witnesses** for ten families: `selfHostedRunner`, `credentials`, `authorization`, `providerUsage`, `subWorkflow`, `scheduling`, `artifactTypes`, `aiEnvelope`, `deadLetter` and `portability`. Each validates its v2 record plus the facet checks the v2 prose states. No `supported` leg is ported: presence is the claim (capabilities.md:50).
package/README.md CHANGED
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 648 scenario files under `src/scenarios/`.
138
+ The current suite has 649 scenario files under `src/scenarios/`.
139
139
  - 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
140
140
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
141
141
  - 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.
@@ -482,7 +482,7 @@ Server-required (added in 1.7.0):
482
482
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
483
  | **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. |
484
484
 
485
- Current source tree: 648 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
+ Current source tree: 649 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
486
486
 
487
487
  ## Remaining Gaps
488
488
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.16",
4
- "stampSha256": "c610c19acb41b926293269611f14cb30b5fcf60565f5d64d22e06dcbf1631e89"
3
+ "version": "2.45.17",
4
+ "stampSha256": "688d7b577efa96d9b9295d27f6e4a1245c1f07201c84d6560a712032b9562796"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.16",
3
+ "version": "2.45.17",
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.45.16"
59
+ "@openwop/spec-artifacts": "2.45.17"
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": 724,
6
- "tests": 2745,
7
- "withStableId": 2745,
5
+ "files": 725,
6
+ "tests": 2747,
7
+ "withStableId": 2747,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2611
9
+ "explicitIds": 2613
10
10
  },
11
11
  "records": [
12
12
  {
@@ -30342,6 +30342,49 @@
30342
30342
  }
30343
30343
  ]
30344
30344
  },
30345
+ {
30346
+ "id": "openwop.it.v2-discovery-etag.the-discovery-document-carries-an-etag",
30347
+ "file": "v2-discovery-etag.test.ts",
30348
+ "line": 58,
30349
+ "title": "the discovery document carries an ETag",
30350
+ "explicitId": "openwop.requirement.capabilities.discovery-etag-emitted",
30351
+ "citations": [
30352
+ {
30353
+ "section": "spec/v2/core/capabilities.md §1",
30354
+ "requirement": null,
30355
+ "interpolated": true
30356
+ }
30357
+ ]
30358
+ },
30359
+ {
30360
+ "id": "openwop.it.v2-discovery-etag.a-matching-if-none-match-receives-304-with-no-body-and-the-version-header-a-non",
30361
+ "file": "v2-discovery-etag.test.ts",
30362
+ "line": 65,
30363
+ "title": "a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200",
30364
+ "explicitId": "openwop.requirement.capabilities.discovery-etag-revalidates",
30365
+ "citations": [
30366
+ {
30367
+ "section": "spec/v2/core/capabilities.md §1",
30368
+ "requirement": null,
30369
+ "interpolated": true
30370
+ },
30371
+ {
30372
+ "section": "spec/v2/core/headers.md §Request headers",
30373
+ "requirement": null,
30374
+ "interpolated": true
30375
+ },
30376
+ {
30377
+ "section": "spec/v2/core/versioning.md §1.4",
30378
+ "requirement": null,
30379
+ "interpolated": true
30380
+ },
30381
+ {
30382
+ "section": "spec/v2/core/capabilities.md §1",
30383
+ "requirement": null,
30384
+ "interpolated": true
30385
+ }
30386
+ ]
30387
+ },
30345
30388
  {
30346
30389
  "id": "openwop.it.v2-dispatch-input-mapping.inputmapping-projects-the-parent-variable-into-the-dispatched-child-inputs",
30347
30390
  "file": "v2-dispatch-input-mapping.test.ts",
@@ -35086,7 +35129,7 @@
35086
35129
  {
35087
35130
  "id": "openwop.it.v2-prompt-list-and-fetch.get-prompts-answers-items-whose-every-entry-validates",
35088
35131
  "file": "v2-prompt-list-and-fetch.test.ts",
35089
- "line": 56,
35132
+ "line": 58,
35090
35133
  "title": "GET /prompts answers items whose every entry validates",
35091
35134
  "explicitId": "openwop.requirement.prompts.list-shape",
35092
35135
  "citations": [
@@ -35100,7 +35143,7 @@
35100
35143
  {
35101
35144
  "id": "openwop.it.v2-prompt-list-and-fetch.the-kind-and-source-filters-narrow-the-list",
35102
35145
  "file": "v2-prompt-list-and-fetch.test.ts",
35103
- "line": 64,
35146
+ "line": 66,
35104
35147
  "title": "the kind and source filters narrow the list",
35105
35148
  "explicitId": "openwop.requirement.prompts.list-filters",
35106
35149
  "citations": [
@@ -35114,7 +35157,7 @@
35114
35157
  {
35115
35158
  "id": "openwop.it.v2-prompt-list-and-fetch.a-listed-template-is-fetched-by-id",
35116
35159
  "file": "v2-prompt-list-and-fetch.test.ts",
35117
- "line": 72,
35160
+ "line": 74,
35118
35161
  "title": "a listed template is fetched by id",
35119
35162
  "explicitId": "openwop.requirement.prompts.fetch-by-id",
35120
35163
  "citations": [
@@ -35126,10 +35169,10 @@
35126
35169
  ]
35127
35170
  },
35128
35171
  {
35129
- "id": "openwop.it.v2-prompt-list-and-fetch.an-etag-when-sent-revalidates-to-304",
35172
+ "id": "openwop.it.v2-prompt-list-and-fetch.advisory-an-etag-when-sent-revalidates-to-304",
35130
35173
  "file": "v2-prompt-list-and-fetch.test.ts",
35131
- "line": 80,
35132
- "title": "an ETag, when sent, revalidates to 304",
35174
+ "line": 82,
35175
+ "title": "advisory: an ETag, when sent, revalidates to 304",
35133
35176
  "explicitId": "openwop.requirement.prompts.etag-revalidation",
35134
35177
  "citations": [
35135
35178
  {
@@ -35142,7 +35185,7 @@
35142
35185
  {
35143
35186
  "id": "openwop.it.v2-prompt-list-and-fetch.an-unknown-templateid-answers-404-with-the-error-envelope",
35144
35187
  "file": "v2-prompt-list-and-fetch.test.ts",
35145
- "line": 88,
35188
+ "line": 90,
35146
35189
  "title": "an unknown templateId answers 404 with the error envelope",
35147
35190
  "explicitId": "openwop.requirement.prompts.unknown-template-404",
35148
35191
  "citations": [
@@ -35156,7 +35199,7 @@
35156
35199
  {
35157
35200
  "id": "openwop.it.v2-prompt-list-and-fetch.with-endpointssupported-unadvertised-get-prompts-answers-404-not-found",
35158
35201
  "file": "v2-prompt-list-and-fetch.test.ts",
35159
- "line": 96,
35202
+ "line": 98,
35160
35203
  "title": "with endpointsSupported unadvertised, GET /prompts answers 404 not_found",
35161
35204
  "explicitId": "openwop.requirement.prompts.endpoints-gate-off-404",
35162
35205
  "citations": [
@@ -36680,7 +36723,7 @@
36680
36723
  {
36681
36724
  "id": "openwop.it.v2-run-snapshot-etag.a-matching-if-none-match-receives-304-with-no-body-and-the-version-header-a-non",
36682
36725
  "file": "v2-run-snapshot-etag.test.ts",
36683
- "line": 35,
36726
+ "line": 46,
36684
36727
  "title": "a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200",
36685
36728
  "explicitId": "openwop.requirement.0170.run-snapshot-etag",
36686
36729
  "citations": [
@@ -36696,7 +36739,8 @@
36696
36739
  },
36697
36740
  {
36698
36741
  "section": "spec/v2/core/versioning.md §1.4",
36699
- "requirement": "every response carries OpenWOP-Version, the 304 included"
36742
+ "requirement": null,
36743
+ "interpolated": true
36700
36744
  },
36701
36745
  {
36702
36746
  "section": "spec/v2/core/runs.md §Snapshot",
@@ -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": 648,
4
+ "files": 649,
5
5
  "v1": 455,
6
- "v2": 208
6
+ "v2": 209
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1416,6 +1416,9 @@
1416
1416
  "v2-dead-letter-advertisement.test.ts": [
1417
1417
  2
1418
1418
  ],
1419
+ "v2-discovery-etag.test.ts": [
1420
+ 2
1421
+ ],
1419
1422
  "v2-dispatch-input-mapping.test.ts": [
1420
1423
  2
1421
1424
  ],
@@ -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.45.16",
5
- "corpusTag": "v2.45.16",
4
+ "version": "2.45.17",
5
+ "corpusTag": "v2.45.17",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "429b70d7514eca241ec0353d061ddba2263d34f4e4d7614ecad3e11b2d880076",
13
- "api/v2/asyncapi.yaml": "8a8a8f72e0ec39809c4cc85d71834d8263d51dabacc74d1673f3468a66156911",
14
- "api/v2/openapi.yaml": "970fdc562da65537205354df32da66c9e1956496fc9b6cd482b1af13e1550fb8",
12
+ "api/seams-v2.yaml": "d963b75d8b3b689a67e276f29024d7d2b58a4b4a79c11b9f2c112b30c5173156",
13
+ "api/v2/asyncapi.yaml": "a1d8826c7a8c6bc64361a1db69b607025988fc7fb64a2cb52cca0a79f9ace7c5",
14
+ "api/v2/openapi.yaml": "d9c1a78a13a32c653d148126c7dd086bd25e1394cac2f4a35db6290ffdc7fa3b",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "bd1c00fae7b6fda506a75aff841bbdc438d4ce092642a72041203bee999b88de",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -207,7 +207,7 @@
207
207
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
208
208
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
209
209
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
210
- "spec/v1/core-standard-manifest.json": "e034d628d415045195718b3065abf8ac2df8ad2179804ac63ff04ad7d0e198bb",
210
+ "spec/v1/core-standard-manifest.json": "2ad9ad7d0d478da17f8b9829997c4fc9b905162e3c2b0c6e4828f50470f7f4e0",
211
211
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
212
212
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
213
213
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
@@ -309,9 +309,9 @@
309
309
  "spec/v2/path-manifest.json": "b84c7561f50c7df0cb61b202bd6595acad3ee7edca7a45f711949bea3c69bd27",
310
310
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
311
311
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
312
- "spec/v2/release.json": "68d8fd2c8ef4cca09f08ba43adece748ea97a2eb9a5218b899be543569dbe26a",
312
+ "spec/v2/release.json": "5b6e531003a3c47ed176f1f4fa1777b3b597cb386f31b1bfe4ac962df16ae4a3",
313
313
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
314
- "spec/v2/surface-baseline.json": "94199023cd15ef90174eccbb457aa805ac4a60df858fe8dc11e96d599edc56ca"
314
+ "spec/v2/surface-baseline.json": "273566823eac3465b9f692c9cb795eca669e753bd0e733575b4722d176e115ce"
315
315
  },
316
- "corpusCommit": "11150aa3a6506f9d679564bff000755897628922"
316
+ "corpusCommit": "725db8ec383cc3d61068846def2d5b90771b8933"
317
317
  }
@@ -152,7 +152,15 @@ export async function fetchLeg(profile: MajorProfile, a: PromptAdverts, validate
152
152
  ] };
153
153
  }
154
154
 
155
- /** Leg: an ETag, when sent, revalidates to `304`. `getPromptTemplate` SHOULD send one; none is inapplicable. */
155
+ /**
156
+ * Leg (advisory): an ETag, when sent, revalidates to `304`. v2 binds the host
157
+ * to nothing here: `getPromptTemplate` SHOULD send an ETag (host-services.md
158
+ * §prompts), and the `304` MUST in headers.md §Request headers covers only the
159
+ * discovery document and the run snapshot. So the leg passes on a `304` and
160
+ * records `inapplicable` otherwise; it is never failed (the
161
+ * `tool-catalog-projection` advisory precedent). Until 2.45.17 it failed on
162
+ * RFC 9110 alone, which no v2 rule imports for this read.
163
+ */
156
164
  export async function etagLeg(profile: MajorProfile, a: PromptAdverts): Promise<PromptOutcome> {
157
165
  const g = endpointsGate(a); if (g) return g;
158
166
  const t = await aTemplate(a);
@@ -161,7 +169,8 @@ export async function etagLeg(profile: MajorProfile, a: PromptAdverts): Promise<
161
169
  const etag = first.headers.get('etag');
162
170
  if (first.status !== 200 || etag === null || etag.length === 0) return { kind: 'skip', disposition: 'inapplicable', reason: `getPromptTemplate sent no ETag (status ${first.status}) — a SHOULD, so there is nothing to revalidate` };
163
171
  const again = await driver.get(tplPath(profile, a, t.templateId), { headers: { 'If-None-Match': etag } });
164
- return { kind: 'observed', findings: [f(again.status === 304, `${DOC_LIB}; RFC 9110 §13.1.2`, `If-None-Match with the current ETag MUST answer 304 (got ${again.status})`)] };
172
+ if (again.status !== 304) return { kind: 'skip', disposition: 'inapplicable', reason: `advisory SHOULD not met: If-None-Match with the current ETag answered ${again.status}, not 304 — v2 binds 304 only on discovery and the run snapshot (headers.md), so this is recorded, never failed` };
173
+ return { kind: 'observed', findings: [f(true, `${DOC_LIB} (SHOULD); RFC 9110 §13.1.2`, 'If-None-Match with the current ETag answers 304')] };
165
174
  }
166
175
 
167
176
  /** Leg: an unknown template id is `404` with the error envelope. */
@@ -0,0 +1,86 @@
1
+ /**
2
+ * v2 — the discovery document's conditional GET (`spec/v2/core/capabilities.md`
3
+ * §1; `headers.md` §Request headers). The v1 twin is the presence-gated leg in
4
+ * `discovery.test.ts`, which runs at major 1 only, so until 2.45.17 nothing
5
+ * witnessed this MUST at major 2.
6
+ *
7
+ * At v2 both halves are MUSTs: a host MUST emit a standard `ETag` on the
8
+ * discovery document and MUST honour `If-None-Match` with `304`. The 304
9
+ * carries no body and carries `OpenWOP-Version` like every response
10
+ * (versioning.md §1.4).
11
+ *
12
+ * etag-emitted the 200 carries a non-empty `ETag`;
13
+ * etag-revalidates a matching `If-None-Match` receives `304`, no body,
14
+ * `OpenWOP-Version` present; a non-matching one receives
15
+ * `200` with the document (control: a host that answers
16
+ * 304 to any conditional request fails).
17
+ *
18
+ * The revalidation runs once per client spelling: `OpenWOP-Version: 2.0` and
19
+ * `2` (versioning.md §1: a host MUST accept both), and `2` with a wildcard `Accept`
20
+ * (curl, a default fetch). openwop-app's public origin answered `304` to the
21
+ * suite's own spelling and `200` to the bare one, so a single spelling hid a
22
+ * violation every other client sees.
23
+ *
24
+ * Measured against the PUBLIC origin a host is cut on. A front door that drops
25
+ * `If-None-Match` on the way to a correct origin (openwop-app, 2026-10-04:
26
+ * direct `304`, through the hosting rewrite `200`) fails here, as it should:
27
+ * the rule binds what clients reach.
28
+ *
29
+ * Dispositions: discovery unreadable ⇒ `blocked`. No other skip — the rule is
30
+ * unconditional at v2.
31
+ *
32
+ * @see spec/v2/core/capabilities.md §1
33
+ * @see spec/v2/core/headers.md §Request headers
34
+ */
35
+
36
+ import { describe, it, expect } from 'vitest';
37
+ import { driver, type OpenWOPResponse } from '../lib/driver.js';
38
+ import { softSkip } from '../lib/soft-skip.js';
39
+ import { req } from '../lib/requirement-ids.js';
40
+
41
+ const DOC = 'spec/v2/core/capabilities.md §1';
42
+ const ID_EMITTED = 'openwop.requirement.capabilities.discovery-etag-emitted';
43
+ const ID_REVALIDATES = 'openwop.requirement.capabilities.discovery-etag-revalidates';
44
+ const V2 = { 'OpenWOP-Version': '2.0' };
45
+ /** The client spellings a host MUST treat alike (versioning.md §1). */
46
+ const SPELLINGS: ReadonlyArray<Readonly<Record<string, string>>> = [
47
+ { 'OpenWOP-Version': '2.0' },
48
+ { 'OpenWOP-Version': '2' },
49
+ { 'OpenWOP-Version': '2', Accept: '*/*' },
50
+ ];
51
+ const label = (h: Readonly<Record<string, string>>): string => Object.entries(h).map(([k, v]) => `${k}: ${v}`).join(', ');
52
+
53
+ async function get(headers: Readonly<Record<string, string>> = V2): Promise<OpenWOPResponse | null> {
54
+ try { return await driver.get('/.well-known/openwop', { authenticated: false, headers: { ...headers } }); } catch { return null; }
55
+ }
56
+
57
+ describe('v2 discovery ETag (capabilities.md §1)', () => {
58
+ it('the discovery document carries an ETag', async () => {
59
+ const first = await get();
60
+ if (first === null || first.status !== 200) return softSkip('blocked', `v2 discovery unreachable (${first?.status ?? 'no response'})`);
61
+ const etag = first.headers.get('etag');
62
+ expect(etag !== null && etag.trim().length > 0, req(ID_EMITTED, DOC, `a host MUST emit a standard ETag on the discovery document (got ${etag === null ? 'none' : JSON.stringify(etag)})`)).toBe(true);
63
+ });
64
+
65
+ it('a matching If-None-Match receives 304 with no body and the version header; a non-matching one receives 200', async () => {
66
+ const first = await get();
67
+ if (first === null || first.status !== 200) return softSkip('blocked', `v2 discovery unreachable (${first?.status ?? 'no response'})`);
68
+ const etag = first.headers.get('etag');
69
+ if (etag === null || etag.trim().length === 0) return softSkip('inapplicable', 'no ETag to revalidate — the emitted-ETag leg records that MUST');
70
+
71
+ for (const spelling of SPELLINGS) {
72
+ const own = await get(spelling);
73
+ if (own === null || own.status !== 200) return softSkip('blocked', `v2 discovery unreachable under ${label(spelling)} (${own?.status ?? 'no response'})`);
74
+ const tag = own.headers.get('etag') ?? etag;
75
+ const hit = await get({ ...spelling, 'If-None-Match': tag });
76
+ if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
77
+ expect(hit.status, req(ID_REVALIDATES, DOC, `under ${label(spelling)}, a matching If-None-Match MUST receive 304 (got ${hit.status}; ETag sent ${tag}, answered ${String(hit.headers.get('etag'))})`)).toBe(304);
78
+ expect(hit.text.length, req(ID_REVALIDATES, 'spec/v2/core/headers.md §Request headers', `under ${label(spelling)}, the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
79
+ expect(hit.headers.get('openwop-version'), req(ID_REVALIDATES, 'spec/v2/core/versioning.md §1.4', `under ${label(spelling)}, the 304 carries OpenWOP-Version like every response`)).not.toBeNull();
80
+ }
81
+
82
+ const miss = await get({ ...V2, 'If-None-Match': '"openwop-conformance-no-such-tag"' });
83
+ if (miss === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
84
+ expect(miss.status, req(ID_REVALIDATES, DOC, `a non-matching If-None-Match MUST receive 200 with the document (got ${miss.status})`)).toBe(200);
85
+ });
86
+ });
@@ -8,7 +8,9 @@
8
8
  * validates against the closed v2 PromptTemplate;
9
9
  * filters `?kind=system` and `?source=host` narrow;
10
10
  * fetch a listed template is fetched by id;
11
- * etag an ETag, when sent (a SHOULD), revalidates to `304`;
11
+ * etag advisory: an ETag, when sent (a SHOULD), revalidates to `304`;
12
+ * a non-304 is recorded `inapplicable`, never failed (v2 binds
13
+ * 304 only on discovery and the run snapshot, headers.md);
12
14
  * unknown an unknown id is `404` with the v2 error envelope;
13
15
  * gate off NEW at v2, unaided: with `prompts.endpointsSupported` not
14
16
  * advertised (the record absent, or the facet not `true`),
@@ -77,7 +79,7 @@ describe('v2 prompt library: read surface (host-services.md §prompts)', () => {
77
79
  for (const x of out.findings) expect(x.ok, req(ID_FETCH, x.doc, x.message)).toBe(true);
78
80
  });
79
81
 
80
- it('an ETag, when sent, revalidates to 304', async () => {
82
+ it('advisory: an ETag, when sent, revalidates to 304', async () => {
81
83
  const r = await ready();
82
84
  if (!r.ok) return r.skip();
83
85
  const out = await etagLeg(PROFILE, r.a);
@@ -7,6 +7,10 @@
7
7
  * a host that sends no ETag records `inapplicable`. Every response carries
8
8
  * `OpenWOP-Version` (versioning.md §1.4), the 304 included.
9
9
  *
10
+ * The revalidation runs once per client spelling (`OpenWOP-Version: 2.0`,
11
+ * `2`, and `2` with a wildcard Accept): a host MUST treat them alike
12
+ * (versioning.md §1), and one public origin served 304 to the first only.
13
+ *
10
14
  * Control: a non-matching `If-None-Match` MUST receive 200 with the body — a
11
15
  * host that answers 304 to any conditional request fails here. `headers.md`
12
16
  * scopes `If-None-Match` to the discovery document; runs.md applies it to the
@@ -27,6 +31,13 @@ import { req } from '../lib/requirement-ids.js';
27
31
  const ID = 'openwop.requirement.0170.run-snapshot-etag';
28
32
  const DOC = 'spec/v2/core/runs.md §Snapshot';
29
33
  const NOOP = 'conformance-noop';
34
+ /** The client spellings a host MUST treat alike (versioning.md §1): openwop-app's public origin answered 304 to `2.0` and 200 to bare `2`. */
35
+ const SPELLINGS: ReadonlyArray<Readonly<Record<string, string>>> = [
36
+ { 'OpenWOP-Version': '2.0' },
37
+ { 'OpenWOP-Version': '2' },
38
+ { 'OpenWOP-Version': '2', Accept: '*/*' },
39
+ ];
40
+ const label = (h: Readonly<Record<string, string>>): string => Object.entries(h).map(([k, v]) => `${k}: ${v}`).join(', ');
30
41
 
31
42
  async function discovery(): Promise<Record<string, unknown> | null> { try { return await v2Discovery(); } catch { return null; } }
32
43
  async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> { try { return await fn(); } catch { return null; } }
@@ -51,29 +62,31 @@ describe('v2 run-snapshot-etag (runs.md §Snapshot)', () => {
51
62
  // happened to finish inside the sleep. It also let a host with a CONSTANT tag
52
63
  // pass for the wrong reason, which the control below still catches.
53
64
  await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(30_000) });
54
- let etag: string | null = null;
55
- let hit: OpenWOPResponse | null = null;
56
- // A terminal run should be still, but "should" is not the claim under test:
57
- // if the tag moves again, take the NEW tag and retry. The violation is a 200
58
- // whose ETag EQUALS the If-None-Match that was sent — never a 200 per se.
59
- for (let attempt = 0; attempt < 4; attempt++) {
60
- const first = await http(() => driver.get(path));
61
- if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
62
- etag = first.headers.get('etag');
63
- if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
64
- const sent = etag;
65
- hit = await http(() => driver.get(path, { headers: { 'If-None-Match': sent } }));
66
- if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
67
- if (hit.status !== 200 || hit.headers.get('etag') === sent) break; // 304, or the real violation: decided below
68
- await new Promise((r) => setTimeout(r, 500)); // 200 with a DIFFERENT tag: the representation moved; the tag sent was honestly stale
69
- }
70
- if (hit === null || etag === null) return softSkip('blocked', 'no conditional GET was made');
71
- if (hit.status === 200 && hit.headers.get('etag') !== etag) {
72
- return softSkip('blocked', `the snapshot of a TERMINAL run kept changing across four reads (last tag sent ${etag}, answered 200 with ${String(hit.headers.get('etag'))}) — the 304 rule cannot be witnessed against a representation that never holds still`);
65
+ for (const spelling of SPELLINGS) {
66
+ let etag: string | null = null;
67
+ let hit: OpenWOPResponse | null = null;
68
+ // A terminal run should be still, but "should" is not the claim under test:
69
+ // if the tag moves again, take the NEW tag and retry. The violation is a 200
70
+ // whose ETag EQUALS the If-None-Match that was sent — never a 200 per se.
71
+ for (let attempt = 0; attempt < 4; attempt++) {
72
+ const first = await http(() => driver.get(path, { headers: { ...spelling } }));
73
+ if (first === null || first.status !== 200) return softSkip('blocked', `GET /runs/{runId} answered ${first?.status ?? 'no response'}`);
74
+ etag = first.headers.get('etag');
75
+ if (!etag) return softSkip('inapplicable', 'the snapshot carries no ETag — runs.md §Snapshot makes the ETag a SHOULD; the 304 rule applies only when it is present');
76
+ const sent = etag;
77
+ hit = await http(() => driver.get(path, { headers: { ...spelling, 'If-None-Match': sent } }));
78
+ if (hit === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
79
+ if (hit.status !== 200 || hit.headers.get('etag') === sent) break; // 304, or the real violation: decided below
80
+ await new Promise((r) => setTimeout(r, 500)); // 200 with a DIFFERENT tag: the representation moved; the tag sent was honestly stale
81
+ }
82
+ if (hit === null || etag === null) return softSkip('blocked', 'no conditional GET was made');
83
+ if (hit.status === 200 && hit.headers.get('etag') !== etag) {
84
+ return softSkip('blocked', `the snapshot of a TERMINAL run kept changing across four reads (last tag sent ${etag}, answered 200 with ${String(hit.headers.get('etag'))}) — the 304 rule cannot be witnessed against a representation that never holds still`);
85
+ }
86
+ expect(hit.status, req(ID, DOC, `under ${label(spelling)}, a request whose If-None-Match matches the CURRENT ETag MUST receive 304 — got ${hit.status} while the response still carried the same tag ${etag}`)).toBe(304);
87
+ expect(hit.text.length, req(ID, DOC, `under ${label(spelling)}, the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
88
+ expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', `under ${label(spelling)}, every response carries OpenWOP-Version, the 304 included`)).not.toBeNull();
73
89
  }
74
- expect(hit.status, req(ID, DOC, `a request whose If-None-Match matches the CURRENT ETag MUST receive 304 — got ${hit.status} while the response still carried the same tag ${etag}`)).toBe(304);
75
- expect(hit.text.length, req(ID, DOC, `the 304 MUST carry no body (got ${hit.text.length} byte(s))`)).toBe(0);
76
- expect(hit.headers.get('openwop-version'), req(ID, 'spec/v2/core/versioning.md §1.4', 'every response carries OpenWOP-Version, the 304 included')).not.toBeNull();
77
90
  const miss = await http(() => driver.get(path, { headers: { 'If-None-Match': '"openwop-conformance-no-such-tag"' } }));
78
91
  if (miss === null) return softSkip('blocked', 'conditional GET unreachable (fetch failed)');
79
92
  expect(miss.status, req(ID, DOC, `a non-matching If-None-Match MUST receive 200 with the body — got ${miss.status} (a host answering 304 to any conditional request is not honouring the tag)`)).toBe(200);