@openwop/openwop-conformance 2.30.0 → 2.31.1

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,16 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.31.1] — 2026-09-20 — major-correct shared polling
4
+
5
+ - Fixed the shared run polling helper to use `/runs/{runId}` in the major-2 lane and retain `/v1/runs/{runId}` only in the major-1 lane. The old helper combined a `/v1` path with `OpenWOP-Version: 2`, so a conforming host correctly answered `protocol_version_mismatch`; `v2-durability-recovery` then misreported an unreachable run even though it had already failed terminally.
6
+ - Added a two-major self-test that pins both paths. Suite remains pinned to corpus `2.31.0`; this is a conformance-harness patch only.
7
+
8
+ ## [2.31.0] — 2026-09-19 — the fifth rung row, and the projection helper
9
+
10
+ - `v2-durability-recovery` gains `poison-exhaustion`, completing RFC 0158's `durable-single-instance` rung at major 2. The existing scenario was major-1 only and seam-bound; the port reads the canonical `GET /runs/{runId}/events` and needs **no seam**, so it can never record `blocked` for want of one. The v1 scenario is untouched.
11
+ - `src/lib/v2-projection.ts` — `stripSupported()` (every depth, including `required[]`) and `carriesUnspliceablePayload()`. Three hand-written v1→v2 projections went wrong in one week; this is the one function they were each re-deriving. It reports unspliceable payload rather than converting it (RFC 0193 §B: only a person can name the seat).
12
+ - Suite pinned to corpus `2.31.0`.
13
+
3
14
  ## [2.30.0] — 2026-09-19 — the unbacked ratchet
4
15
 
5
16
  - No scenario change. `check-registers` now actually ratchets the open-risk count it has always printed `(ratchet)` after. Suite pinned to corpus `2.30.0`.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  # --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
12
12
  # default resolver refuses. npm 10.9 fails outright with
13
13
  # "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
