@openwop/openwop-conformance 2.1.7 → 2.2.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,109 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.2.1] — 2026-09-16 — RFC 0183's `edit-accept` arm is witnessed, and the MUST the suite already enforced is now the spec's
4
+
5
+ **Why a patch.** `PUBLISHING.md` §"Versioning alignment": new `it` blocks in an
6
+ existing scenario file are a patch; no scenario file is added.
7
+
8
+ ### Added
9
+
10
+ - **`interrupt-approval.test.ts` witnesses the `edit-accept` arm of RFC 0183
11
+ §A.2** — two legs on a new `conformance-approval-edit-accept` fixture: an
12
+ edit-accept resolve MUST round-trip `action: "edit-accept"` and the
13
+ `editedArtifactData` supplied, and an edit-accept resolve with no
14
+ `editedArtifactData` MUST be refused. Until now the refine arm was witnessed
15
+ and the edit-accept arm was stated in `spec/v2/core/interrupt.md` §Approval and
16
+ measured nowhere. Gated on the fixture being advertised, like the refine block;
17
+ a host that does not serve the fixture skips rather than fails.
18
+
19
+ ### Fixed
20
+
21
+ - **`replay-fanout-suppression` declared ~77.5 s of poll budget inside vitest's
22
+ 30 s wall** (openwop #1355). The second and third poll ceilings were
23
+ unreachable, `OPENWOP_POLL_TIMEOUT_SCALE` could not extend the wait (the
24
+ header claimed the opposite), and a slow host and a host that re-fired
25
+ fan-out on a replay fork failed with the same generic vitest timeout. The
26
+ test's wall is now derived from its own scaled budgets plus a 15 s margin,
27
+ so every declared ceiling is reachable and a failure names the predicate. No
28
+ assertion changed.
29
+
30
+ ### Changed (spec side, no assertion change)
31
+
32
+ - **The `payload.action` assertion in the refine leg is no longer stricter than
33
+ the spec.** It asserted a MUST from 2.2.0 while RFC 0183 §A.1 said SHOULD —
34
+ a `COMPATIBILITY.md` §2.3 violation (a suite MUST NOT be stricter than the spec
35
+ on wire shape). The spec was raised, not the assertion lowered: RFC 0183 is now
36
+ `Active` and §A.1 is a MUST conditioned on approval kind, with its normative
37
+ home in `spec/v2/core/interrupt.md` §Events. RFC 0184 is `Active` too; suite
38
+ 2.2.0 had shipped both RFCs' legs enforced against `Draft` headers, and the
39
+ corpus gate now refuses that (`check-rfc-status-coherence` rule 7).
40
+
41
+ ## [2.2.0] — 2026-09-16 — the suite wipes the host's mock program store between scenario files, and a bound id travels as one segment
42
+
43
+ **Why a minor and not the 2.1.8 that was pinned for two days.** `PUBLISHING.md`
44
+ §"Versioning alignment": a conformance scenario addition is a minor bump, and
45
+ #1359 added `v2-bound-id-path-projection` (its header says so). No 2.1.x patch
46
+ ever added a scenario file; this release does, so it is 2.2.0. Nothing was
47
+ published as 2.1.8.
48
+
49
+ This entry was first written on 2026-09-14 and said *"No scenario behaviour
50
+ changes."* That was true when written and false by the time the tag was cut, so
51
+ the entry is rewritten rather than appended to: two behaviour changes rode this
52
+ version after the pin was bumped, and a package whose changelog denies them is a
53
+ package lying about itself.
54
+
55
+ ### Fixed
56
+
57
+ - **The suite now wipes the host's conformance-mock program store between
58
+ scenario FILES** (openwop #1357; half two of two, paired with openwop-app
59
+ #3870, which adds the seam). `src/setup.ts` posts
60
+ `POST /v1/host/openwop-app/test/mock-ai/reset` in a per-file `afterAll`.
61
+ Why: a host that keeps mock programs in a module-level map keyed by `nodeId`
62
+ retains any program a scenario does not fully drain, and the four
63
+ envelope-truncation scenarios seed `finishReason: 'length'` programs on
64
+ purpose — so a later file dispatching on a colliding node consumed the
65
+ leftovers and failed `envelope_truncation_unrecoverable` with nothing in that
66
+ file to explain it. Measured on `replay-observable-sequence-determinism`: red
67
+ in-suite, green alone, five runs on unchanged bases, one red at load 3.4 on an
68
+ idle box — not contention, and a wrong value rather than a missing result.
69
+ 2.1.7's per-fixture node ids narrowed the collision; this removes the
70
+ leftover. **Best-effort by design:** a host without the seam answers 404 and
71
+ the call is swallowed, because a suite must never fail a compliant host for
72
+ lacking a TEST seam — so on such a host the leak, if it has one, persists
73
+ silently. Per file rather than per test because programs are seeded for a
74
+ whole scenario's attempt sequence.
75
+
76
+ ### Changed
77
+
78
+ - **A tenant-bound id travels as one `~`-escaped path segment** (RFC 0184 §A.1,
79
+ openwop #1359; §A.5 exactly-once projection, #1360). A bound id is two
80
+ segments joined by `/`; a path parameter is one. The corpus said `%2F`
81
+ carried the separator, and a tier-1 host's front door decoded it back to `/`
82
+ before forwarding, so every bound id was unreachable through its own front
83
+ door. The escape marker is now `~` (RFC 3986 unreserved — an intermediary has
84
+ no license to rewrite it): every byte outside `[A-Za-z0-9._-]` becomes
85
+ `~XX`, total over bytes so a later grammar widening cannot invalidate a
86
+ projection already on the wire. New `src/lib/bound-id.ts` codec;
87
+ `v2-id-grammar` asserts the new form; new scenario
88
+ `v2-bound-id-path-projection` creates one run and asserts the projection is
89
+ applied exactly once (the codec is deliberately not idempotent — `a~3Ab` →
90
+ `a~7E3Ab` — so a double projection corrupts silently, and both reporting hosts
91
+ found a read path that already composed one). **Hosts: a v2 path parameter
92
+ carrying a bound id is now expected in the `~` form. `%2F` is no longer the
93
+ contract.**
94
+ - **RFC 0183 requirement rows** (openwop #1358): `requirements.json` gains the
95
+ ids for the resume actions `interruptResolved` cannot record, and
96
+ `fixtures.md` documents `conformance-approval-refine`. The RFC is `Draft`; no
97
+ scenario asserts these rows yet.
98
+
99
+ ### Packaging
100
+
101
+ - `spec/v2/declaration.json` gains an optional `normativeText` on family
102
+ entries (RFC 0169, openwop #1351): the path(s) where a family's behaviour is
103
+ actually written, as distinct from `section`, which names the declaration
104
+ site. The declaration and the schemas generated from it ship in the tarball,
105
+ which is what first required the identity bump.
106
+
3
107
  ## [2.1.7] — 2026-09-13 — nine fixtures shared one programmable node id
4
108
 
5
109
  The mock-AI program seam is keyed by `nodeId` alone — no run, no workflow, no
package/README.md CHANGED
@@ -117,7 +117,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
117
117
 
118
118
  ## What's Covered
119
119
 
120
- The current suite has 517 scenario files under `src/scenarios/`.
120
+ The current suite has 518 scenario files under `src/scenarios/`.
121
121
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
122
122
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
123
123
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
@@ -462,7 +462,7 @@ Server-required (added in 1.7.0):
462
462
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
463
463
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
464
464
 
465
- Current source tree: 517 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
465
+ Current source tree: 518 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
466
466
 
467
467
  ## Remaining Gaps
468
468
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.1.7",
4
- "stampSha256": "5594e445dafa6f889a005c00556c2ae241f7a591f45e08eee0b126736bfb04d3"
3
+ "version": "2.2.1",
4
+ "stampSha256": "35a007d7fd7df22f277d9b3c6d58c2a723e3a7312eaafd851a0f771c18c844c5"
5
5
  }
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "conformance-approval-edit-accept",
3
+ "name": "Conformance: Approval (edit-accept)",
4
+ "version": "1.0",
5
+ "description": "Suspends on an approval gate offering accept|reject|edit-accept. Resume with {action:'edit-accept', editedArtifactData:{...}} exercises RFC 0183 \u00a7A.2's edit-accept arm \u2014 the resolved payload must carry the action and the edited artifact data.",
6
+ "nodes": [
7
+ {
8
+ "id": "gate",
9
+ "typeId": "core.approvalGate",
10
+ "name": "Approval Gate",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "title": "Conformance approval (edit-accept-capable)",
17
+ "description": "Conformance suite \u2014 please accept to complete the run.",
18
+ "actions": [
19
+ "accept",
20
+ "reject",
21
+ "edit-accept"
22
+ ]
23
+ },
24
+ "inputs": {}
25
+ }
26
+ ],
27
+ "edges": [],
28
+ "triggers": [
29
+ {
30
+ "id": "manual",
31
+ "type": "manual",
32
+ "enabled": true
33
+ }
34
+ ],
35
+ "variables": [],
36
+ "metadata": {
37
+ "tags": [
38
+ "conformance",
39
+ "hitl"
40
+ ]
41
+ },
42
+ "settings": {
43
+ "timeout": 0
44
+ }
45
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "conformance-approval-refine",
3
+ "name": "Conformance: Approval (refine)",
4
+ "version": "1.0",
5
+ "description": "Suspends on an approval gate offering accept|reject|refine. Resume with {action:'refine', refineFeedback:{...}} exercises RFC 0183 \u00a7A.1/\u00a7A.2 \u2014 the resolved payload must carry the action and its structured feedback.",
6
+ "nodes": [
7
+ {
8
+ "id": "gate",
9
+ "typeId": "core.approvalGate",
10
+ "name": "Approval Gate",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "title": "Conformance approval (refine-capable)",
17
+ "description": "Conformance suite \u2014 please accept to complete the run.",
18
+ "actions": [
19
+ "accept",
20
+ "reject",
21
+ "refine"
22
+ ]
23
+ },
24
+ "inputs": {}
25
+ }
26
+ ],
27
+ "edges": [],
28
+ "triggers": [
29
+ {
30
+ "id": "manual",
31
+ "type": "manual",
32
+ "enabled": true
33
+ }
34
+ ],
35
+ "variables": [],
36
+ "metadata": {
37
+ "tags": [
38
+ "conformance",
39
+ "hitl"
40
+ ]
41
+ },
42
+ "settings": {
43
+ "timeout": 0
44
+ }
45
+ }
package/fixtures.md CHANGED
@@ -47,6 +47,8 @@ All fixtures MUST advertise:
47
47
  | Delay | `conformance-delay` | Verifies poll/SSE behavior over time | `completed` | ≤ 30s (input-controlled) |
48
48
  | Failure | `conformance-failure` | Verifies error-event surface | `failed` | ≤ 5s |
49
49
  | Approval | `conformance-approval` | Verifies HITL approval interrupt + resume | `completed` after resolve | unbounded (suspends) |
50
+ | Approval (refine) | `conformance-approval-refine` | RFC 0183 — refine resolution carries action + refineFeedback |
51
+ | Approval (edit-accept) | `conformance-approval-edit-accept` | RFC 0183 — edit-accept resolution carries action + editedArtifactData |
50
52
  | Clarification | `conformance-clarification` | Verifies HITL clarification interrupt + resume | `completed` after resolve | unbounded (suspends) |
51
53
  | Multi-node | `conformance-multi-node` | Verifies edge ordering + per-node events | `completed` | ≤ 10s |
52
54
  | Idempotent | `conformance-idempotent` | Verifies `Idempotency-Key` cache | `completed` | ≤ 5s |
@@ -177,6 +179,32 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
177
179
  - **Terminal status (after accept)**: `completed`.
178
180
  - **Resolve schema**: `{action: "accept" | "reject"}`. Server MUST reject any other shape with 400.
179
181
 
182
+ ### `conformance-approval-refine`
183
+
184
+ - **Purpose**: verify a `refine` resolution round-trips its action and structured feedback (RFC 0183 §A.1/§A.2).
185
+ - **Inputs**: none.
186
+ - **Behavior**:
187
+ 1. Run starts and reaches an `approvalGate` offering `accept | reject | refine`.
188
+ 2. Run status MUST be `waiting-approval`.
189
+ 3. Client POSTs `{action: 'refine', refineFeedback: {scope: 'whole', ...}}` to the interrupt.
190
+ 4. The resolved event payload MUST carry `action: 'refine'` and the `refineFeedback` supplied.
191
+ 5. A `refine` resolution supplying no `refineFeedback` MUST be refused.
192
+ - **Why it is separate from `conformance-approval`**: adding `refine` to that fixture's `actions` would change a
193
+ registered workflow definition every host already serves, forcing a re-registration for a test-only widening.
194
+ A new id costs one catalog row and no host churn.
195
+
196
+ ### `conformance-approval-edit-accept`
197
+
198
+ - **Purpose**: verify an `edit-accept` resolution round-trips its action and the edited artifact (RFC 0183 §A.2, the arm `conformance-approval-refine` does not reach).
199
+ - **Inputs**: none.
200
+ - **Behavior**:
201
+ 1. Run starts and reaches an `approvalGate` offering `accept | reject | edit-accept`.
202
+ 2. Run status MUST be `waiting-approval`.
203
+ 3. Client POSTs `{action: 'edit-accept', editedArtifactData: {...}}` to the interrupt.
204
+ 4. The resolved event payload MUST carry `action: 'edit-accept'` and the `editedArtifactData` supplied.
205
+ 5. An `edit-accept` resolution supplying no `editedArtifactData` MUST be refused.
206
+ - **Why it is separate**: same reason as `conformance-approval-refine` — widening a registered fixture's `actions` forces every host to re-register a test-only change.
207
+
180
208
  ### `conformance-clarification`
181
209
 
182
210
  - **Purpose**: verify HITL clarification interrupt + resume.
@@ -487,6 +515,8 @@ conformance/
487
515
  conformance-delay.json
488
516
  conformance-failure.json
489
517
  conformance-approval.json
518
+ conformance-approval-refine.json
519
+ conformance-approval-edit-accept.json
490
520
  conformance-clarification.json
491
521
  conformance-multi-node.json
492
522
  conformance-idempotent.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.1.7",
3
+ "version": "2.2.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.1.7"
59
+ "@openwop/spec-artifacts": "2.2.1"
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": 566,
6
- "tests": 2156,
7
- "withStableId": 2156,
5
+ "files": 567,
6
+ "tests": 2161,
7
+ "withStableId": 2161,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2111
9
+ "explicitIds": 2116
10
10
  },