14
- npm install --legacy-peer-deps @openwop/openwop-conformance@2.30.0 @openwop/spec-artifacts@2.30.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.31.1 @openwop/spec-artifacts@2.31.1
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
package/coverage.md CHANGED
@@ -144,7 +144,7 @@ The scenario groups in the table below (one row per group; count the rows — th
144
144
  | `tool-session-lifecycle.test.ts` | `capabilities.toolCatalog.sessionLifecycle` (RFC 0078 §D, `tool-catalog.md`) | A (the §D bracket via `POST /v1/host/sample/tools/session-run` + the test event-log seam: `tool.session.opened` before the first RFC 0064 call event → `tool.session.closed` after the last, one shared `sessionId`, each carrying a `toolId`, `closed.outcome` ∈ {completed,failed,aborted,expired}, both content-free) | `host-pending` | `behaviorGate('openwop-tool-session-lifecycle', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0078 → Accepted bar.** First adopter: MyndHyve `toolCatalog`. |
145
145
  | `egress-audience-binding.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §C, `host-capabilities.md`) + `SECURITY/invariants.yaml` `egress-credential-audience-bound` | A (KEYSTONE — the §C confused-deputy MUST via `POST /v1/host/sample/egress/decide`: an out-of-audience egress is `denied`/`downgraded` with `reason:"out-of-audience"` and the credential is NOT attached (`credentialAttached !== true`); a provenance-unevaluable egress fails closed `denied`+`reason:"provenance-unevaluable"`; decision/reason ∈ the closed enums) | `host-pending` | `behaviorGate('openwop-egress-audience-binding', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0079 → Accepted bar** (the `egress-credential-audience-bound` invariant graduates reference-impl → protocol tier when this passes against a host). First adopter: MyndHyve `httpClient.egressPolicy`. |
146
146
  | `egress-decision-content-free.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §F / SR-1) | A (the secret non-leak — a `canary` credential's sentinel never surfaces in the decision (`canaryLeaked !== true`), the `egress.decided` payload carries no forbidden content key, and `reason` stays in the CLOSED vocabulary so no blocked destination spills into a free-form field) | `host-pending` | `behaviorGate('openwop-egress-decision-content-free', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0079 → Accepted bar.** First adopter: MyndHyve `httpClient.egressPolicy`. |
147
- | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (the four `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted PER EFFECT IDENTITY rather than by end state, and `bound-is-derived` as an explicitly-labelled paper check) | `host-pending` | **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar**, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
147
+ | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted PER EFFECT IDENTITY rather than by end state, `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical `GET /runs/{runId}/events`, so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
148
148
  | `memory-degraded-projection.test.ts` | `capabilities.agents.manifestRuntime.supported` + `capabilities.memory.supported` (RFC 0080 §C, `agent-memory.md`) | A (the §C iff-contract on the NORMATIVE `GET /v1/agents`: a degraded entry MUST carry `memoryDegraded:true` + a non-empty, unique `degradedMemoryDimensions[]` drawn from the closed §A-name enum [read/write/search/long-term-durability/compaction/attribution/replay-snapshot/retention]; a non-degraded entry MUST NOT carry a non-empty list; the inventory is non-empty; the degraded branch runs non-vacuously when `OPENWOP_DEGRADED_AGENT_ID` names a known-degraded agent) | `host-pending` | `behaviorGate('openwop-memory-degraded', …)`. Black-box on the normative path (no POST seam); soft-skips on 404 / when the host computes no degradation. **This is the RFC 0080 → Accepted bar.** First adopter: MyndHyve `memory`. |
149
149
  | `budget-enforcement.test.ts` | `capabilities.budget.supported` (RFC 0084 §C/§D, `budget-policy.md`) + `SECURITY/invariants.yaml` `budget-no-pricing-leak` | A (the §C/§D enforcement via `POST /v1/host/sample/budget/run` + the test event-log seam: a `hard-cost-exhaust` run emits the strict-ordered `budget.reserved → budget.consumed → budget.threshold.crossed{percent} → budget.exhausted → cap.breached{kind:"budget-cost"} → run.failed{error:"budget_exhausted"}` chain; a `model-denied` run is refused `budget_model_denied` BEFORE the provider call (fail-closed); an `advisory` host emits the `budget.*` events without stopping; every `budget.*` payload content-free — no pricing/rate) | `host-pending` | `behaviorGate('openwop-budget-enforcement', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0084 → Accepted bar.** First adopter: MyndHyve `budget`. |
150
150
  | `agent-platform-aggregate-evidence.test.ts` | `openwop-agent-platform` claim — live discovery `profiles[]` includes it (RFC 0085 §C, `agent-platform-profile.md`) | A (the §C/§D honest-advertisement on live `/.well-known/openwop`: a host claiming `openwop-agent-platform` MUST satisfy the §B floor predicate (`isAgentPlatformPartial` → `partial`/`full`, never `none`), the claim backed by per-capability evidence not the profile string; `OPENWOP_AGENT_PLATFORM_TIER=full` forces the full-predicate bar — all governance terms + tenant installScope + all 16 §D terms) | `host-pending` | `behaviorGate('openwop-agent-platform', …)`. Black-box on the discovery doc (no POST seam); soft-skips until a host claims the profile. **This is the RFC 0085 → Accepted bar.** First adopter: MyndHyve (after the memory batch surfaces the floor's `memory.supported`). |
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.30.0",
4
- "stampSha256": "960e276b864a7d7ddca28bb0ce04b72ccd7e0ec6f95fa9d2b42918732b16c1d4"
3
+ "version": "2.31.1",
4
+ "stampSha256": "7d8c030abbf9fd38c0f527eaa57d0d1621bdbf1c94463854c33c2399e52866f9"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.30.0",
3
+ "version": "2.31.1",
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.30.0"
59
+ "@openwop/spec-artifacts": "2.31.1"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,10 +3,10 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 581,
6
- "tests": 2204,
7
- "withStableId": 2204,
6
+ "tests": 2205,
7
+ "withStableId": 2205,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2158
9
+ "explicitIds": 2159
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26650,7 +26650,7 @@
26650
26650
  {
26651
26651
  "id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
26652
26652
  "file": "v2-durability-recovery.test.ts",
26653
- "line": 105,
26653
+ "line": 129,
26654
26654
  "title": "accepted work survives a kill before dispatch and dispatches on resume",
26655
26655
  "explicitId": "openwop.requirement.0158.kill-after-accept",
26656
26656
  "citations": [
@@ -26664,7 +26664,7 @@
26664
26664
  {
26665
26665
  "id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
26666
26666
  "file": "v2-durability-recovery.test.ts",
26667
- "line": 141,
26667
+ "line": 165,
26668
26668
  "title": "work executing at a real process death is never reported complete, and resumes",
26669
26669
  "explicitId": "openwop.requirement.0158.kill-during-execution",
26670
26670
  "citations": [
@@ -26678,7 +26678,7 @@
26678
26678
  {
26679
26679
  "id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
26680
26680
  "file": "v2-durability-recovery.test.ts",
26681
- "line": 175,
26681
+ "line": 199,
26682
26682
  "title": "the same accepted work delivered twice fires each effect exactly once",
26683
26683
  "explicitId": "openwop.requirement.0158.duplicate-delivery",
26684
26684
  "citations": [
@@ -26692,7 +26692,7 @@
26692
26692
  {
26693
26693
  "id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
26694
26694
  "file": "v2-durability-recovery.test.ts",
26695
- "line": 214,
26695
+ "line": 238,
26696
26696
  "title": "the declared recovery bound is derived from the mechanism that enforces it",
26697
26697
  "explicitId": "openwop.requirement.0158.bound-is-derived",
26698
26698
  "citations": [
@@ -26703,6 +26703,29 @@
26703
26703
  }
26704
26704
  ]
26705
26705
  },
26706
+ {
26707
+ "id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
26708
+ "file": "v2-durability-recovery.test.ts",
26709
+ "line": 268,
26710
+ "title": "deterministically failing work reaches a terminal state and stops being retried",
26711
+ "explicitId": "openwop.requirement.0158.poison-exhaustion",
26712
+ "citations": [
26713
+ {
26714
+ "section": "RFC 0158 §C.8",
26715
+ "requirement": null,
26716
+ "interpolated": true
26717
+ },
26718
+ {
26719
+ "section": "RFC 0158 §C.8",
26720
+ "requirement": "at least one attempt MUST be recorded — zero attempts means nothing was ever delivered, and the bound below would hold vacuously"
26721
+ },
26722
+ {
26723
+ "section": "RFC 0158 §C.8",
26724
+ "requirement": null,
26725
+ "interpolated": true
26726
+ }
26727
+ ]
26728
+ },
26706
26729
  {
26707
26730
  "id": "openwop.it.v2-effect-identity-business-key.get-runs-runid-effects-validates-and-every-row-is-keyed-on-business-identity-or",
26708
26731
  "file": "v2-effect-identity-business-key.test.ts",
@@ -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.30.0",
5
- "corpusTag": "v2.30.0",
4
+ "version": "2.31.1",
5
+ "corpusTag": "v2.31.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": "378fb7c3325adb156d1a7ac0b9f024b3685d7fc424ede769c170f35be88288c7",
13
- "api/v2/asyncapi.yaml": "d7c04a5737fcfdf1967bed784954fbaa822e28be7834bd305484d889b6fcfb59",
14
- "api/v2/openapi.yaml": "ee4b91c42e8f8ae4fcb685be5a2184444d6d148ef33ece8adfbcbd36973750f0",
12
+ "api/seams-v2.yaml": "dfecf104a518efca7b9b915578c4198c6a9e1f8965d8e84fc300bbbafc5503e7",
13
+ "api/v2/asyncapi.yaml": "577bd2252f10d8c3dceaac0dc7855925cc466e06fd6bf1613b8e451869fcdc54",
14
+ "api/v2/openapi.yaml": "82b884b23460438059a5b38b47b5f3e883d03365013ed8c2632b664417c0f699",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "938ee6f714634dd116c900a6d87852a868c6bb21a74603d479d58edbc1b783b6",
204
+ "spec/v1/core-standard-manifest.json": "0640fb5fdced5af7a5e2ae2108c69da13d79bd3c5373128ee5039e9cd1af0e43",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -279,8 +279,8 @@
279
279
  "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
280
280
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
281
281
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
282
- "spec/v2/release.json": "d34f8f71a7c34a56fa4ab1c16915525cd4a18b450dccdbdca19157a3732a6cec",
282
+ "spec/v2/release.json": "92131d62a7fcd12445d7e04eb588e2297f839b7e5e2a87f89ae9e61593f837cd",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "1b33fe1a11d465c368e4d4b63a88879371137d85"
285
+ "corpusCommit": "691974ccf758a6ea42dad8c38ab6b5570e8f89f2"
286
286
  }
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { driver } from './driver.js';
31
+ import { targetMajor } from './seams.js';
31
32
 
32
33
  export interface RunSnapshot {
33
34
  readonly runId: string;
@@ -73,9 +74,14 @@ export function scaledTimeoutMs(timeoutMs: number): number {
73
74
  const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
74
75
 
75
76
  export async function getRun(runId: string): Promise<RunSnapshot> {
76
- const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
77
+ // A major-2 request MUST use the unversioned path space. The driver adds the
78
+ // OpenWOP-Version: 2 header, so retaining `/v1` here asked the host to serve
79
+ // two different majors at once and a conforming negotiator correctly refused
80
+ // it with protocol_version_mismatch. Keep the v1 key only in the v1 lane.
81
+ const prefix = targetMajor() === 2 ? '' : '/v1';
82
+ const res = await driver.get(`${prefix}/runs/${encodeURIComponent(runId)}`);
77
83
  if (res.status !== 200) {
78
- throw new Error(`GET /v1/runs/${runId} returned ${res.status}: ${res.text.slice(0, 200)}`);
84
+ throw new Error(`GET ${prefix}/runs/${runId} returned ${res.status}: ${res.text.slice(0, 200)}`);
79
85
  }
80
86
  return res.json as RunSnapshot;
81
87
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Project a v1 capability value into its v2 shape.
3
+ *
4
+ * WHY THIS EXISTS, in three measured instances from one week:
5
+ *
6
+ * - openwop-app projected `aiProviders.selfHosted` by hand and turned a v1
7
+ * `string[]` into a v2 `boolean`.
8
+ * - The steward's own hand-authored v2 facet override flattened
9
+ * `aiProviders.input` and `aiProviders.policies` to
10
+ * `{ type: 'object', additionalProperties: true }` — losing the RFC 0091
11
+ * `modalities` enum, so a misspelled modality would have validated.
12
+ *
13
+ * Three hand-written projections, each wrong in a different direction. That is
14
+ * not three mistakes; it is one missing function. A hand-written v2 copy is a
15
+ * second source of truth, and the second source drifts.
16
+ *
17
+ * ── What the projection actually is ─────────────────────────────────────────
18
+ * v2 retired the `supported` flag: presence of the record is the claim
19
+ * (RFC 0192). v1 owners carry `supported` NESTED INSIDE each facet, not only at
20
+ * family level, so a strip that only looks at the top level leaves ghosts
21
+ * behind — which is exactly how 26 facet descriptions came to condition a MUST
22
+ * on a field the closed v2 schema forbids. This strips at every depth.
23
+ *
24
+ * It deliberately does NOT invent shape. If a v1 value carries payload a v2
25
+ * record cannot hold, that is RFC 0193's named-seat problem and needs a person
26
+ * to name the seat; this function will not paper over it.
27
+ */
28
+
29
+ /** Recursively drop `supported` — at every depth, and out of any `required[]`. */
30
+ export function stripSupported<T>(value: T): T {
31
+ if (Array.isArray(value)) return value.map((v) => stripSupported(v)) as unknown as T;
32
+ if (value === null || typeof value !== 'object') return value;
33
+
34
+ const out: Record<string, unknown> = {};
35
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
36
+ if (k === 'supported') continue;
37
+ out[k] = stripSupported(v);
38
+ }
39
+ // A `required` list naming `supported` keeps a retired field mandatory, which
40
+ // is how a closed v2 record ends up unsatisfiable by any honest host.
41
+ if (Array.isArray(out['required'])) {
42
+ const kept = (out['required'] as unknown[]).filter((r) => r !== 'supported');
43
+ if (kept.length > 0) out['required'] = kept;
44
+ else delete out['required'];
45
+ }
46
+ return out as T;
47
+ }
48
+
49
+ /**
50
+ * True when a v1 value carries payload a uniform v2 record cannot splice —
51
+ * an array, a map, an enum or a scalar rather than an object with properties.
52
+ *
53
+ * RFC 0193: the v2 generator builds a record by splicing the seeded v1
54
+ * property's `properties` in as siblings, so a family that WAS an object kept
55
+ * its payload (`limits`) and one that was an array, a map or an enum lost it
56
+ * SILENTLY. A production host published `supportedEnvelopes: {status:"stable"}`
57
+ * — a stable claim to an envelope-kind catalog containing no catalog.
58
+ *
59
+ * Callers use this to REFUSE rather than to guess. The seat name cannot be
60
+ * derived, because the v1 value *was* the whole property; only a person can
61
+ * name it.
62
+ */
63
+ export function carriesUnspliceablePayload(v1Value: unknown): boolean {
64
+ if (v1Value === null || typeof v1Value !== 'object' || Array.isArray(v1Value)) return false;
65
+ const p = v1Value as Record<string, unknown>;
66
+ const props = p['properties'];
67
+ if (props !== undefined && typeof props === 'object' && props !== null && Object.keys(props).length > 0) return false;
68
+ if (p['type'] === 'boolean') return false; // presence of the record IS the claim
69
+ if (Array.isArray(p['enum'])) return true;
70
+ if (p['type'] === 'array') return true;
71
+ if (p['type'] === 'string' || p['type'] === 'integer' || p['type'] === 'number') return true;
72
+ if (p['type'] === 'object' && typeof p['additionalProperties'] === 'object' && p['additionalProperties'] !== null) return true;
73
+ return false;
74
+ }
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * RFC 0158 — the `durable-single-instance` recovery rows.
3
3
  *
4
- * These are the four rows the RFC names that no scenario has ever existed for.
5
- * `durability/poison-exhaustion` is the fifth and is written
6
- * (`durability-poison-exhaustion.test.ts`) — but it is registered at MAJOR 1
7
- * ONLY, so no v2 bundle has ever carried a durability row of any kind. Porting
8
- * it to major 2 needs the v2 event-log read and is tracked separately; without
9
- * it, the rung is still unwitnessable at major 2 even when these four pass.
4
+ * All five `durable-single-instance` rows the RFC names, four of which no
5
+ * scenario had ever existed for.
6
+ *
7
+ * `durability/poison-exhaustion` DID exist
8
+ * (`durability-poison-exhaustion.test.ts`) but was registered at MAJOR 1 ONLY
9
+ * and read a hard-coded `/v1/host/sample/test/runs/{runId}/events` seam, so no
10
+ * v2 bundle could carry a durability row of any kind and the rung was
11
+ * unwitnessable at major 2 even with the other four passing. The port is the
12
+ * fifth `it` below, and it needs NO SEAM: at major 2 the canonical
13
+ * `GET /runs/{runId}/events` answers the same question the v1 sample seam was
14
+ * invented to answer, so the row can never be `blocked` for want of a seam a
15
+ * host did not wire. The v1 scenario stays where it is, unchanged.
10
16
  *
11
17
  * ── The disposition ruling, which is the load-bearing design decision ────────
12
18
  * §E says an unmet OPERATOR PRECONDITION is `blocked` with the precondition
@@ -51,10 +57,28 @@ import { driver } from '../lib/driver.js';
51
57
  import { v2Discovery } from '../lib/v2.js';
52
58
  import { isFixtureAdvertised } from '../lib/fixtures.js';
53
59
  import { softSkip } from '../lib/soft-skip.js';
54
- import { scaledTimeoutMs } from '../lib/polling.js';
60
+ import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
55
61
  import { req } from '../lib/requirement-ids.js';
56
62
 
57
63
  const FIXTURE = 'conformance-noop';
64
+ const FAILURE_FIXTURE = 'conformance-failure';
65
+
66
+ /** A `node.started` is attempt 1; each `node.retried` is one more. Counting BOTH
67
+ * catches a host that re-dispatches without emitting `node.retried`. */
68
+ const ATTEMPT_TYPES = new Set(['node.started', 'node.retried']);
69
+
70
+ /** Watch window after terminal. A LONGER wait is a STRONGER claim here, because
71
+ * it is a wait for something that must not happen. */
72
+ const QUIET_WINDOW_MS = 4_000;
73
+
74
+ /** The canonical major-2 run-event read. Null when it does not answer. */
75
+ async function runEvents(runId: string): Promise<Array<{ type: string }> | null> {
76
+ const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events`);
77
+ if (r.status !== 200) return null;
78
+ const events = (r.json as { events?: Array<{ type?: unknown }> } | null)?.events;
79
+ if (!Array.isArray(events)) return null;
80
+ return events.filter((e): e is { type: string } => typeof e.type === 'string');
81
+ }
58
82
 