11
11
  "records": [
12
12
  {
@@ -14544,7 +14544,7 @@
14544
14544
  {
14545
14545
  "id": "openwop.it.interrupt-approval.run-suspends-at-gate-accept-resolution-drives-terminal-completed",
14546
14546
  "file": "interrupt-approval.test.ts",
14547
- "line": 21,
14547
+ "line": 27,
14548
14548
  "title": "run suspends at gate, accept resolution drives terminal completed",
14549
14549
  "explicitId": "openwop.it.interrupt-approval.run-suspends-at-gate-accept-resolution-drives-terminal-completed",
14550
14550
  "citations": [
@@ -14565,7 +14565,7 @@
14565
14565
  {
14566
14566
  "id": "openwop.it.interrupt-approval.400-or-422-when-action-is-not-in-accept-reject",
14567
14567
  "file": "interrupt-approval.test.ts",
14568
- "line": 50,
14568
+ "line": 56,
14569
14569
  "title": "400 (or 422) when action is not in {accept, reject}",
14570
14570
  "explicitId": "openwop.it.interrupt-approval.400-or-422-when-action-is-not-in-accept-reject",
14571
14571
  "citations": [
@@ -14578,7 +14578,7 @@
14578
14578
  {
14579
14579
  "id": "openwop.it.interrupt-approval.400-404-when-nodeid-does-not-match-an-active-interrupt",
14580
14580
  "file": "interrupt-approval.test.ts",
14581
- "line": 78,
14581
+ "line": 84,
14582
14582
  "title": "400/404 when nodeId does not match an active interrupt",
14583
14583
  "explicitId": "openwop.it.interrupt-approval.400-404-when-nodeid-does-not-match-an-active-interrupt",
14584
14584
  "citations": [
@@ -14588,6 +14588,82 @@
14588
14588
  }
14589
14589
  ]
14590
14590
  },
14591
+ {
14592
+ "id": "openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires",
14593
+ "file": "interrupt-approval.test.ts",
14594
+ "line": 120,
14595
+ "title": "a refine resolve round-trips `action` and the structured feedback it requires",
14596
+ "explicitId": "openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires",
14597
+ "citations": [
14598
+ {
14599
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
14600
+ "requirement": "a gate offering `refine` MUST accept a refine resolution carrying refineFeedback"
14601
+ },
14602
+ {
14603
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
14604
+ "requirement": "the resolution MUST be recorded as an event"
14605
+ },
14606
+ {
14607
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
14608
+ "requirement": "the resolved payload MUST carry `action: \"refine\"` — the seat RFC 0183 adds"
14609
+ },
14610
+ {
14611
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
14612
+ "requirement": "a refine resolution MUST carry the refineFeedback it was given — an action whose meaning is incomplete without it"
14613
+ }
14614
+ ]
14615
+ },
14616
+ {
14617
+ "id": "openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback",
14618
+ "file": "interrupt-approval.test.ts",
14619
+ "line": 170,
14620
+ "title": "refuses a refine resolution that supplies no refineFeedback",
14621
+ "explicitId": "openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback",
14622
+ "citations": [
14623
+ {
14624
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
14625
+ "requirement": "refine without refineFeedback MUST be refused — the feedback is what the action means"
14626
+ }
14627
+ ]
14628
+ },
14629
+ {
14630
+ "id": "openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires",
14631
+ "file": "interrupt-approval.test.ts",
14632
+ "line": 197,
14633
+ "title": "an edit-accept resolve round-trips `action` and the edited artifact it requires",
14634
+ "explicitId": "openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires",
14635
+ "citations": [
14636
+ {
14637
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
14638
+ "requirement": "a gate offering `edit-accept` MUST accept an edit-accept resolution carrying editedArtifactData"
14639
+ },
14640
+ {
14641
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
14642
+ "requirement": "the resolution MUST be recorded as an event"
14643
+ },
14644
+ {
14645
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.1",
14646
+ "requirement": "the resolved payload MUST carry `action: \"edit-accept\"`"
14647
+ },
14648
+ {
14649
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
14650
+ "requirement": "an edit-accept resolution MUST carry the editedArtifactData it was given"
14651
+ }
14652
+ ]
14653
+ },
14654
+ {
14655
+ "id": "openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata",
14656
+ "file": "interrupt-approval.test.ts",
14657
+ "line": 247,
14658
+ "title": "refuses an edit-accept resolution that supplies no editedArtifactData",
14659
+ "explicitId": "openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata",
14660
+ "citations": [
14661
+ {
14662
+ "section": "RFCS/0183-interrupt-resolved-action-fidelity.md §A.2",
14663
+ "requirement": "edit-accept without editedArtifactData MUST be refused — the edit is what the action means"
14664
+ }
14665
+ ]
14666
+ },
14591
14667
  {
14592
14668
  "id": "openwop.it.interrupt-approver-routing.capabilities-schema-declares-interrupt-approverrouting",
14593
14669
  "file": "interrupt-approver-routing.test.ts",
@@ -21620,7 +21696,7 @@
21620
21696
  {
21621
21697
  "id": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
21622
21698
  "file": "replay-fanout-suppression.test.ts",
21623
- "line": 159,
21699
+ "line": 205,
21624
21700
  "title": "delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork",
21625
21701
  "explicitId": "openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br",
21626
21702
  "citations": [
@@ -25930,6 +26006,49 @@
25930
26006
  }
25931
26007
  ]
25932
26008
  },
26009
+ {
26010
+ "id": "openwop.it.v2-bound-id-path-projection.a-tenant-bound-id-is-readable-at-its-escaped-path-segment-links-carry-that-form",
26011
+ "file": "v2-bound-id-path-projection.test.ts",
26012
+ "line": 51,
26013
+ "title": "a tenant-bound id is readable at its ~-escaped path segment, links carry that form, and a malformed escape is refused",
26014
+ "explicitId": "openwop.requirement.0184.bound-id-path-projection",
26015
+ "citations": [
26016
+ {
26017
+ "section": "spec/v2/core/identity.md §5",
26018
+ "requirement": null,
26019
+ "interpolated": true
26020
+ },
26021
+ {
26022
+ "section": "spec/v2/core/identity.md §5",
26023
+ "requirement": null,
26024
+ "interpolated": true
26025
+ },
26026
+ {
26027
+ "section": "spec/v2/core/identity.md §5",
26028
+ "requirement": "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"
26029
+ },
26030
+ {
26031
+ "section": "spec/v2/core/identity.md §5",
26032
+ "requirement": null,
26033
+ "interpolated": true
26034
+ },
26035
+ {
26036
+ "section": "spec/v2/core/identity.md §5",
26037
+ "requirement": null,
26038
+ "interpolated": true
26039
+ },
26040
+ {
26041
+ "section": "spec/v2/core/identity.md §5",
26042
+ "requirement": null,
26043
+ "interpolated": true
26044
+ },
26045
+ {
26046
+ "section": "spec/v2/core/identity.md §5",
26047
+ "requirement": null,
26048
+ "interpolated": true
26049
+ }
26050
+ ]
26051
+ },
25933
26052
  {
25934
26053
  "id": "openwop.it.v2-bundle-v3-signed.a-signed-v3-bundle-validates-against-the-closed-root-schema-an-extra-root-key-is",
25935
26054
  "file": "v2-bundle-v3-signed.test.ts",
@@ -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": 517,
4
+ "files": 518,
5
5
  "v1": 445,
6
- "v2": 74
6
+ "v2": 75
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1244,6 +1244,9 @@
1244
1244
  "v2-assurance-downgrade-audited.test.ts": [
1245
1245
  2
1246
1246
  ],
1247
+ "v2-bound-id-path-projection.test.ts": [
1248
+ 2
1249
+ ],
1247
1250
  "v2-bundle-v3-signed.test.ts": [
1248
1251
  2
1249
1252
  ],
@@ -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.1.7",
5
- "corpusTag": "v2.1.7",
4
+ "version": "2.2.1",
5
+ "corpusTag": "v2.2.1",
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": "a848487c183fff6a6f9117932245c2c7765b5b7da8cad1d0a6d02e669ef1b40c",
13
- "api/v2/asyncapi.yaml": "66440a6d3f136c5d12c9ebe4dad80e993f7d8fcf5c6203a5694f06ce45f977df",
14
- "api/v2/openapi.yaml": "0ca4bdcc6572f427369e1d55b7d3e981668cc424f4bc84f314d6d25170964d51",
12
+ "api/seams-v2.yaml": "a309a537a5b0aaa20c5f496943bb8cc6642b143eb27b7481ee0b9e1030d00bfb",
13
+ "api/v2/asyncapi.yaml": "1f7023d581d02dba0427630dfd89ec890941ecbbc31bf8f09ce7ec8b965988b2",
14
+ "api/v2/openapi.yaml": "f26603003e562b928c148ebaebe49b93c9bd35c01e0996611311114908858e5d",
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": "37cdbdb5038e148056c104a49b8d28a9668479e06d401309d354a8b0a70921a3",
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": "05c98e3f8a72fa79be123765bd9c2ea7c6620188d69df8524160399225316cd6",
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": "1d3ccffc4e506d3109c17990e01976ba935e61672562684b9635743d56af3f57",
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": "de593dd613393b6d93d90c8e76032466aaa68379a53eaf274fcd921264e51ead",
203
+ "spec/v1/core-standard-manifest.json": "9d76a090508697628031972795c813a12ff83b53ed1dff2dbaabe14d853b90c2",
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": "d52caf29071e3784d4c20259cdbd5a7b533986548a440bb632a80b82cadd7ea5",
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,18 +212,18 @@
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": "e3d8f6a13338014c349e57bf2b50a398aa1e0aa162c5adda0273e26dd6f48acd",
215
+ "spec/v2/README.md": "0f8a5c36ca9af5c6aa80b2e24ecf2d6af5ede9964bf70e88839110d9beab5693",
216
216
  "spec/v2/core/capabilities.md": "c5107a4964e487c20bbb5254cfe1f706e0d9ba7e34da9070bc43b50b385e7948",
217
217
  "spec/v2/core/conformance.md": "622084e3c74c4549087f4d861b1bb98cec201a0c5aec23788d27f53a355fde3b",
218
218
  "spec/v2/core/connection-packs.md": "1d759681e2962f5103228c52c45f24607ed4bb9ae73e30605f690c1d5a59e9ee",
219
219
  "spec/v2/core/errors.md": "74fdb5862f432a831cf0c2eadbe0c8cca6b2660ec578180ae33db54773a1df28",
220
- "spec/v2/core/events.md": "defe0969d6ec7c980f415cc0d78b9d065782ac7e5638b12a44aa4895a35d6126",
220
+ "spec/v2/core/events.md": "3b7d1ad34f9e438c9825d60c0114b9a21c82a468f15ccbd7567022641a4537ad",
221
221
  "spec/v2/core/form-content-packs.md": "6ffaae85d4550be48dd53fb6e2959af8aceb35bd87c5343b722885084f197713",
222
- "spec/v2/core/headers.md": "a795c8cd3e5f004b05dad9603d14b92b639a24ccd23b183716cb01097a106119",
222
+ "spec/v2/core/headers.md": "2dd67d71673539e16612f09f3f2513a384f6566052d7cd8a00885885689854c2",
223
223
  "spec/v2/core/idempotency.md": "b86c352d964aebad532727f314e26ed2177d49de382076fa64d3162e2f56118d",
224
- "spec/v2/core/identity.md": "dd5299f9cdf54640870e1fffae11edb820cf6396ea298c4ebd14c649c4bd4a87",
224
+ "spec/v2/core/identity.md": "9d2b823eb899463807452fe5721dcf09881837c161be024175957db27870b243",
225
225
  "spec/v2/core/interop.md": "d4c404e469e1d9d9be88bc2c939d777e7d7c1d0419b1251c18770f039838a401",
226
- "spec/v2/core/interrupt.md": "d872e0652a938594eeb2a19e86cf64e64e29b234f5d9c4fbe2970ab5fb191b9c",
226
+ "spec/v2/core/interrupt.md": "f525479a99104655219fa2e51c4cad2a03d8e3be93ff4dac173a684675e57348",
227
227
  "spec/v2/core/overview.md": "69377c90bbb87b6bfe4b8be4f1b65810094b0759c8d4f93ca6df92cc530ea023",
228
228
  "spec/v2/core/packs.md": "e72a7586a61630cdfd342eaf84e989f864b3e199006f5511ef59ea7c1a1c56f7",
229
229
  "spec/v2/core/persistence.md": "2373c2815b25b7b4fe29dfdf3783d3f2f3e637d3abce5085e69f019546302fb6",
@@ -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": "63dd351a87439241bc744ce62fdc498b7621292c11a4ad42c3f41429d72366b9",
237
- "spec/v2/declaration.schema.json": "eac5f8080bd572f147bd57d0c4ac8ad73b14536d9236222490ae29a33b21e184",
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": "ce2d0ca085d7668d7ef745d2560b385ec6881d24a6d4b47c49931986b2ba642d",
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": "d9d1f8e266660b581f11020b9511a65c677e026b1a71ada870682ad77e18555d",
269
+ "spec/v2/id-field-bindings.json": "eecadfd11581ba2e075ccb320d85fc033b816edfdd51edb5c5727a181e55dcb4",
270
270
  "spec/v2/path-manifest.json": "a6f4e652e866889e8bde815874062f62929aee95dc8e58e7152c56adee262163",
271
- "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
271
+ "spec/v2/peer-dependency-aliases.json": "10fcab53c65305e9b6bae4a40d30176091dca68739ac8a78291510c77b50ca6b",
272
272
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
273
- "spec/v2/release.json": "2bacef4931ec682b7bf003746eb1f69e3b945bbb93b98d76ddf86128e9922be5",
273
+ "spec/v2/release.json": "ee596fce1d27e7ec3f7a061b5705ca8022158adee5ba1aa7f321ee9abd97e9fb",
274
274
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
275
275
  },
276
- "corpusCommit": "4c50dd0dc8b411090dfeeb3a5bbf9466474807c9"
276
+ "corpusCommit": "a27e18972293865c72e4a776426718be6e2d77cc"
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}$/;
@@ -17,6 +17,12 @@ 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
+
23
+ const EDIT_ACCEPT_WORKFLOW_ID = 'conformance-approval-edit-accept';
24
+ const SKIP_NO_EDIT_ACCEPT = !isFixtureAdvertised(EDIT_ACCEPT_WORKFLOW_ID);
25
+
20
26
  describe.skipIf(SKIP_NO_FIXTURE)('interrupt: approval accept resumes to `completed`', () => {
21
27
  it('run suspends at gate, accept resolution drives terminal completed', async () => {
22
28
  const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
@@ -96,3 +102,164 @@ describe.skipIf(SKIP_NO_FIXTURE)('interrupt: resolving an unknown interrupt retu
96
102
  });
97
103
  });
98
104
  });
105
+
106
+ /**
107
+ * RFC 0183 §A.1/§A.2 — a `refine` resolution must be recordable.
108
+ *
109
+ * Before RFC 0183, `interruptResolved` was `additionalProperties: false` with no
110
+ * seat for the resume action and no seat for the feedback a refine REQUIRES, and
111
+ * `decision` was an unconstrained string whose documented vocabulary is a
112
+ * different axis (governance: granted|rejected|overridden). A host resolving by
113
+ * refine could record neither what the approver did nor the feedback they gave.
114
+ *
115
+ * Uses its own fixture rather than widening `conformance-approval`, because
116
+ * adding `refine` to that fixture's `actions` would change a registered workflow
117
+ * definition every host already serves.
118
+ */
119
+ describe.skipIf(SKIP_NO_REFINE)('interrupt: refine resolution carries action + refineFeedback (RFC 0183)', () => {
120
+ it('a refine resolve round-trips `action` and the structured feedback it requires', async () => {
121
+ const create = await driver.post('/v1/runs', { workflowId: REFINE_WORKFLOW_ID });
122
+ expect(create.status).toBe(201);
123
+ const runId = (create.json as { runId: string }).runId;
124
+
125
+ const suspended = await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
126
+ expect(suspended.currentNodeId).toBe(NODE_ID);
127
+
128
+ const refineFeedback = { scope: 'whole', text: 'conformance: please tighten the summary' };
129
+ const resolve = await driver.post(
130
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
131
+ { resumeValue: { action: 'refine', refineFeedback } },
132
+ );
133
+ expect(
134
+ resolve.status,
135
+ req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
136
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
137
+ 'a gate offering `refine` MUST accept a refine resolution carrying refineFeedback',
138
+ ),
139
+ ).toBe(200);
140
+
141
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
142
+ expect(events.status).toBe(200);
143
+ const rows = ((events.json as { events?: Array<Record<string, unknown>> } | undefined)?.events ?? []);
144
+ const resolved = rows.find((e) => String(e.type).endsWith('interrupt.resolved') || String(e.type).endsWith('approval.received'));
145
+ expect(
146
+ resolved,
147
+ req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
148
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
149
+ 'the resolution MUST be recorded as an event',
150
+ ),
151
+ ).toBeDefined();
152
+
153
+ const payload = (resolved?.payload ?? {}) as Record<string, unknown>;
154
+ expect(
155
+ payload.action,
156
+ req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
157
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
158
+ 'the resolved payload MUST carry `action: "refine"` — the seat RFC 0183 adds',
159
+ ),
160
+ ).toBe('refine');
161
+ expect(
162
+ payload.refineFeedback,
163
+ req('openwop.it.interrupt-approval.a-refine-resolve-round-trips-action-and-the-structured-feedback-it-requires',
164
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
165
+ 'a refine resolution MUST carry the refineFeedback it was given — an action whose meaning is incomplete without it',
166
+ ),
167
+ ).toBeTruthy();
168
+ });
169
+
170
+ it('refuses a refine resolution that supplies no refineFeedback', async () => {
171
+ const create = await driver.post('/v1/runs', { workflowId: REFINE_WORKFLOW_ID });
172
+ expect(create.status).toBe(201);
173
+ const runId = (create.json as { runId: string }).runId;
174
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
175
+
176
+ const resolve = await driver.post(
177
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
178
+ { resumeValue: { action: 'refine' } },
179
+ );
180
+ expect(
181
+ [400, 422].includes(resolve.status),
182
+ req('openwop.it.interrupt-approval.refuses-a-refine-resolution-that-supplies-no-refinefeedback',
183
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
184
+ 'refine without refineFeedback MUST be refused — the feedback is what the action means',
185
+ ),
186
+ ).toBe(true);
187
+ });
188
+ });
189
+
190
+ /**
191
+ * RFC 0183 §A.2, the `edit-accept` arm — an edit-accept resolution must be
192
+ * recordable with the artifact the approver edited. The refine block above
193
+ * witnesses one of §A.2's two conditional MUSTs; until this block the other was
194
+ * stated in `spec/v2/core/interrupt.md` §Approval and witnessed nowhere.
195
+ */
196
+ describe.skipIf(SKIP_NO_EDIT_ACCEPT)('interrupt: edit-accept resolution carries action + editedArtifactData (RFC 0183)', () => {
197
+ it('an edit-accept resolve round-trips `action` and the edited artifact it requires', async () => {
198
+ const create = await driver.post('/v1/runs', { workflowId: EDIT_ACCEPT_WORKFLOW_ID });
199
+ expect(create.status).toBe(201);
200
+ const runId = (create.json as { runId: string }).runId;
201
+
202
+ const suspended = await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
203
+ expect(suspended.currentNodeId).toBe(NODE_ID);
204
+
205
+ const editedArtifactData = { summary: 'conformance: edited by the approver' };
206
+ const resolve = await driver.post(
207
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
208
+ { resumeValue: { action: 'edit-accept', editedArtifactData } },
209
+ );
210
+ expect(
211
+ resolve.status,
212
+ req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
213
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
214
+ 'a gate offering `edit-accept` MUST accept an edit-accept resolution carrying editedArtifactData',
215
+ ),
216
+ ).toBe(200);
217
+
218
+ const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
219
+ expect(events.status).toBe(200);
220
+ const rows = ((events.json as { events?: Array<Record<string, unknown>> } | undefined)?.events ?? []);
221
+ const resolved = rows.find((e) => String(e.type).endsWith('interrupt.resolved') || String(e.type).endsWith('approval.received'));
222
+ expect(
223
+ resolved,
224
+ req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
225
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
226
+ 'the resolution MUST be recorded as an event',
227
+ ),
228
+ ).toBeDefined();
229
+
230
+ const payload = (resolved?.payload ?? {}) as Record<string, unknown>;
231
+ expect(
232
+ payload.action,
233
+ req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
234
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.1',
235
+ 'the resolved payload MUST carry `action: "edit-accept"`',
236
+ ),
237
+ ).toBe('edit-accept');
238
+ expect(
239
+ payload.editedArtifactData,
240
+ req('openwop.it.interrupt-approval.an-edit-accept-resolve-round-trips-action-and-the-edited-artifact-it-requires',
241
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
242
+ 'an edit-accept resolution MUST carry the editedArtifactData it was given',
243
+ ),
244
+ ).toEqual(editedArtifactData);
245
+ });
246
+
247
+ it('refuses an edit-accept resolution that supplies no editedArtifactData', async () => {
248
+ const create = await driver.post('/v1/runs', { workflowId: EDIT_ACCEPT_WORKFLOW_ID });
249
+ expect(create.status).toBe(201);
250
+ const runId = (create.json as { runId: string }).runId;
251
+ await pollUntilStatus(runId, 'waiting-approval', { timeoutMs: 10_000 });
252
+
253
+ const resolve = await driver.post(
254
+ `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(NODE_ID)}`,
255
+ { resumeValue: { action: 'edit-accept' } },
256
+ );
257
+ expect(
258
+ [400, 422].includes(resolve.status),
259
+ req('openwop.it.interrupt-approval.refuses-an-edit-accept-resolution-that-supplies-no-editedartifactdata',
260
+ 'RFCS/0183-interrupt-resolved-action-fidelity.md §A.2',
261
+ 'edit-accept without editedArtifactData MUST be refused — the edit is what the action means',
262
+ ),
263
+ ).toBe(true);
264
+ });
265
+ });
@@ -36,6 +36,24 @@
36
36
  * operator raising the scale on a slow host makes this scenario harder to pass,