59
83
  /** The host-extension seam these rows drive. Non-normative; advertises nothing. */
60
84
  const KILL_SEAM = '/host/durability/kill';
@@ -240,4 +264,55 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
240
264
  req('openwop.requirement.0158.bound-is-derived', 'RFC 0158 §B.5', `the declared recovery bound MUST follow from the per-class terms that produce it — declared ${bound}, terms sum to ${summed}. A host that states a bound it cannot produce fails; a host whose sweeper wedges still passes, which is why this row MUST NOT be read as evidence that the mechanism runs`),
241
265
  ).toBe(true);
242
266
  }, 120_000);
267
+
268
+ it('deterministically failing work reaches a terminal state and stops being retried', async () => {
269
+ // NO SEAM GATE. The v1 twin reads `/v1/host/sample/test/runs/{runId}/events`
270
+ // and records `blocked` when a host has not wired it — "unobservable, not
271
+ // unmet". At major 2 the canonical run-event read answers the same
272
+ // question, so this row is black-box and cannot be blocked for want of
273
+ // infrastructure. That is the whole reason the port was worth doing rather
274
+ // than dual-majoring the original.
275
+ const doc = await v2Discovery();
276
+ if (!doc) return softSkip('blocked', 'v2 discovery unreachable');
277
+ if (!isFixtureAdvertised(FAILURE_FIXTURE)) {
278
+ return softSkip('inapplicable', `${FAILURE_FIXTURE} fixture not advertised — there is no deterministically failing work to bound`);
279
+ }
280
+
281
+ const create = await driver.post('/runs', { workflowId: FAILURE_FIXTURE });
282
+ if (create.status !== 201) return softSkip('blocked', `POST /runs answered ${create.status} for the failing fixture`);
283
+ const runId = (create.json as { runId: string }).runId;
284
+
285
+ // First clause: a terminal, operator-visible state.
286
+ const terminal = await pollUntilTerminal(runId, { timeoutMs: scaledTimeoutMs(30_000) });
287
+ expect(
288
+ terminal.status,
289
+ req('openwop.requirement.0158.poison-exhaustion', 'RFC 0158 §C.8', `deterministically failing work MUST reach a terminal, operator-visible state — read ${terminal.status}`),
290
+ ).toBe('failed');
291
+
292
+ const before = await runEvents(runId);
293
+ if (before === null) return softSkip('blocked', 'GET /runs/{runId}/events did not answer — attempts are unobservable, so boundedness would be a vacuous claim');
294
+ // Non-vacuity: the failure must actually be ON the log. Without this a host
295
+ // returning an empty array sails through every count comparison below,
296
+ // because 0 === 0 after any wait.
297
+ if (!before.some((e) => e.type === 'node.failed')) {
298
+ return softSkip('blocked', 'the run log records no node.failed — an empty or unprojected log makes every attempt count vacuous');
299
+ }
300
+ const attemptsBefore = before.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
301
+ expect(
302
+ attemptsBefore > 0,
303
+ req('openwop.requirement.0158.poison-exhaustion', 'RFC 0158 §C.8', 'at least one attempt MUST be recorded — zero attempts means nothing was ever delivered, and the bound below would hold vacuously'),
304
+ ).toBe(true);
305
+
306
+ // The load-bearing clause: NOT redelivered indefinitely. "The run reached
307
+ // failed" says nothing about it — a host that redelivers forever ALSO
308
+ // reports a terminal status at some point. Count, wait, count again.
309
+ await new Promise((r) => setTimeout(r, scaledTimeoutMs(QUIET_WINDOW_MS)));
310
+ const after = await runEvents(runId);
311
+ if (after === null) return softSkip('blocked', 'the second GET /runs/{runId}/events did not answer, so the stability comparison has one side');
312
+ const attemptsAfter = after.filter((e) => ATTEMPT_TYPES.has(e.type)).length;
313
+ expect(
314
+ attemptsAfter,
315
+ req('openwop.requirement.0158.poison-exhaustion', 'RFC 0158 §C.8', `attempts MUST NOT continue after the run reports terminal — a host still redelivering records more (${attemptsBefore} before the quiet window, ${attemptsAfter} after)`),
316
+ ).toBe(attemptsBefore);
317
+ }, 120_000);
243
318
  });