37
37
  * not easier.
38
38
  *
39
+ * CORRECTED 2026-09-15 — the paragraph above described a property this file did
40
+ * not have, and argued for it at length, which is the most expensive kind of
41
+ * stale comment. The scale knob DID multiply every poll and quiet window, and it
42
+ * did NOT touch vitest's own `testTimeout` (30_000, `conformance/vitest.config.ts`).
43
+ * The declared budgets in this one test already summed to ~77.5 s — 10 s + 30 s +
44
+ * 30 s of polling plus three quiet windows — so at scale 1 the second and third
45
+ * poll budgets were UNREACHABLE, and raising the scale made the test die at the
46
+ * same 30 s wall having spent even less of the window it was told to wait.
47
+ *
48
+ * So the knob could not make this scenario harder to pass. It could only make it
49
+ * fail sooner, on the clock, with vitest's generic "Test timed out in 30000ms" in
50
+ * place of `pollUntil`'s message naming the run, the predicate and the last
51
+ * status. A host that genuinely re-fired on replay and a host that was merely
52
+ * slow produced the same output.
53
+ *
54
+ * `TEST_TIMEOUT_MS` below now derives the test's own wall clock from the same
55
+ * budgets and the same scale, so the paragraph above is true as written.
56
+ *
39
57
  * ── Deliberately NOT in the `openwop-replay-fork` floor ──────────────────────
40
58
  * This scenario needs the host to accept a loopback receiver. A host with an
41
59
  * SSRF guard on `POST /v1/webhooks` correctly refuses one, and the suite's
@@ -155,6 +173,34 @@ const DELIVERY_GRACE_MS = 1_500;
155
173
  */
156
174
  const SUPPRESSION_WINDOW_MS = 6_000;
157
175
 
176
+ /** Poll ceilings, named so the test's own wall clock can be derived from them
177
+ * rather than guessed alongside them. */
178
+ const SOURCE_POLL_MS = 10_000;
179
+ const FORK_POLL_MS = 30_000;
180
+
181
+ /**
182
+ * THE TEST'S OWN WALL CLOCK, and it must be computed, not defaulted.
183
+ *
184
+ * Every budget in this file is a claim about how long a legitimate host may
185
+ * take. A `testTimeout` smaller than their sum does not tighten those claims —
186
+ * it DELETES them, silently, because vitest kills the test before the budget it
187
+ * was given can be spent. The suite default (30_000) is smaller than the first
188
+ * two budgets alone.
189
+ *
190
+ * It scales with `scaledTimeoutMs` for the same reason: an operator who raises
191
+ * `OPENWOP_POLL_TIMEOUT_SCALE` is saying "this host is slow, wait longer". A
192
+ * scaled poll budget under an unscaled wall clock turns that instruction into
193
+ * its opposite. Both move together or neither should.
194
+ *
195
+ * The flat slack covers the run submissions, receiver registration and
196
+ * assertions between the waits — work with no budget of its own. It is added
197
+ * AFTER scaling deliberately: it is fixed overhead, not host latency, so
198
+ * multiplying it would inflate the ceiling for a reason that is not the host's.
199
+ */
200
+ const TEST_TIMEOUT_MS =
201
+ scaledTimeoutMs(SOURCE_POLL_MS + FORK_POLL_MS * 2 + DELIVERY_GRACE_MS * 2 + SUPPRESSION_WINDOW_MS)
202
+ + 15_000;
203
+
158
204
  describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted events', () => {
159
205
  it('delivers for a live run, suppresses for a replay fork, and delivers again for a branch fork', async (ctx) => {
160
206
  const disco = await driver.get('/.well-known/openwop');
@@ -204,7 +250,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
204
250
  const create = await driver.post('/v1/runs', { workflowId: 'conformance-noop' });
205
251
  expect(create.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'webhooks.md §"Register"', 'failed to start conformance-noop')).toBe(201);
206
252
  const sourceRunId = (create.json as { runId: string }).runId;
207
- await pollUntilTerminal(sourceRunId, { timeoutMs: 10_000 });
253
+ await pollUntilTerminal(sourceRunId, { timeoutMs: SOURCE_POLL_MS });
208
254
  await quietWindow(DELIVERY_GRACE_MS);
209
255
 
210
256
  expect(
@@ -267,7 +313,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
267
313
  }
268
314
  expect(replay.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'webhooks.md §"Register"', 'replay fork should be accepted')).toBe(201);
269
315
  const replayRunId = (replay.json as { runId: string }).runId;
270
- await pollUntilTerminal(replayRunId, { timeoutMs: 30_000 });
316
+ await pollUntilTerminal(replayRunId, { timeoutMs: FORK_POLL_MS });
271
317
  await quietWindow(SUPPRESSION_WINDOW_MS);
272
318
 
273
319
  expect(
@@ -315,7 +361,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
315
361
  }
316
362
  expect(branch.status, req('openwop.it.replay-fanout-suppression.delivers-for-a-live-run-suppresses-for-a-replay-fork-and-delivers-again-for-a-br', 'replay.md §"Host-initiated fan-out is an external effect"', 'branch fork should be accepted')).toBe(201);
317
363
  const branchRunId = (branch.json as { runId: string }).runId;
318
- await pollUntilTerminal(branchRunId, { timeoutMs: 30_000 });
364
+ await pollUntilTerminal(branchRunId, { timeoutMs: FORK_POLL_MS });
319
365
  await quietWindow(DELIVERY_GRACE_MS);
320
366
 
321
367
  expect(
@@ -326,5 +372,7 @@ describe('replay-fanout-suppression: a replay fork MUST NOT fan out re-emitted e
326
372
  + 'means the host keyed on "is a fork" rather than on replay-ness read from the run',
327
373
  ),
328
374
  ).toBeGreaterThan(0);
329
- });
375
+ // The third positional argument is the whole point of this fix: without it
376
+ // the suite default (30_000) silently overrides every budget above.
377
+ }, TEST_TIMEOUT_MS);
330
378
  });
@@ -0,0 +1,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; }
package/src/setup.ts CHANGED
@@ -25,6 +25,7 @@
25
25
  * `it` here; vitest treats setupFiles differently from scenario files.
26
26
  */
27
27
 
28
+ import { driver } from './lib/driver.js';
28
29
  import { setAdvertisedFixtures, setDiscoveryUnreadable } from './lib/fixtures.js';
29
30
  import { setMultiAgentCapabilities } from './lib/multi-agent-capabilities.js';
30
31
  import { OtelCollector, setCollector } from './lib/otel-collector.js';
@@ -351,6 +352,43 @@ beforeEach(({ task }) => {
351
352
  _itSoftSkipMarks.set(file, softSkipMark()); // rc.56: this test's own softSkip window
352
353
  takeExplicitRequirementId(); // clear any override left by a test that threw before afterEach
353
354
  });
355
+ /**
356
+ * Wipe the host's conformance-mock program store between scenario FILES.
357
+ *
358
+ * WHY THIS IS NECESSARY AND WAS MISSING. The host keeps the mock's programs in a
359
+ * module-level map keyed by `nodeId`, with a cursor. Several scenarios here
360
+ * deliberately seed programs that return `finishReason: 'length'` to exercise
361
+ * RFC 0033 truncation handling — `envelope-truncation-cap-exhaustion`,
362
+ * `envelope-truncated`, `envelope-retry-exhausted`,
363
+ * `envelope-completion-distinguishes-truncation`. A program that a scenario does
364
+ * not fully DRAIN stays pending for the life of the host process.
365
+ *
366
+ * This suite had no way to clear it. The host exposed a seam to SEED
367
+ * (`POST …/test/mock-ai/program`) and none to reset, while the host-side
368
+ * `resetMockPrograms` carried a header claiming it was "called between
369
+ * conformance scenarios" — it was called only by the host's own unit tests.
370
+ *
371
+ * The failure it produced accuses the wrong file: a later scenario dispatching
372
+ * on a colliding nodeId consumes the leftover truncation entries, its run fails
373
+ * `envelope_truncation_unrecoverable`, and nothing in that scenario explains it.
374
+ * MEASURED on `replay-observable-sequence-determinism`: red in-suite, green
375
+ * alone, five runs across unchanged bases, one of the reds at load1 3.4 on an
376
+ * idle box — so not contention, and a WRONG VALUE rather than a missing result.
377
+ *
378
+ * Per FILE rather than per test: programs are seeded for a whole scenario's
379
+ * sequence and a mid-file wipe would break the multi-attempt scenarios this
380
+ * store exists to serve. Best-effort — a host that predates the reset seam
381
+ * answers 404, and a conformance suite must not fail a compliant host for
382
+ * lacking a TEST seam.
383
+ */
384
+ afterAll(async () => {
385
+ try {
386
+ await driver.post('/v1/host/openwop-app/test/mock-ai/reset', {});
387
+ } catch {
388
+ /* seam absent or host down — see above; never fail a scenario for this */
389
+ }
390
+ });
391
+
354
392
  afterEach(({ task }) => {
355
393
  const file = _fileOf(task as { file?: { filepath?: string; name?: string } });
356
394
  if (file === null) return;