@openwop/openwop-conformance 2.44.2 → 2.44.4

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,22 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.44.4] — 2026-09-29 — six more core families get v2 normative homes, replay derives a rejection and counts terminals, and the replay fixtures wait on a pure hold node
4
+
5
+ - **`0223.replay-derives-rejection`: replay derives a rejection, never re-decides it** (#1756). Two new legs in `v2-approval-reject-disposition` (target major 2), gated on `replay` advertising mode `replay` and on each fixture. The leg forks `mode: "replay"` at the gate's recorded `interrupt.resolved`, so the prefix holds the request and suspension and the fork must supply the resolution. It asserts four things. (i) No new `interrupt.requested` / `approval.requested` for the gate at or after `fromSeq`; this is the discriminator. (ii) The fork's `interrupt.resolved` carries the source's `decision`, `action` and `reason`. (iii) The fork fails the same way: `approval_rejected`, `failedNodeId: gate`, a non-retryable `node.failed`. (iv) Leg A only: a resolve answers `409 interrupt_already_resolved`. Leg B uses `conformance-approval-timeout-approve` and compares the fork's `resolvedBy` with the source's (a host subject or absent, RFC 0183), rather than requiring it absent. `interruptId` equality is not asserted. Forking at the gate's `node.started` was rejected: a host may re-emit the recorded request as history, which cannot be told from a new one. Measured on the v2 reference host (openwop-examples `b6302cd`): both legs `executed-pass`. With its replay derivation disabled, both fail.
6
+ - **The loop fixtures are bounded by their scenarios, not a host cap** (#1748). `conformance-approval-reject-loopback` and `conformance-replay-ordinal-loop` no longer claim to be bounded by `settings.maxLoopbackIterations`, which no spec binds. `v2-approval-reject-disposition`'s loopback leg and `v2-replay-suppression-ordinal` create the run with `configurable: { version: 1, run: { recursionLimit: 20 } }` as a runaway guard. A cycle-running host no longer needs a definition-level loopback cap to advertise either fixture.
7
+ - **`core.conformance.hold` replaces `core.delay` in the replay fixtures** (#1769). A new conformance-reserved PURE node (`fixtures.md`): it completes after `inputs.delayMs` with its inputs as outputs, is never classified side-effecting, and re-executes live in a replay. `conformance-replay-ordinal-loop` and the v1 `conformance-replay-side-effect` now wait on it. With `core.delay`, which the registry's flow pack declares `role: "side-effect"`, a host honouring `replay.md` rule 4 failed the wait with `replay_source_missing` before the node under test. `v2-replay-suppression-ordinal`'s docstring follows rule 2's re-correction: the ordinal counts recorded terminals, not `node.started`.
8
+ - **No leg change for #1770** (`interrupt.md` §Events: the kind-specific interrupt types are now a MAY). The suite never asserted their emission. `spec-artifacts` carries the corrected `interrupt.md`.
9
+ - **`webhook-signed-delivery`'s tunnel control registered the tunnel, not loopback, and failed conformant hosts.** The control leg ("with a tunnel wired, the host still refuses the loopback receiver") posted `receiver.url`, commented as "deliberately the raw loopback URL, NOT the tunnel". Since 2.37.0, `startScopedReceiver` returns the PUBLIC FRONT as `url` whenever `OPENWOP_WEBHOOK_RECEIVER_URL` is set: the tunnel, plus the exercise's nonce. So on every tunnelled cut the control registered a legitimate public https destination, a host with a working SSRF guard accepted it, and the row recorded `executed-fail` with "the guard is absent (a finding)". Measured on openwop-app production (2026-09-28, suite 2.43.1): the control failed while the same host answers `https://127.0.0.1:…` and `https://localhost:…` with `400 webhook_url_rejected` ("denied (loopback / link-local / private-IP)"). The control now registers `receiver.localUrl` rewritten to `https:`, so a refusal can only come from the loopback deny list and not from a scheme check. Untunnelled runs are unaffected, because the control soft-skips without a tunnel.
10
+ - **The 2.44.4 cycle opens.** Six more core families get a v2 normative home (RFC 0189), which changes the packed `@openwop/spec-artifacts` tree: `memory`, `prompts` and `aiProviders` in `host-services.md`; `limits` in `runs.md`; `artifactTypes` in a new `artifact-type-packs.md`, with field-level rules in `schemas/v2/artifact-type-pack-manifest.schema.json` descriptions. v1-dependent core families: 8 → 3 (`agents`, `budget`, `production`, each blocked on error codes v2 does not register). No scenario changes.
11
+
12
+ ## [2.44.3] — 2026-09-28 — fifteen more core families get v2 normative homes, and webhook retry rows record the wait path they measured
13
+
14
+ - **Webhook retry rows say which wait path ran and what they measured** (RFC 0225 witness). A passing dead-letter row carried no detail, so a bundle could not show whether its wait came from the host's advertised `retryPolicy.maxElapsedMs`, an operator-raised cap or the 20 s floor, or when the dead-letter arrived. MyndHyve's 2.44.1 cut was the case: it advertises 600000, and it could not be counted as RFC 0225's witness. Rows driven by `retryWaitFor` now carry an informational detail, on passes too. `0173.webhook-durable-delivery.dead-letter` and `0188.dead-letter-content-free` record, for example, `observed: advertised-bound (maxElapsedMs 600000): waited ≤630000ms; 5 attempt(s), last attempt after 225031ms, sink after 231412ms`. The last attempt is a lower bound on the dead-letter time, and the sink read an upper bound. The retry leg, `v2-webhook-unregister-stops-delivery` and `v2-webhook-message-id-stable` record the path and window. The detail is prefixed `observed: `, never `partial-witness: `, so `check-accepted-predicate` counts the row as a full witness. The bundle already carries `detail` on every row inside the witness digest, so the note is signed. New `lib/row-observation.ts`, attached in `setup.ts`; `retryWaitSelection` / `waitObservation` in `lib/webhook-retry-window.ts`; server-free tests in `row-observation.test.ts` and `webhook-retry-window.test.ts`, each sabotage-checked.
15
+ - **The 2.44.3 cycle opens.** Fifteen core families get a v2 normative home (RFC 0189), which changes the packed `@openwop/spec-artifacts` tree. v1-dependent core families fall from 23 to 8. No scenario changes: the rules are restated, none added.
16
+ - `events.md`: `feedback`, `providerUsage`, `envelopes`, and the rest of `heartbeat`.
17
+ - `host-services.md`: `secrets`, `modelCapabilities`, `scheduling`, `queueBus`, `toolHooks`, `httpClient`; `runs.md`: `deadLetter`.
18
+ - New `execution.md`: `selfHostedRunner`, `subWorkflow`, `multiAgent` (the family claim and `executionModel` facet); `identity.md` §1.5: `anonymousActor`.
19
+
3
20
  ## [2.44.2] — 2026-09-28 — RFC 0215's no-head-of-line leg does not convict a late fan-out, and says when it sampled each count
4
21
 
5
22
  - **The 2.44.2 cycle opens.** A patch: the requirement is unchanged, and one verdict branch that convicted without the contention it names becomes unjudged. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
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.44.2 @openwop/spec-artifacts@2.44.2
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.44.3 @openwop/spec-artifacts@2.44.3
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.44.2",
4
- "stampSha256": "66d1059ab9f4e5799bbb84c8b66e64033bc08f4daf6016c22c71164cca948c4c"
3
+ "version": "2.44.4",
4
+ "stampSha256": "0075c7ff983e3ad3d67935b7bb2287d624fee7b6517ccea44f89071741bdda9b"
5
5
  }
@@ -2,7 +2,7 @@
2
2
  "id": "conformance-approval-reject-loopback",
3
3
  "name": "Conformance: Approval (reject loops back)",
4
4
  "version": "1.0",
5
- "description": "openwop#1697. A rejected approval gate routed over an any_failed edge to revise, which loops back to the gate (bounded by settings.maxLoopbackIterations 2). Rejected once, the gate is visited again: the host MUST raise a new interrupt.requested with a different key and wait for a new decision, never replaying the first rejection. Conformance scenario: v2-approval-reject-disposition.test.ts.",
5
+ "description": "openwop#1697. A rejected approval gate routed over an any_failed edge to revise, which loops back to the gate (the loop is bounded by the scenario, a single reject, not by the host; the scenario passes configurable.run.recursionLimit 20 as a runaway guard; settings.maxLoopbackIterations is authoring metadata, openwop#1748). Rejected once, the gate is visited again: the host MUST raise a new interrupt.requested with a different key and wait for a new decision, never replaying the first rejection. Conformance scenario: v2-approval-reject-disposition.test.ts.",
6
6
  "nodes": [
7
7
  { "id": "start", "typeId": "core.noop", "name": "Start", "position": { "x": 0, "y": 0 }, "config": {}, "inputs": {} },
8
8
  {
@@ -2,13 +2,13 @@
2
2
  "id": "conformance-replay-ordinal-loop",
3
3
  "name": "Conformance: Replay outcome keyed on the execution ordinal",
4
4
  "version": "1.0",
5
- "description": "openwop#1718. start → effect (a side-effecting node) → wait (core.delay) → back to effect, bounded by settings.maxLoopbackIterations 2, so effect executes twice. The suite cancels the source run inside wait, after effect's first execution completed and before its second, then forks mode:\"replay\" at effect's first node.started. In the fork, effect's first execution resolves from the recorded outcome for n = 1, and its second execution has no recorded outcome for n = 2, so it MUST fail closed with replay_source_missing (replay.md §Suppression rule 2). A host keying on (nodeId, attempt) or selecting the latest outcome resolves the second execution from the first and completes it. core.conformance.side-effect is the reserved side-effecting typeId (see conformance-replay-side-effect). Advertised only by a host that runs cycles and suppresses side effects in replay.",
5
+ "description": "openwop#1718. start → effect (a side-effecting node) → wait (core.conformance.hold, a reserved PURE node) → back to effect, bounded by the scenario (cancel plus fail-closed), not by the host; the scenario passes configurable.run.recursionLimit 20 as a runaway guard, and settings.maxLoopbackIterations is authoring metadata (openwop#1748), so effect executes twice. The suite cancels the source run inside wait, after effect's first execution completed and before its second, then forks mode:\"replay\" at effect's first node.started. In the fork, effect's first execution resolves from the recorded outcome for n = 1, and its second execution has no recorded outcome for n = 2, so it MUST fail closed with replay_source_missing (replay.md §Suppression rule 2). A host keying on (nodeId, attempt) or selecting the latest outcome resolves the second execution from the first and completes it. core.conformance.side-effect is the reserved side-effecting typeId (see conformance-replay-side-effect). Advertised only by a host that runs cycles and suppresses side effects in replay.",
6
6
  "nodes": [
7
7
  { "id": "start", "typeId": "core.noop", "name": "Start", "position": { "x": 0, "y": 0 }, "config": {}, "inputs": {} },
8
8
  { "id": "effect", "typeId": "core.conformance.side-effect", "name": "Side Effect", "position": { "x": 200, "y": 0 }, "config": {}, "inputs": {} },
9
9
  {
10
10
  "id": "wait",
11
- "typeId": "core.delay",
11
+ "typeId": "core.conformance.hold",
12
12
  "name": "Wait",
13
13
  "position": { "x": 400, "y": 0 },
14
14
  "config": {},
@@ -2,11 +2,11 @@
2
2
  "id": "conformance-replay-side-effect",
3
3
  "name": "Conformance: Replay Side-Effect Suppression",
4
4
  "version": "1.0",
5
- "description": "RFC 0140 fixture for `replay-side-effect-suppression.test.ts`. A `core.delay` gate followed by a node the host classifies as SIDE-EFFECTING. The test starts a run, cancels it mid-delay so the effect node never reaches a terminal outcome, then forks `mode:\"replay\"` — a host advertising `replay.sideEffectSuppression: \"recorded-outcome\"` MUST fail that node closed with `replay_source_missing` (replay.md §\"Side-effect suppression in replay\" requirement 3) rather than executing it. `core.conformance.side-effect` is a conformance-RESERVED typeId (same pattern as `core.conformance.mock-agent`): a host advertising the capability MUST map it to a node its replay classifier treats as side-effecting. The node's actual effect is irrelevant to the assertion — what matters is that the host would refuse to perform it during a replay. Hosts that do not advertise the capability need not supply this fixture; the scenario skips.",
5
+ "description": "RFC 0140 fixture for `replay-side-effect-suppression.test.ts`. A `core.conformance.hold` wait (a conformance-reserved PURE node that re-executes live in a replay; it replaced `core.delay` on 2026-09-29, openwop#1769, because the registry declares `core.delay` side-effecting) followed by a node the host classifies as SIDE-EFFECTING. The test starts a run, cancels it mid-delay so the effect node never reaches a terminal outcome, then forks `mode:\"replay\"` — a host advertising `replay.sideEffectSuppression: \"recorded-outcome\"` MUST fail that node closed with `replay_source_missing` (replay.md §\"Side-effect suppression in replay\" requirement 3) rather than executing it. `core.conformance.side-effect` is a conformance-RESERVED typeId (same pattern as `core.conformance.mock-agent`): a host advertising the capability MUST map it to a node its replay classifier treats as side-effecting. The node's actual effect is irrelevant to the assertion — what matters is that the host would refuse to perform it during a replay. Hosts that do not advertise the capability need not supply this fixture; the scenario skips.",
6
6
  "nodes": [
7
7
  {
8
8
  "id": "wait",
9
- "typeId": "core.delay",
9
+ "typeId": "core.conformance.hold",
10
10
  "name": "Wait",
11
11
  "position": {
12
12
  "x": 0,
package/fixtures.md CHANGED
@@ -255,7 +255,7 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
255
255
 
256
256
  - **Purpose**: witness that `key` is per visit (`spec/v2/core/interrupt.md` §Re-entry and resume values, openwop#1697).
257
257
  - **Inputs**: none.
258
- - **Graph**: `start` (`core.noop`) → `gate` (`any_success`); `gate` → `revise` (`core.noop`, `any_failed`); `revise` → `gate` (`any_success`). `settings.maxLoopbackIterations: 2`.
258
+ - **Graph**: `start` (`core.noop`) → `gate` (`any_success`); `gate` → `revise` (`core.noop`, `any_failed`); `revise` → `gate` (`any_success`). The loop is bounded by the scenario (a single reject; cancel plus fail-closed), not by the host. The scenario creates the run with `configurable: { version: 1, run: { recursionLimit: 20 } }` as a runaway guard (`runs.md` §run section). `settings.maxLoopbackIterations: 2` stays in the JSON as authoring metadata; the scenario does not depend on it (openwop#1748).
259
259
  - **Behavior**:
260
260
  1. Run reaches `gate` and MUST be `waiting-approval`.
261
261
  2. Client POSTs `{action: 'reject'}`. `gate` fails with `approval_rejected`, and `revise` runs.
@@ -306,8 +306,8 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
306
306
 
307
307
  ### `conformance-replay-ordinal-loop`
308
308
 
309
- - **Purpose**: witness that a replay fork keys a side-effecting node's recorded outcome on `(sourceRunId, nodeId, n)`, where `n` counts the node's `node.started` events through this execution (`spec/v2/core/replay.md` §Suppression rule 2, openwop#1718).
310
- - **Shape**: `start` (`core.noop`) → `effect` (`core.conformance.side-effect`, `any_success`) → `wait` (`core.delay`) → `effect` (`any_success`); `settings.maxLoopbackIterations: 2`, so `effect` executes twice.
309
+ - **Purpose**: witness that a replay fork keys a side-effecting node's recorded outcome on `(sourceRunId, nodeId, n)`, where `n` is one more than the node's recorded terminals (`node.completed`, `node.failed`) before this execution (`spec/v2/core/replay.md` §Suppression rule 2, openwop#1718, re-corrected by openwop#1769).
310
+ - **Shape**: `start` (`core.noop`) → `effect` (`core.conformance.side-effect`, `any_success`) → `wait` (`core.conformance.hold`) → `effect` (`any_success`), so `effect` executes twice. The loop is bounded by the scenario (a single reject; cancel plus fail-closed), not by the host. The scenario creates the run with `configurable: { version: 1, run: { recursionLimit: 20 } }` as a runaway guard (`runs.md` §run section). `settings.maxLoopbackIterations: 2` stays in the JSON as authoring metadata; the scenario does not depend on it (openwop#1748).
311
311
  - **Inputs**: `delayMs` (integer, default 3000), long enough to cancel inside `wait`.
312
312
  - **Expected behavior**:
313
313
  1. The source run completes `effect`'s first execution. The client cancels it inside `wait`, before the second execution.
@@ -318,7 +318,7 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
318
318
  ### `conformance-replay-side-effect`
319
319
 
320
320
  - **Purpose**: verify RFC 0140 — a `mode:"replay"` fork MUST NOT re-perform a side-effecting node's effect.
321
- - **Shape**: `core.delay` (`wait`) → `core.conformance.side-effect` (`effect`).
321
+ - **Shape**: `core.conformance.hold` (`wait`) → `core.conformance.side-effect` (`effect`). *(Corrected 2026-09-29, openwop#1769: `wait` was `core.delay`, which the registry's flow pack declares `role: "side-effect"`; a host honouring that floor resolved `wait` from the cancelled source and failed it with `replay_source_missing` before `effect` was reached.)*
322
322
  - **`core.conformance.side-effect` is a conformance-RESERVED typeId** (same pattern as `core.conformance.mock-agent`): a host advertising `replay.sideEffectSuppression: "recorded-outcome"` MUST map it to a node its replay classifier treats as side-effecting. The node's actual effect is irrelevant to the assertion — what matters is that the host would refuse to perform it during a replay. Hosts that do not advertise the capability need not supply this fixture; the scenario skips.
323
323
  - **Inputs**:
324
324
  - `delayMs` (integer, required, 1 ≤ value ≤ 60000) — long enough for the test to cancel before `effect` runs.
@@ -590,6 +590,7 @@ The fixtures reference these typeIds:
590
590
  | `core.delay` | delay, cancellable | Sleep `config.delayMs` ms |
591
591
  | `core.fail` | failure | Throw with `code: "example.conformance_failure"` (a vendor code under the registered `example` org; errors.md §The registry, openwop#1698), message: "Intentional conformance failure" |
592
592
  | `core.approvalGate` | approval | Call `ctx.interrupt({kind: 'approval', ...})` |
593
+ | `core.conformance.hold` | replay-side-effect, replay-ordinal-loop | Conformance-RESERVED (openwop#1769). Completes after `inputs.delayMs` with its inputs as outputs and performs nothing observable outside the run's own event log. A host advertising a fixture that uses it MUST NOT classify it side-effecting (`replay.md` §Suppression rule 1) and MUST re-execute it live in a replay. |
593
594
  | `core.clarificationGate` | clarification | Call `ctx.interrupt({kind: 'clarification', ...})` |
594
595
  | `conformance.requiresMissing` | capability-missing | Declares `requires: ['conformance.never-provided']`; engine MUST refuse dispatch. Opt-in fixture registration is recommended so production deployments don't expose the fixture surface. |
595
596
  | `conformance.artifact.emit` | artifact-emit | Opt-in (RFC 0205). Produce one artifact of `config.artifactType` with payload `config.data`; emit `artifact.created` naming it; readable through `getArtifact`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.44.2",
3
+ "version": "2.44.4",
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.44.2"
59
+ "@openwop/spec-artifacts": "2.44.4"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -3,8 +3,8 @@
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
5
  "files": 648,
6
- "tests": 2490,
7
- "withStableId": 2490,
6
+ "tests": 2492,
7
+ "withStableId": 2492,
8
8
  "interpolatedTitles": 0,
9
9
  "explicitIds": 2372
10
10
  },
@@ -22926,7 +22926,7 @@
22926
22926
  {
22927
22927
  "id": "openwop.it.replay-side-effect-suppression.sideeffectsuppression-when-present-is-one-of-the-two-defined-values",
22928
22928
  "file": "replay-side-effect-suppression.test.ts",
22929
- "line": 101,
22929
+ "line": 103,
22930
22930
  "title": "sideEffectSuppression, when present, is one of the two defined values",
22931
22931
  "explicitId": "openwop.it.replay-side-effect-suppression.sideeffectsuppression-when-present-is-one-of-the-two-defined-values",
22932
22932
  "citations": [
@@ -22939,7 +22939,7 @@
22939
22939
  {
22940
22940
  "id": "openwop.it.replay-side-effect-suppression.fails-a-side-effecting-node-closed-with-replay-source-missing-when-the-source-ne",
22941
22941
  "file": "replay-side-effect-suppression.test.ts",
22942
- "line": 115,
22942
+ "line": 117,
22943
22943
  "title": "fails a side-effecting node CLOSED with replay_source_missing when the source never recorded it",
22944
22944
  "explicitId": "openwop.it.replay-side-effect-suppression.fails-a-side-effecting-node-closed-with-replay-source-missing-when-the-source-ne",
22945
22945
  "citations": [
@@ -22974,7 +22974,7 @@
22974
22974
  {
22975
22975
  "id": "openwop.it.replay-side-effect-suppression.the-manifest-still-offers-the-role-value-the-requirement-binds",
22976
22976
  "file": "replay-side-effect-suppression.test.ts",
22977
- "line": 240,
22977
+ "line": 242,
22978
22978
  "title": "the manifest still offers the role value the requirement binds",
22979
22979
  "explicitId": "openwop.it.replay-side-effect-suppression.the-manifest-still-offers-the-role-value-the-requirement-binds",
22980
22980
  "citations": [
@@ -22988,7 +22988,7 @@
22988
22988
  {
22989
22989
  "id": "openwop.it.replay-side-effect-suppression.replay-md-binds-that-declaration-naming-the-schema-it-comes-from",
22990
22990
  "file": "replay-side-effect-suppression.test.ts",
22991
- "line": 251,
22991
+ "line": 253,
22992
22992
  "title": "replay.md binds that declaration, naming the schema it comes from",
22993
22993
  "explicitId": "openwop.it.replay-side-effect-suppression.replay-md-binds-that-declaration-naming-the-schema-it-comes-from",
22994
22994
  "citations": [
@@ -23002,7 +23002,7 @@
23002
23002
  {
23003
23003
  "id": "openwop.it.replay-side-effect-suppression.a-throw-is-named-a-backstop-not-a-discharge",
23004
23004
  "file": "replay-side-effect-suppression.test.ts",
23005
- "line": 261,
23005
+ "line": 263,
23006
23006
  "title": "a throw is named a backstop, not a discharge",
23007
23007
  "explicitId": "openwop.it.replay-side-effect-suppression.a-throw-is-named-a-backstop-not-a-discharge",
23008
23008
  "citations": [
@@ -23016,7 +23016,7 @@
23016
23016
  {
23017
23017
  "id": "openwop.it.replay-side-effect-suppression.the-floor-direction-is-stated-not-left-to-inference",
23018
23018
  "file": "replay-side-effect-suppression.test.ts",
23019
- "line": 285,
23019
+ "line": 287,
23020
23020
  "title": "the floor direction is stated, not left to inference",
23021
23021
  "explicitId": "openwop.it.replay-side-effect-suppression.the-floor-direction-is-stated-not-left-to-inference",
23022
23022
  "citations": [
@@ -27856,7 +27856,7 @@
27856
27856
  {
27857
27857
  "id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-with-no-routing-edge-fails-the-run-with-approval-rejected",
27858
27858
  "file": "v2-approval-reject-disposition.test.ts",
27859
- "line": 126,
27859
+ "line": 141,
27860
27860
  "title": "a rejected gate with no routing edge fails the run with approval_rejected",
27861
27861
  "explicitId": null,
27862
27862
  "citations": [
@@ -27879,7 +27879,7 @@
27879
27879
  {
27880
27880
  "id": "openwop.it.v2-approval-reject-disposition.interrupt-resolved-records-action-reject-and-decision-rejected",
27881
27881
  "file": "v2-approval-reject-disposition.test.ts",
27882
- "line": 137,
27882
+ "line": 152,
27883
27883
  "title": "interrupt.resolved records action reject and decision rejected",
27884
27884
  "explicitId": null,
27885
27885
  "citations": [
@@ -27898,7 +27898,7 @@
27898
27898
  {
27899
27899
  "id": "openwop.it.v2-approval-reject-disposition.the-gate-node-fails-with-approval-rejected-not-retryable-and-run-failed-names-it",
27900
27900
  "file": "v2-approval-reject-disposition.test.ts",
27901
- "line": 150,
27901
+ "line": 165,
27902
27902
  "title": "the gate node fails with approval_rejected, not retryable, and run.failed names it",
27903
27903
  "explicitId": null,
27904
27904
  "citations": [
@@ -27919,7 +27919,7 @@
27919
27919
  {
27920
27920
  "id": "openwop.it.v2-approval-reject-disposition.a-majority-reject-decides-the-quorum-gate-on-the-deciding-vote-and-fails-the-run",
27921
27921
  "file": "v2-approval-reject-disposition.test.ts",
27922
- "line": 163,
27922
+ "line": 178,
27923
27923
  "title": "a majority reject decides the quorum gate on the deciding vote and fails the run with approval_rejected",
27924
27924
  "explicitId": null,
27925
27925
  "citations": [
@@ -27945,7 +27945,7 @@
27945
27945
  {
27946
27946
  "id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-routed-over-an-any-failed-edge-fails-as-a-node-its-all-success-s",
27947
27947
  "file": "v2-approval-reject-disposition.test.ts",
27948
- "line": 186,
27948
+ "line": 201,
27949
27949
  "title": "a rejected gate routed over an any_failed edge fails as a node, its all_success sibling never runs, and the run completes",
27950
27950
  "explicitId": null,
27951
27951
  "citations": [
@@ -27971,7 +27971,7 @@
27971
27971
  {
27972
27972
  "id": "openwop.it.v2-approval-reject-disposition.an-unresolved-gate-past-its-timeoutms-with-no-ontimeout-is-resolved-rejected-by",
27973
27973
  "file": "v2-approval-reject-disposition.test.ts",
27974
- "line": 205,
27974
+ "line": 220,
27975
27975
  "title": "an unresolved gate past its timeoutMs with no onTimeout is resolved rejected by the host and fails the run naming the gate",
27976
27976
  "explicitId": null,
27977
27977
  "citations": [
@@ -27992,7 +27992,7 @@
27992
27992
  {
27993
27993
  "id": "openwop.it.v2-approval-reject-disposition.a-gate-whose-ontimeout-is-approve-still-resolves-rejected-on-timeout-and-fails-t",
27994
27994
  "file": "v2-approval-reject-disposition.test.ts",
27995
- "line": 224,
27995
+ "line": 239,
27996
27996
  "title": "a gate whose onTimeout is approve still resolves rejected on timeout and fails the run: a timeout never grants",
27997
27997
  "explicitId": null,
27998
27998
  "citations": [
@@ -28014,7 +28014,7 @@
28014
28014
  {
28015
28015
  "id": "openwop.it.v2-approval-reject-disposition.a-rejected-gate-looped-back-to-itself-is-asked-again-under-a-new-key-and-the-fir",
28016
28016
  "file": "v2-approval-reject-disposition.test.ts",
28017
- "line": 242,
28017
+ "line": 257,
28018
28018
  "title": "a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed",
28019
28019
  "explicitId": null,
28020
28020
  "citations": [
@@ -28037,6 +28037,33 @@
28037
28037
  }
28038
28038
  ]
28039
28039
  },
28040
+ {
28041
+ "id": "openwop.it.v2-approval-reject-disposition.a-replay-fork-of-a-decided-reject-derives-the-rejection-from-the-log-and-never-r",
28042
+ "file": "v2-approval-reject-disposition.test.ts",
28043
+ "line": 333,
28044
+ "title": "a replay fork of a decided reject derives the rejection from the log and never re-decides it (leg A)",
28045
+ "explicitId": null,
28046
+ "citations": [
28047
+ {
28048
+ "section": "spec/v2/core/errors.md §One code per state",
28049
+ "requirement": "a resolve against the replayed gate MUST answer 409 interrupt_already_resolved — the fork holds no open decision"
28050
+ }
28051
+ ]
28052
+ },
28053
+ {
28054
+ "id": "openwop.it.v2-approval-reject-disposition.a-replay-fork-of-a-timed-out-gate-derives-the-timeout-rejection-from-the-log-and",
28055
+ "file": "v2-approval-reject-disposition.test.ts",
28056
+ "line": 348,
28057
+ "title": "a replay fork of a timed-out gate derives the timeout rejection from the log and never re-arms it (leg B)",
28058
+ "explicitId": null,
28059
+ "citations": [
28060
+ {
28061
+ "section": null,
28062
+ "requirement": "leg B: the derived resolution is the recorded timeout — action timeout, reason timeout, and the source's resolvedBy (a host subject or absent), never a caller",
28063
+ "interpolated": true
28064
+ }
28065
+ ]
28066
+ },
28040
28067
  {
28041
28068
  "id": "openwop.it.v2-approver-enforced.a-resolver-outside-approverslist-is-refused-at-resolve-time",
28042
28069
  "file": "v2-approver-enforced.test.ts",
@@ -33078,7 +33105,7 @@
33078
33105
  {
33079
33106
  "id": "openwop.it.v2-replay-suppression-ordinal.a-replay-fork-resolves-each-execution-of-a-looped-side-effecting-node-by-its-own",
33080
33107
  "file": "v2-replay-suppression-ordinal.test.ts",
33081
- "line": 73,
33108
+ "line": 74,
33082
33109
  "title": "a replay fork resolves each execution of a looped side-effecting node by its own ordinal, failing closed where none was recorded",
33083
33110
  "explicitId": "openwop.requirement.replay.suppression-execution-ordinal",
33084
33111
  "citations": [
@@ -34355,7 +34382,7 @@
34355
34382
  {
34356
34383
  "id": "openwop.it.v2-webhook-durable-delivery.a-failed-attempt-is-retried-and-the-event-is-delivered-at-least-once",
34357
34384
  "file": "v2-webhook-durable-delivery.test.ts",
34358
- "line": 263,
34385
+ "line": 279,
34359
34386
  "title": "a failed attempt is retried and the event is delivered at least once",
34360
34387
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery",
34361
34388
  "citations": [
@@ -34392,7 +34419,7 @@
34392
34419
  {
34393
34420
  "id": "openwop.it.v2-webhook-durable-delivery.an-exhausted-delivery-is-dead-lettered-never-dropped",
34394
34421
  "file": "v2-webhook-durable-delivery.test.ts",
34395
- "line": 383,
34422
+ "line": 400,
34396
34423
  "title": "an exhausted delivery is dead-lettered, never dropped",
34397
34424
  "explicitId": "openwop.requirement.0173.webhook-durable-delivery.dead-letter",
34398
34425
  "citations": [
@@ -34431,7 +34458,7 @@
34431
34458
  {
34432
34459
  "id": "openwop.it.v2-webhook-durable-delivery.the-dead-letter-read-is-served-and-its-records-carry-no-payload",
34433
34460
  "file": "v2-webhook-durable-delivery.test.ts",
34434
- "line": 491,
34461
+ "line": 509,
34435
34462
  "title": "the dead-letter read is served and its records carry no payload",
34436
34463
  "explicitId": "openwop.requirement.0188.dead-letter-read",
34437
34464
  "citations": [
@@ -34444,7 +34471,7 @@
34444
34471
  {
34445
34472
  "id": "openwop.it.v2-webhook-durable-delivery.after-unregister-the-dead-letter-read-answers-404-not-found-as-for-a-subscriptio",
34446
34473
  "file": "v2-webhook-durable-delivery.test.ts",
34447
- "line": 512,
34474
+ "line": 530,
34448
34475
  "title": "after unregister, the dead-letter read answers 404 not_found, as for a subscription that never existed",
34449
34476
  "explicitId": "openwop.requirement.0217.dead-letter-read-after-unregister",
34450
34477
  "citations": [
@@ -34471,7 +34498,7 @@
34471
34498
  {
34472
34499
  "id": "openwop.it.v2-webhook-durable-delivery.a-dead-letter-record-carries-no-delivered-payload",
34473
34500
  "file": "v2-webhook-durable-delivery.test.ts",
34474
- "line": 545,
34501
+ "line": 563,
34475
34502
  "title": "a dead-letter record carries no delivered payload",
34476
34503
  "explicitId": "openwop.requirement.0188.dead-letter-content-free",
34477
34504
  "citations": [
@@ -34615,7 +34642,7 @@
34615
34642
  {
34616
34643
  "id": "openwop.it.v2-webhook-message-id-stable.every-attempt-of-one-delivery-carries-one-webhook-id-and-distinct-deliveries-car",
34617
34644
  "file": "v2-webhook-message-id-stable.test.ts",
34618
- "line": 69,
34645
+ "line": 70,
34619
34646
  "title": "every attempt of one delivery carries one webhook-id, and distinct deliveries carry distinct ids",
34620
34647
  "explicitId": "openwop.requirement.0201.message-id-stable",
34621
34648
  "citations": [
@@ -34854,7 +34881,7 @@
34854
34881
  {
34855
34882
  "id": "openwop.it.v2-webhook-unregister-stops-delivery.no-attempt-for-an-unregistered-subscription-starts-after-the-204-while-its-contr",
34856
34883
  "file": "v2-webhook-unregister-stops-delivery.test.ts",
34857
- "line": 86,
34884
+ "line": 87,
34858
34885
  "title": "no attempt for an unregistered subscription starts after the 204, while its control keeps being retried",
34859
34886
  "explicitId": "openwop.requirement.0215.unregister-stops-delivery",
34860
34887
  "citations": [
@@ -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.44.2",
5
- "corpusTag": "v2.44.2",
4
+ "version": "2.44.4",
5
+ "corpusTag": "v2.44.4",
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": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "f24127f3cd544f1c5a942859ffc492b438d37b1b7d7a660c631fb87f70cd24f6",
13
- "api/v2/asyncapi.yaml": "3283ff1bbe962dc75e6d66d2b755d4eb7d53770ce8511f2d671b923d6ca0e51a",
14
- "api/v2/openapi.yaml": "4a455ba22c81718e2842adce60e5d3c5970e6f8a55b301d9154ccc8060f6cf91",
12
+ "api/seams-v2.yaml": "0c5a5ce153d5ac87b89f331f1e13ac25389937f966adbd3207bdfcc17e7ef8f5",
13
+ "api/v2/asyncapi.yaml": "d195115d983cadc6ae08d617de1a1c2f09073b22b0e5309ddd9119319164c566",
14
+ "api/v2/openapi.yaml": "d928ce4c62122954da1fecd5a79baff89957e65d4cc54606868bed92127b70c6",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -112,7 +112,7 @@
112
112
  "schemas/v2/ai-envelope.schema.json": "0dc7dfbf032a37239ffd81fc2d67f999a40782ff5074866a56e414164cdee809",
113
113
  "schemas/v2/annotation-create.schema.json": "ec32c22a243b343eeb6956a23be2d76ba5b50c8018ec3ec9d6efa5a1d9b0c792",
114
114
  "schemas/v2/annotation.schema.json": "eb4813cdaa8e325412cfcaeeee5c8a28f3751c946dec64e546c7ba0ad3aad8d4",
115
- "schemas/v2/artifact-type-pack-manifest.schema.json": "c8820351a2ab93855bd654ad3d8a9788f97975d09b3a7a0418e374ed6114595d",
115
+ "schemas/v2/artifact-type-pack-manifest.schema.json": "acbbf14d32da4e0cb81254bbd6cc48fc10083890572044ddef71c59a0f65c185",
116
116
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
117
117
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
118
118
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
@@ -193,61 +193,63 @@
193
193
  "schemas/v2/webhook-delivery.schema.json": "4e8ab3b722ad1731adf0548c1217f0de62da40de14e8abf77533aee4ace4c079",
194
194
  "schemas/v2/webhook-verification.schema.json": "76e4e0d81143ffd2649bc7adba2aa34f2525e2c4e9f0300a425d908b3163196b",
195
195
  "schemas/v2/workflow-chain-pack-manifest.schema.json": "0816c6c59a91452d6fa1931394b766780eaf90949c9f7cd0ced522dadd32ff81",
196
- "schemas/v2/workflow-definition.schema.json": "b9043e3413fc01d939e7b2892cc0fe28bdfd77d5bacc561b6ca4ae20197b985b",
196
+ "schemas/v2/workflow-definition.schema.json": "3f031c0cb1644ac21f203945a0860e04dcea4bd1ab722f280bdcdc788edd0a22",
197
197
  "schemas/v2/workload-identity.schema.json": "1f589debfac6d70e0908da511550ec69a27b4bcc8314551cd843b6d923605efc",
198
198
  "schemas/v2/workspace-file-create.schema.json": "b6b5260857318c3b67de0231ed15e3a746f525880a763e7b36a7854178dd10d0",
199
199
  "schemas/v2/workspace-file.schema.json": "d2ed39e8c229dde33b56905c8df6f8b2ff056c97e1c5f615e3124baa54a6254f",
200
200
  "schemas/workflow-chain-pack-manifest.schema.json": "c027571913c44d8d9678750c72701a710a02fa0df55e86849da03e13e95c333a",
201
- "schemas/workflow-definition.schema.json": "28b050ffe22121796c9d4a5a8c7e57c901bb283bd08d3fd3ff2ea25a898108c1",
201
+ "schemas/workflow-definition.schema.json": "fbab3787707fc0e00ecf2adea8de8aba582e6057af9cde893b1b0eb969adea83",
202
202
  "schemas/workload-identity.schema.json": "64ed93e12cc9f0657e56ff9b8f045e5d68d397df5276d103c07f42c7f34f0579",
203
203
  "schemas/workspace-file-create.schema.json": "70a23b92c2958c0165d7d29aea9020a045dc7816af82c80c5f50d0aac19c4856",
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "29f21e0fb2c7a232a92a5c19b4cba3a86bc3b5e16f06780452f0421c66c6ec38",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "3d7fd99cd02153094723c2bf383d757c282e653526aa3b199d9bdb4b9cfd7c80",
207
+ "spec/v1/core-standard-manifest.json": "8f01c8ce3e66e4ff879e28da7f0de8bb8e2f3f85e5855cae17f0563c902328e5",
208
208
  "spec/v1/deprecations.json": "46df807fb4adf0aae24671c20f69975cf3496e3bc3a521cb7e6ea556bdce563c",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "07b0f5a206a015336307dd343b75adec647f06a9bae59ee08dbbfac851c503d5",
213
+ "spec/v1/gaps.json": "11c35393ad666899fe8a668c45fa69f79d20361ab57e84949dce3bc2f528d5c3",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
- "spec/v1/migrations.json": "2efcd9064b2bc65a84a3b3d281aa86a91efd002690c1f4f14e8335c4a18194d1",
215
+ "spec/v1/migrations.json": "ca57b7ce335a681aaa78573ce229e9c3d7d75859f03e087ae93bbfc889359127",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
217
217
  "spec/v1/operation-path-manifest.json": "fada0342815fb129c1ae61f1829bd6e34417717408ce86fcc54280f373c19e74",
218
218
  "spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
219
- "spec/v2/README.md": "377639de1dad1ed71aba6dae8d34fdb6ee954fb23106e89454fe8d29cdde6349",
219
+ "spec/v2/README.md": "ddea90d7cf14aefffe1a43eeeecd4222dadad1d1e5ac64dceb073990eb93d9ef",
220
+ "spec/v2/core/artifact-type-packs.md": "e143bfd6f98d4867855bb57090a6b854e98d4b0a6202b9b3734cb45366f99854",
220
221
  "spec/v2/core/capabilities.md": "ad728e8a761153b55d036ece55043ff62d0982121e2e629e5e7cdf560fca3c03",
221
222
  "spec/v2/core/conformance.md": "0cfceb926eed87055550a5c23e640210c795fc8cf9dd1b2544db0e2b3842c564",
222
223
  "spec/v2/core/connection-packs.md": "1ee129d792c537bfe82393ccb92555d8e6ddc342b1aa781a13f101b27ec38171",
223
224
  "spec/v2/core/conversation.md": "354fa57c73d11062e523a44aa75a6353c5c81aefaf306d30c42416a228c82c86",
224
225
  "spec/v2/core/errors.md": "6d5a67389de84556f81e2558a30d83959c9283c8c5481ce7c759021e486d84fd",
225
- "spec/v2/core/events.md": "604abb6f9e2259a04103e1cb76d728712c3701c0d84dd915d4dc2949c912ee5e",
226
+ "spec/v2/core/events.md": "8a2767691f604b2adf76399edeeaccfc151c697fc9717dc17cf7dd57372a8c22",
227
+ "spec/v2/core/execution.md": "299c4d548e16e5a0809957a47cbd34b56a38de82b8c9b3a5d036b2c7abca09c6",
226
228
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
227
229
  "spec/v2/core/headers.md": "5cb1624fa63acc81992b6a6d842ec392cf7271aaf2f65d1f83c8c4fa286e6730",
228
- "spec/v2/core/host-services.md": "8b920bf2e9335ab04452068429b0c37d6eb8872b380a4d53368f2600d4afa241",
230
+ "spec/v2/core/host-services.md": "aa8b269e6348bdcb797da1a23c82f9df95c2133a7b947253799f6eaf88ccd3f1",
229
231
  "spec/v2/core/i18n.md": "2d5a67bd3b9b956d1144acab2fbc3646cdb50b3621f97d772087eaa953798286",
230
232
  "spec/v2/core/idempotency.md": "2084d756789c1faf5121593fb39e67ca36a4c227178e175004742e403178ea49",
231
- "spec/v2/core/identity.md": "2dceb1c2697015e4a4fa8a9b28f4fbd25fa44a76ca5a919c752e2a8163c819db",
233
+ "spec/v2/core/identity.md": "582eddbf2e1bad41911e886bd08524683837045404f7b8f6bceccf61792b2765",
232
234
  "spec/v2/core/interop.md": "e72e25dff864c83b2980d7366b34054cffa072af0225821199f260f6228d260e",
233
- "spec/v2/core/interrupt.md": "4c825634acac72900ebd3c5ac5941f55648fa33d2df92913ef16371dcb700a34",
235
+ "spec/v2/core/interrupt.md": "c1e13179e792bf8099b2d1c7891542646bb1d4cdee1757590f190833cae6047c",
234
236
  "spec/v2/core/node-pack-runtimes.md": "e1088cb69b8c3c67256c494063328916c2980ab1aa97a7063de25bab82fdb1e5",
235
237
  "spec/v2/core/oauth.md": "c96483a44b62978c596945fbb7d7c863cf6056cd980fdb4cf4e97555e3e10b72",
236
- "spec/v2/core/overview.md": "c8c3bedb8e02dd8ce244b8a7a75d23af1c30dc92a343af912489424a89674d70",
237
- "spec/v2/core/packs.md": "5929e708bd4e79f6e1beea570af583c80f3b63bb21dec78a5df03c235e73364a",
238
+ "spec/v2/core/overview.md": "d7000fbcd2f59280870fc592f5d5ea28a5195b9eb209029f09c5de59e0f2bf9c",
239
+ "spec/v2/core/packs.md": "935fc2f633c68fa98ad6c9cdb083dd556e955b1a15eff32f4773d13329a57238",
238
240
  "spec/v2/core/persistence.md": "711874f74cb08b03cd118d683056db10a063517762fcea73ec70feb5d983e6a4",
239
241
  "spec/v2/core/portability.md": "87c4327f674f714fda6217fd79b99d2ba219ad47dc02f9751bb56c200fb2c17a",
240
- "spec/v2/core/replay.md": "4d379494057746c6883f6047972e93e621863b998ed291b9a691d3aa57c1ff43",
241
- "spec/v2/core/runs.md": "55e21292ae4b41d0d104199a6c2634ecb3f787c4a671f9ebc37084be38ceef03",
242
+ "spec/v2/core/replay.md": "f406d71fb690b2e71f4b86ff799a49e6f1a0662c98fc59bcfe517d100eb6117c",
243
+ "spec/v2/core/runs.md": "8a1d71fb9305fd0df31150bfbbd892f96b8aab553400a36196212f7d0a24906d",
242
244
  "spec/v2/core/security-defaults.md": "153884da7a2d54c30c81b5e649cfdef31e151b3a2a19dee1bc5a16c35d60a3aa",
243
245
  "spec/v2/core/storage.md": "598ad4f3775c297c698a20daf72a7d15a6836c1231ddbca5961865c704f765b5",
244
246
  "spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
245
247
  "spec/v2/core/versioning.md": "e3b3ab6d76e51035039a32173e9b412628a52c8fe7b16687ff58a2d0d983faa0",
246
- "spec/v2/core/webhooks.md": "d2d33c6be777f217ade3e15dd369849e5c6ce67e1a4417216f0aa6ec5bdecc56",
248
+ "spec/v2/core/webhooks.md": "3f8730e0d15a5e4244e302adb910a0b6f745fbfa8e80920c14118117811ba881",
247
249
  "spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
248
250
  "spec/v2/corrections.json": "59f647f4d22477f6c2b06dcf913b682dde5d51dd5e607a5d0cf648ab60864d6e",
249
251
  "spec/v2/corrections.schema.json": "46bbffb954d13bd9995aebc912ceeb2544f7064331abf25fd6ab85c81a5cec94",
250
- "spec/v2/declaration.json": "56c85c6d49f93d08d3bd48ccf1c049191cfbc4d4c22a1cd5202c1b6252759aca",
252
+ "spec/v2/declaration.json": "edace8c4ec7e32aba44828f459e2ae8933dc88ec8129f16549bca6279e3ca24c",
251
253
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
252
254
  "spec/v2/errors.json": "fa650cbbdf25a140fdaf82ec5269f95b4585ac4bdde1fa6b0b3ffe48a3f7943c",
253
255
  "spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
@@ -265,11 +267,11 @@
265
267
  "spec/v2/ext/kanban/README.md": "bfc386dc0f874a4afa1e3ec6a2f99084b547dcce9bd363da3b8ebd8786b9c184",
266
268
  "spec/v2/ext/knowledge/README.md": "cbe1f945a757791dedac0fd542a68c86c5b457778b831d58d9ae44a2baa270f2",
267
269
  "spec/v2/ext/launchStudio/README.md": "a941ef1d2401438a412eaea48d2d6ca807a032b44ea08719fea33120daf088a0",
268
- "spec/v2/ext/messaging/README.md": "949f350e35294f52ad490e42699f8c560cd154549516fbaa1805f06312b7f342",
270
+ "spec/v2/ext/messaging/README.md": "ee2d19fb6f6c2de11d889e966a41e6d1ff34272771689a81d7de039105a5be03",
269
271
  "spec/v2/ext/portability/README.md": "c59423ee446638b748a911b9e97e803c4786157be6120f656f0a060586bc7b82",
270
272
  "spec/v2/ext/provider-idempotency/README.md": "9d1bccef0ec16a19a3370ae79c2f5a1726ce0d1d25a51352907f288a2ccb15d7",
271
273
  "spec/v2/ext/provider-idempotency/registry.json": "5b5fecad604fb4ab38da39f5abe697a4e57b05406c398f8afeb377331b2bd965",
272
- "spec/v2/ext/restTransport/README.md": "c875fd7ae4818a5d5e93ab9b21e569de8700bb07c4dafacd60a46a46e4022ddb",
274
+ "spec/v2/ext/restTransport/README.md": "bd7207683b2fbcead78bdb7d474fa723e08adad500185ea10804fd4e99541c0c",
273
275
  "spec/v2/ext/sandbox-runtime-notes/README.md": "0d1290fb1e20b2e35c0b15051541ab6546e4e2a6f693b4f912cb3f7d6a65e92c",
274
276
  "spec/v2/ext/webResearch/README.md": "b675e6f6646128d781b661f754c2393eed86e5935d16c3a22df582a5ce025b9b",
275
277
  "spec/v2/facets/a2a.schema.json": "b68bf5e29e455e46c2ad7cfcccf5963730ec2310ff9db81884550d8df30fd6ef",
@@ -299,9 +301,9 @@
299
301
  "spec/v2/path-manifest.json": "17298a245c5da9a70aa7ab0b7f2db4277e06209d6fcdd7187a8b4cea179c08f3",
300
302
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
301
303
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
302
- "spec/v2/release.json": "28b407019929da55701a718110d440f08899646af0b74d62f6adc5542c9c1c91",
304
+ "spec/v2/release.json": "8b868146a0b8fef709478ed80a75a204c980d0c761463e0602c921c6b25fd0d8",
303
305
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
304
- "spec/v2/surface-baseline.json": "d0540276e74001f5058dd81909100ea6ee3c1925c6ac02918418f5ec941d0661"
306
+ "spec/v2/surface-baseline.json": "d074465ed4ee594016bfb842fc3161d278394e84740133daf4fd93d1b3b0c4d8"
305
307
  },
306
- "corpusCommit": "c486eafcfa16908a7b57c98b33cfd66fdf8e599d"
308
+ "corpusCommit": "95fbb4c9963f9da47eb0e870bf402bad3f4db29d"
307
309
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * An informational note for the CURRENT `it`'s row (suite 2.44.3, RFC 0225).
3
+ *
4
+ * `setup.ts` attaches it to the row's `detail` in `afterEach`, whatever the
5
+ * disposition, so a PASSING row can say what it observed. The bundle writer
6
+ * already carries `detail` on every row and the witness digest covers it, so the
7
+ * note is signed with the row. It is prefixed `observed: `, never
8
+ * `partial-witness: `: it describes how a witness was obtained and does not
9
+ * qualify it (`check-accepted-predicate` counts a pass as partial only on that
10
+ * prefix). Same shape as `noteEvidence` (lib/durability-evidence.ts).
11
+ */
12
+ export const OBSERVATION_PREFIX = 'observed: ';
13
+
14
+ let pending: string | null = null;
15
+
16
+ /** Note what this `it` observed. A second note in the same `it` is appended. */
17
+ export function noteObservation(text: string): void {
18
+ const t = text.trim();
19
+ if (t === '') return;
20
+ pending = pending === null ? t : `${pending} · ${t}`;
21
+ }
22
+
23
+ /** Take (and clear) the pending note. */
24
+ export function takeNotedObservation(): string | null {
25
+ const n = pending;
26
+ pending = null;
27
+ return n;
28
+ }
29
+
30
+ /**
31
+ * The detail a row carries once an observation is attached. A row with no
32
+ * detail gets `observed: …`; a row that already has one (a non-pass reason, or
33
+ * a partial-witness marker) keeps it FIRST and gains `· observed: …`, so no
34
+ * prefix a reader filters on ever moves.
35
+ */
36
+ export function attachObservation(detail: string | undefined, observation: string | null): string | undefined {
37
+ if (observation === null || observation.trim() === '') return detail;
38
+ if (detail === undefined || detail.trim() === '') return `${OBSERVATION_PREFIX}${observation}`;
39
+ return `${detail} · ${OBSERVATION_PREFIX}${observation}`;
40
+ }
@@ -90,6 +90,62 @@ export function retryTestTimeoutMs(waits: number, slackMs: number): number {
90
90
  return waits * MAX_RETRY_WAIT_MS + slackMs;
91
91
  }
92
92
 
93
+ /**
94
+ * WHICH branch `retryWaitFor` took, so a row can say so (RFC 0225 witness, suite
95
+ * 2.44.3). A passing dead-letter row used to carry no detail at all, so a
96
+ * bundle could not show whether the wait was derived from the host's advertised
97
+ * `maxElapsedMs` (the RFC 0225 path), from an operator-raised cap, or from the
98
+ * 20 s floor, and a certifier could not tell a witness of the bound from a pass
99
+ * the old default would have produced anyway.
100
+ *
101
+ * advertised-bound the window is the advertised maxElapsedMs + the grace
102
+ * cap the window is the cap (default or operator-raised); when
103
+ * a bound is advertised this means the operator raised the
104
+ * cap ABOVE it, so the bound did not set the wait
105
+ * floor no policy, or backoff none: the 20 s floor
106
+ */
107
+ export type RetryWaitPath = 'advertised-bound' | 'cap' | 'floor';
108
+ export interface RetryWaitSelection { readonly path: RetryWaitPath; readonly windowMs: number; readonly maxElapsedMs: number | null; readonly capMs: number }
109
+
110
+ export function retryWaitSelection(policy: AdvertisedRetryPolicy | null, capMs: number): RetryWaitSelection {
111
+ const windowMs = retryWaitFor(policy, capMs);
112
+ const bound = advertisedMaxElapsedMs(policy);
113
+ if (policy === null) return { path: 'floor', windowMs, maxElapsedMs: null, capMs };
114
+ if (bound !== null) return { path: bound + ADVERTISED_BOUND_GRACE_MS >= capMs ? 'advertised-bound' : 'cap', windowMs, maxElapsedMs: bound, capMs };
115
+ const backoff = String(policy.backoff ?? '');
116
+ return { path: backoff === 'exponential' || backoff === 'fixed' ? 'cap' : 'floor', windowMs, maxElapsedMs: null, capMs };
117
+ }
118
+
119
+ /** What the leg measured, in ms since the delivery's FIRST attempt reached the receiver. */
120
+ export interface RetryWaitMeasurement { readonly attempts: number; readonly lastAttemptAfterMs: number | null; /** undefined when the leg does not read the sink */ readonly sinkSeenAfterMs?: number | null }
121
+
122
+ /**
123
+ * The informational row detail: the path, the window, and what was measured.
124
+ * The last attempt is a LOWER bound on the dead-lettering time (the host cannot
125
+ * dead-letter before its last attempt); the sink observation is an UPPER bound
126
+ * (the read that first found the record). Never starts with the partial-witness
127
+ * prefix: this describes a witness, it does not qualify one.
128
+ */
129
+ /** The path and window alone, for a leg that measures no dead-lettering. */
130
+ export function waitPathNote(sel: RetryWaitSelection): string {
131
+ return `${pathLabel(sel)}: waited ≤${sel.windowMs}ms`;
132
+ }
133
+
134
+ function pathLabel(sel: RetryWaitSelection): string {
135
+ return sel.path === 'advertised-bound'
136
+ ? `advertised-bound (maxElapsedMs ${sel.maxElapsedMs})`
137
+ : sel.path === 'cap'
138
+ ? `cap (${sel.capMs > DEFAULT_RETRY_WAIT_CAP_MS ? `${RETRY_WAIT_ENV} raised to ${sel.capMs}` : `default ${sel.capMs}`}ms; advertised maxElapsedMs ${sel.maxElapsedMs ?? 'none'})`
139
+ : 'floor';
140
+ }
141
+
142
+ export function waitObservation(sel: RetryWaitSelection, m: RetryWaitMeasurement): string {
143
+ const how = pathLabel(sel);
144
+ const last = m.lastAttemptAfterMs === null ? 'no attempt' : `last attempt after ${m.lastAttemptAfterMs}ms`;
145
+ const sink = m.sinkSeenAfterMs === undefined ? '' : m.sinkSeenAfterMs === null ? ', sink not observed' : `, sink after ${m.sinkSeenAfterMs}ms`;
146
+ return `${how}: waited ≤${sel.windowMs}ms; ${m.attempts} attempt(s), ${last}${sink}`;
147
+ }
148
+
93
149
  /** What a row says when its window closed before the host's schedule did. Computed, so the numbers a host reads are the ones the run used. */
94
150
  export function windowClosedNote(seen: number, maxAttempts: number, waitedMs: number, capMs: number): string {
95
151
  const raised = capMs > DEFAULT_RETRY_WAIT_CAP_MS;
@@ -34,7 +34,9 @@
34
34
  *
35
35
  * ## Construction
36
36
  *
37
- * `conformance-replay-side-effect` is `core.delay` → a side-effecting node. We
37
+ * `conformance-replay-side-effect` is `core.conformance.hold` (a reserved PURE
38
+ * wait; it was `core.delay`, which the registry declares side-effecting,
39
+ * openwop#1769) → a side-effecting node. We
38
40
  * start it with a long delay, cancel mid-flight so the effect node never
39
41
  * records a terminal outcome, then fork `mode:"replay"`. Requirement 3 of
40
42
  * `replay.md` §"Side-effect suppression in replay" then applies to that node.
@@ -145,7 +147,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('replay-side-effect-suppression: a replay does
145
147
  // side-effecting node downstream never reaches a terminal outcome.
146
148
  //
147
149
  // Kept SHORT on purpose: a `replay` fork re-executes from seq 0, so it
148
- // re-runs this delay too (a delay is not side-effecting). A 30s delay
150
+ // re-runs this hold too (`core.conformance.hold` is pure). A 30s delay
149
151
  // here would make the fork outlive any sane poll budget — measured, not
150
152
  // theorised: the first version of this scenario used 30s and timed out
151
153
  // at step 3 with the fork still `running`.
@@ -182,7 +184,7 @@ describe.skipIf(SKIP_NO_FIXTURE)('replay-side-effect-suppression: a replay does
182
184
  }
183
185
  expect(fork.status, req('openwop.it.replay-side-effect-suppression.fails-a-side-effecting-node-closed-with-replay-source-missing-when-the-source-ne', 'replay.md §"Side-effect suppression in replay" requirement 1', 'fork should be accepted')).toBe(201);
184
186
  const forkRunId = (fork.json as { runId: string }).runId;
185
- await pollUntilTerminal(forkRunId, { timeoutMs: 45_000 }); // re-runs the 5s delay before reaching `effect`
187
+ await pollUntilTerminal(forkRunId, { timeoutMs: 45_000 }); // re-runs the pure hold before reaching `effect`
186
188
 
187
189
  // 3. THE assertion. A non-suppressing host executes the node; a suppressing
188
190
  // host cannot, and has no recorded outcome to serve, so it must fail
@@ -33,6 +33,21 @@
33
33
  * rejection is not replayed). `inapplicable` on a host that does not
34
34
  * advertise the fixture, i.e. one that does not run cycles.
35
35
  *
36
+ * 9. `openwop.requirement.0223.replay-derives-rejection` (openwop#1756, suite
37
+ * 2.44.4; gated on `replay` advertising mode `replay` and on each leg's
38
+ * fixture): a `replay` fork taken at the gate's recorded `interrupt.resolved` (the
39
+ * prefix holds its request and suspension) derives
40
+ * the rejection from the recorded `interrupt.resolved` and never
41
+ * re-decides it. The fork raises no new `interrupt.requested` for the
42
+ * gate (the discriminator: a re-deciding host re-raises and waits), records
43
+ * an `interrupt.resolved` whose `decision` / `action` / `reason` equal the
44
+ * source's, fails the same way (`approval_rejected`, `failedNodeId`, a
45
+ * non-retryable `node.failed`), and answers a resolve with `409
46
+ * interrupt_already_resolved`. Leg A replays a decided reject
47
+ * (`conformance-approval`); leg B replays a timeout
48
+ * (`conformance-approval-timeout-approve`), whose `resolvedBy` must match the source's (a host subject or absent).
49
+ * `interruptId` equality is not asserted: a host may re-mint ids.
50
+ *
36
51
  * The legs are separate rows so a host that misses one field fails that row
37
52
  * only. Legs 5 and 6 each need their own fixture, because `conformance-approval`
38
53
  * has no edges and no timer and widening a fixture every host serves would force
@@ -98,8 +113,8 @@ const errorCode = (o: unknown): string | undefined => (o as { code?: unknown } |
98
113
  const resolve = (runId: string, resumeValue: unknown): Promise<OpenWOPResponse | null> => http(() => driver.post(`/runs/${enc(runId)}/interrupts/${enc(NODE_ID)}`, { resumeValue }));
99
114
 
100
115
  /** Create a run of `fixture` and wait for it to suspend on its gate. */
101
- async function suspended(fixture: string): Promise<{ runId: string } | { reason: string }> {
102
- const res = await http(() => driver.post('/runs', { workflowId: fixture }));
116
+ async function suspended(fixture: string, extra: Record<string, unknown> = {}): Promise<{ runId: string } | { reason: string }> {
117
+ const res = await http(() => driver.post('/runs', { workflowId: fixture, ...extra }));
103
118
  if (res === null) return { reason: 'POST /runs unreachable (fetch failed)' };
104
119
  const runId = (res.json as { runId?: unknown } | null)?.runId;
105
120
  if (res.status !== 201 || typeof runId !== 'string') return { reason: `POST /runs (${fixture}) answered ${res.status} ${readErrorCode(res.json) ?? ''}`.trim() };
@@ -242,7 +257,8 @@ describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conf
242
257
  it('a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed', async () => {
243
258
  if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
244
259
  if (!isFixtureAdvertised(LOOPBACK)) return softSkip('inapplicable', `fixture ${LOOPBACK} is not advertised — the host does not run cycles`);
245
- const s = await suspended(LOOPBACK);
260
+ // The loop is bounded by the scenario (one reject), not by the host; recursionLimit is a runaway guard (openwop#1748).
261
+ const s = await suspended(LOOPBACK, { configurable: { version: 1, run: { recursionLimit: 20 } } });
246
262
  if ('reason' in s) return softSkip('blocked', s.reason);
247
263
  const res = await resolve(s.runId, { action: 'reject' });
248
264
  if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
@@ -259,4 +275,91 @@ describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conf
259
275
  expect(evs.filter((e) => e.type === 'interrupt.resolved').length, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'an earlier visit\'s resumeValue MUST NOT be returned: only the one caller resolve is recorded')).toBe(1);
260
276
  await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
261
277
  }, 45_000);
278
+
279
+ // ── openwop#1756: replay derives the rejection, never re-decides it ──────────
280
+ const REPLAY_ID = R('replay-derives-rejection');
281
+ const REPLAY_DOC = `${DOC}, last bullet; spec/v2/core/replay.md §Determinism`;
282
+ interface SeqEv extends Ev { readonly sequence?: number }
283
+ async function debugLog(runId: string): Promise<SeqEv[]> {
284
+ const res = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1&streamMode=debug`));
285
+ const evs = ((res?.json as { events?: SeqEv[] } | null)?.events ?? []).filter((e) => typeof e.sequence === 'number');
286
+ return evs.sort((a, b) => (a.sequence as number) - (b.sequence as number));
287
+ }
288
+ const onGate = (e: SeqEv): boolean => (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID;
289
+ async function replayGate(): Promise<string | null> {
290
+ const fam = await gateFamily('replay');
291
+ if (!fam) return 'replay family not advertised (gate recorded under openwop.family.replay)';
292
+ const modes = Array.isArray(fam['modes']) ? (fam['modes'] as unknown[]) : [];
293
+ return modes.includes('replay') ? null : `replay.modes ${JSON.stringify(modes)} does not include replay`;
294
+ }
295
+ /**
296
+ * Run → the source's own disposition → a `replay` fork at the gate's recorded
297
+ * `interrupt.resolved`: the prefix holds the gate's request and suspension, and the
298
+ * fork must supply the resolution. A deriving host takes the recorded one (replay.md
299
+ * §Determinism 2); a re-deciding host raises a NEW `interrupt.requested` and waits.
300
+ * Forking at the gate's `node.started` instead would let a host re-emit the recorded
301
+ * request as history, which cannot be told apart from a new one.
302
+ */
303
+ async function replayedRejection(fixture: string, decide: ((runId: string) => Promise<string | null>) | null, settleMs: number): Promise<{ source: SeqEv[]; fromSeq: number; forkId: string; forkStatus: string | null; fork: SeqEv[] } | { reason: string }> {
304
+ const s = await suspended(fixture);
305
+ if ('reason' in s) return s;
306
+ if (decide !== null) { const why = await decide(s.runId); if (why !== null) return { reason: why }; }
307
+ const terminal = await waitStatus(s.runId, TERMINAL, settleMs);
308
+ if (terminal?.['status'] !== 'failed') return { reason: `the source run did not end failed (status ${String(terminal?.['status'])}) — the reject rows own that contract` };
309
+ const source = await debugLog(s.runId);
310
+ const at = source.find((e) => e.type === 'interrupt.resolved' && onGate(e));
311
+ if (at?.sequence === undefined) return { reason: 'the source log has no interrupt.resolved for the gate to fork at' };
312
+ const fork = await http(() => driver.post(`/runs/${enc(s.runId)}:fork`, { mode: 'replay', fromSeq: at.sequence }));
313
+ const forkId = (fork?.json as { runId?: unknown } | null)?.runId;
314
+ if (fork === null || fork.status !== 201 || typeof forkId !== 'string') return { reason: `POST /runs/{runId}:fork answered ${fork?.status ?? 'nothing'} ${readErrorCode(fork?.json) ?? ''} — forkRun owns that contract`.trim() };
315
+ const settled = await waitStatus(forkId, new Set([...TERMINAL, 'waiting-approval']), scaledTimeoutMs(20_000));
316
+ return { source, fromSeq: at.sequence, forkId, forkStatus: settled === null ? null : String(settled['status']), fork: await debugLog(forkId) };
317
+ }
318
+ function assertDerived(r: { source: SeqEv[]; fromSeq: number; forkId: string; forkStatus: string | null; fork: SeqEv[] }, leg: string): void {
319
+ const reRaised = r.fork.filter((e) => (e.sequence as number) >= r.fromSeq && (e.type === 'interrupt.requested' || e.type === 'approval.requested') && onGate(e));
320
+ expect(reRaised.length, req(REPLAY_ID, REPLAY_DOC, `${leg}: the replay fork MUST NOT raise a new interrupt for the gate — the rejection is derived from the recorded interrupt.resolved, never re-decided (status ${String(r.forkStatus)})`)).toBe(0);
321
+ const pick = (e: SeqEv | undefined): Record<string, unknown> => ({ decision: e?.payload?.['decision'], action: e?.payload?.['action'], reason: e?.payload?.['reason'] });
322
+ const srcResolved = r.source.find((e) => e.type === 'interrupt.resolved' && onGate(e));
323
+ const forkResolved = r.fork.find((e) => e.type === 'interrupt.resolved' && onGate(e));
324
+ expect(pick(forkResolved), req(REPLAY_ID, REPLAY_DOC, `${leg}: the fork's interrupt.resolved for the gate MUST carry the source's decision, action and reason`)).toEqual(pick(srcResolved));
325
+ const runFailed = r.fork.find((e) => e.type === 'run.failed');
326
+ const nodeFailed = r.fork.find((e) => e.type === 'node.failed' && onGate(e));
327
+ expect(
328
+ { status: r.forkStatus, code: errorCode(runFailed?.payload?.['error']), failedNodeId: runFailed?.payload?.['failedNodeId'], retryable: (nodeFailed?.payload?.['error'] as { retryable?: unknown } | undefined)?.retryable },
329
+ req(REPLAY_ID, REPLAY_DOC, `${leg}: the fork MUST fail as the source did — failed, run.failed approval_rejected naming the gate, and a non-retryable node.failed`),
330
+ ).toEqual({ status: 'failed', code: CODE, failedNodeId: NODE_ID, retryable: false });
331
+ }
332
+
333
+ it('a replay fork of a decided reject derives the rejection from the log and never re-decides it (leg A)', async () => {
334
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
335
+ const noReplay = await replayGate();
336
+ if (noReplay !== null) return softSkip('inapplicable', noReplay);
337
+ if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `fixture ${FIXTURE} is not advertised`);
338
+ const r = await replayedRejection(FIXTURE, async (runId) => {
339
+ const res = await resolve(runId, { action: 'reject' });
340
+ return res === null || res.status < 200 || res.status >= 300 ? `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''}` : null;
341
+ }, 10_000);
342
+ if ('reason' in r) return softSkip('blocked', r.reason);
343
+ const again = await resolve(r.forkId, { action: 'accept' });
344
+ assertDerived(r, 'leg A (decided reject)');
345
+ expect({ status: again?.status, code: readErrorCode(again?.json) }, req(REPLAY_ID, 'spec/v2/core/errors.md §One code per state', 'a resolve against the replayed gate MUST answer 409 interrupt_already_resolved — the fork holds no open decision')).toEqual({ status: 409, code: 'interrupt_already_resolved' });
346
+ }, 90_000);
347
+
348
+ it('a replay fork of a timed-out gate derives the timeout rejection from the log and never re-arms it (leg B)', async () => {
349
+ if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
350
+ const noReplay = await replayGate();
351
+ if (noReplay !== null) return softSkip('inapplicable', noReplay);
352
+ if (!isFixtureAdvertised(TIMEOUT_APPROVE)) return softSkip('inapplicable', `fixture ${TIMEOUT_APPROVE} is not advertised`);
353
+ const r = await replayedRejection(TIMEOUT_APPROVE, null, scaledTimeoutMs(20_000));
354
+ if ('reason' in r) return softSkip('blocked', r.reason);
355
+ assertDerived(r, 'leg B (timeout)');
356
+ // resolvedBy on a timeout row is the host's subject or absent (RFC 0183 §Unresolved 1), so
357
+ // the leg asserts the fork carries the SOURCE's value, not that it is absent.
358
+ const forkResolved = r.fork.find((e) => e.type === 'interrupt.resolved' && onGate(e));
359
+ const srcResolved = r.source.find((e) => e.type === 'interrupt.resolved' && onGate(e));
360
+ expect(
361
+ { action: forkResolved?.payload?.['action'], reason: forkResolved?.payload?.['reason'], resolvedBy: JSON.stringify(forkResolved?.payload?.['resolvedBy'] ?? null) },
362
+ req(REPLAY_ID, REPLAY_DOC, 'leg B: the derived resolution is the recorded timeout — action timeout, reason timeout, and the source\'s resolvedBy (a host subject or absent), never a caller'),
363
+ ).toEqual({ action: 'timeout', reason: 'timeout', resolvedBy: JSON.stringify(srcResolved?.payload?.['resolvedBy'] ?? null) });
364
+ }, 120_000);
262
365
  });
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `spec/v2/core/replay.md` §Suppression rule 2 — a replay fork resolves a
3
- * side-effecting node's outcome keyed on `(sourceRunId, nodeId, n)`, where `n`
4
- * counts the node's `node.started` events through this execution, including
5
- * retries, later visits and the fork's inherited prefix (openwop#1718, suite
6
- * 2.43.2, target major 2).
3
+ * side-effecting node's outcome keyed on `(sourceRunId, nodeId, n)`, where `n` is
4
+ * one more than the node's recorded terminals (`node.completed`, `node.failed`)
5
+ * before this execution, the fork's inherited prefix included (openwop#1718,
6
+ * re-corrected by openwop#1769 from a count of `node.started`, which a pause or a
7
+ * suspend re-emits; suite 2.43.2, target major 2).
7
8
  *
8
9
  * The rule said `(nodeId, attempt)`. In a run where a node executes more than
9
10
  * once (a loop back over an edge), that key names no single execution: a host
@@ -11,8 +12,8 @@
11
12
  * outcome, and one selecting the latest recorded outcome does the same when
12
13
  * only one exists.
13
14
  *
14
- * Construction (`conformance-replay-ordinal-loop`): start → effect → wait →
15
- * effect, so `effect` executes twice. The suite cancels the source inside
15
+ * Construction (`conformance-replay-ordinal-loop`): start → effect → wait
16
+ * (`core.conformance.hold`, a reserved PURE node that re-executes live) → effect, so `effect` executes twice. The suite cancels the source inside
16
17
  * `wait`, after `effect`'s first execution completed and before its second, and
17
18
  * forks `mode: "replay"` at `effect`'s first `node.started`. In the fork:
18
19
  * - `effect` execution 1 (n = 1) has a recorded outcome and resolves from it;
@@ -74,7 +75,7 @@ describe('v2 replay-suppression-ordinal (replay.md §Suppression rule 2, openwop
74
75
  if (!(await gateFamily('replay'))) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
75
76
  if (!isFixtureAdvertised(FIXTURE)) return softSkip('inapplicable', `fixture ${FIXTURE} is not advertised — the host does not run cycles with a side-effecting node`);
76
77
 
77
- const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: { delayMs: 3000 } }));
78
+ const created = await http(() => driver.post('/runs', { workflowId: FIXTURE, inputs: { delayMs: 3000 }, configurable: { version: 1, run: { recursionLimit: 20 } } }));
78
79
  const runId = (created?.json as { runId?: unknown } | null)?.runId;
79
80
  if (created === null || created.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FIXTURE}) answered ${created?.status ?? 'nothing'} ${readErrorCode(created?.json) ?? ''}`.trim());
80
81
 
@@ -35,7 +35,8 @@ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedR
35
35
  import { readErrorCode } from '../lib/error-envelope.js';
36
36
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
37
37
  import { req } from '../lib/requirement-ids.js';
38
- import { pastAdvertisedBound, retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, windowClosedNote } from '../lib/webhook-retry-window.js';
38
+ import { pastAdvertisedBound, retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, retryWaitSelection, waitObservation, windowClosedNote } from '../lib/webhook-retry-window.js';
39
+ import { noteObservation } from '../lib/row-observation.js';
39
40
 
40
41
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
41
42
 
@@ -180,6 +181,21 @@ function retryWaitMs(doc: Record<string, unknown>): number {
180
181
  return retryWaitFor(advertisedRetryPolicy(doc), RETRY_WAIT_CAP_MS);
181
182
  }
182
183
 
184
+ /**
185
+ * Suite 2.44.3 (RFC 0225 witness): say, on the row, which wait path ran and what
186
+ * was measured. The first attempt is t0; the last attempt lower-bounds the
187
+ * dead-lettering, and the sink read that found the record upper-bounds it.
188
+ */
189
+ function observeWait(doc: Record<string, unknown>, attempts: ReadonlyArray<{ readonly at: number }>, sinkSeenAt?: number | null): void {
190
+ const t0 = attempts[0]?.at;
191
+ const last = attempts[attempts.length - 1]?.at;
192
+ noteObservation(waitObservation(retryWaitSelection(advertisedRetryPolicy(doc), RETRY_WAIT_CAP_MS), {
193
+ attempts: attempts.length,
194
+ lastAttemptAfterMs: t0 === undefined || last === undefined ? null : last - t0,
195
+ ...(sinkSeenAt === undefined ? {} : { sinkSeenAfterMs: t0 === undefined || sinkSeenAt === null ? null : sinkSeenAt - t0 }),
196
+ }));
197
+ }
198
+
183
199
  /**
184
200
  * The per-test budget, DERIVED from the wait above (suite 2.0.2).
185
201
  *
@@ -287,6 +303,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
287
303
  const ours = () => receiver.attempts.filter((a) => a.runId === runId && a.webhookId === sub.webhookId);
288
304
  const retried = await waitFor(() => ours().some((a) => a.status === 204), retryWaitMs(doc));
289
305
  const attempts = ours();
306
+ observeWait(doc, attempts);
290
307
  // A zero that is PROVABLY not a verdict about the host records `blocked`
291
308
  // with its cause, not `executed-fail` (2.37.0). `absenceIsUnmeasured` is
292
309
  // true only when other traffic reached this listener — the path from the
@@ -440,6 +457,7 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
440
457
  const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
441
458
  inSink = sink.status === 200 && ((sink.json as { deliveries?: unknown[] } | null)?.deliveries ?? []).some((r) => (r as Record<string, unknown>)['runId'] === runId);
442
459
  }
460
+ observeWait(doc, ours(), inSink === true ? Date.now() : null);
443
461
  await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
444
462
  // "Routed to the sink, not dropped" is a claim about an EXHAUSTED delivery,
445
463
  // and until 2.34.1 it was asserted whether or not exhaustion had been
@@ -589,9 +607,11 @@ describe('RFC 0173 §B — webhook-durable-delivery (gated on webhooks)', () =>
589
607
  await new Promise((r) => setTimeout(r, 1_000));
590
608
 
591
609
  const sink = await driver.get(`/webhooks/${projectBoundId(sub.webhookId)}/dead-letters`);
610
+ const sinkReadAt = Date.now();
592
611
  await driver.delete(`/webhooks/${encodeURIComponent(sub.webhookId)}`);
593
612
  if (sink.status !== 200) return blockedDespiteAssertions(`the dead-letter read answered ${sink.status}`);
594
613
  const rows = ((sink.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
614
+ observeWait(doc, ours(), rows.length > 0 ? sinkReadAt : null);
595
615
  // An empty sink is NOT a pass. Recording one as a pass is exactly the
596
616
  // vacuous witness this leg used to produce; say so instead.
597
617
  if (rows.length === 0) {
@@ -33,7 +33,8 @@ import { req } from '../lib/requirement-ids.js';
33
33
  import { readErrorCode } from '../lib/error-envelope.js';
34
34
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
35
35
  import { hitHeader, mintWhsec, startModalReceiver, STANDARD_WEBHOOKS_ID, type ModalHit } from '../lib/webhook-receiver.js';
36
- import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
36
+ import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, retryWaitSelection, waitPathNote } from '../lib/webhook-retry-window.js';
37
+ import { noteObservation } from '../lib/row-observation.js';
37
38
  import {
38
39
  STANDARD_WEBHOOKS_ALG,
39
40
  deliveriesFor,
@@ -89,7 +90,9 @@ describe('RFC 0201 §C.10 — webhook-id is stable across retries, distinct acro
89
90
  return m;
90
91
  };
91
92
  const expectedKeys = subs.length * EVENTS.length;
92
- const window = retryWaitFor((g.facet['retryPolicy'] as { backoff?: string; maxElapsedMs?: unknown } | undefined) ?? null, CAP_MS);
93
+ const advertised = (g.facet['retryPolicy'] as { backoff?: string; maxElapsedMs?: unknown } | undefined) ?? null;
94
+ const window = retryWaitFor(advertised, CAP_MS);
95
+ noteObservation(waitPathNote(retryWaitSelection(advertised, CAP_MS)));
93
96
  await waitFor(() => {
94
97
  const m = byKey();
95
98
  return m.size >= expectedKeys && [...m.values()].every((a) => a.length >= 2);
@@ -45,7 +45,8 @@ import { absenceIsUnmeasured, noDeliveryCause, startScopedReceiver, type ScopedR
45
45
  import { readErrorCode } from '../lib/error-envelope.js';
46
46
  import { blockedDespiteAssertions, softSkip } from '../lib/soft-skip.js';
47
47
  import { req } from '../lib/requirement-ids.js';
48
- import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor } from '../lib/webhook-retry-window.js';
48
+ import { retryTestTimeoutMs, retryWaitCapMs, retryWaitFor, retryWaitSelection, waitPathNote } from '../lib/webhook-retry-window.js';
49
+ import { noteObservation } from '../lib/row-observation.js';
49
50
 
50
51
  export const REQUIRES_HOST_CALLBACK = 'the host POSTs and retries webhook deliveries to the suite-owned scoped receiver behind OPENWOP_WEBHOOK_RECEIVER_URL';
51
52
 
@@ -94,6 +95,7 @@ describe('RFC 0215 §B — unregistering stops the attempts (gated on webhooks)'
94
95
  const policy = advertisedRetryPolicy(doc);
95
96
  if (policy?.maxAttempts === 1) return softSkip('inapplicable', 'webhooks.retryPolicy.maxAttempts is 1 — the host schedules no retry, so there is none for unregistering to stop');
96
97
  const windowMs = retryWaitFor(policy, CAP_MS);
98
+ noteObservation(waitPathNote(retryWaitSelection(policy, CAP_MS)));
97
99
 
98
100
  const arrivals = new Map<string, number[]>();
99
101
  let targetId: string | null = null;
@@ -453,8 +453,18 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
453
453
  activeServer = receiver;
454
454
 
455
455
  const ownedTenant = await discoverOwnedTenant(driver);
456
+ // The LOOPBACK address, over https so the refusal can only come from the
457
+ // loopback deny list and not from a scheme check. `receiver.url` is the
458
+ // wrong value here: since 2.37.0 `startScopedReceiver` returns the PUBLIC
459
+ // FRONT as `url` whenever OPENWOP_WEBHOOK_RECEIVER_URL is set (the tunnel,
460
+ // plus this exercise's nonce). So this control registered the tunnel, a
461
+ // host with a working guard correctly accepted it, and the control failed
462
+ // on a conformant host. Measured on openwop-app production 2026-09-28: the
463
+ // same host answers `https://127.0.0.1:…` and `https://localhost:…` with
464
+ // 400 webhook_url_rejected ("denied (loopback / link-local / private-IP)").
465
+ const loopback = receiver.localUrl.replace(/^http:/, 'https:');
456
466
  const reg = await driver.post('/v1/webhooks', {
457
- url: receiver.url, // deliberately the raw loopback URL, NOT the tunnel
467
+ url: loopback, // deliberately the raw loopback listener, NOT the tunnel
458
468
  events: ['run.completed'],
459
469
  ...(ownedTenant ? { tenantId: ownedTenant } : {}),
460
470
  });
package/src/setup.ts CHANGED
@@ -38,6 +38,7 @@ import { PKG_ROOT_PATH } from './lib/paths.js';
38
38
  import { recordRequirement, hasRequirement, journalLength, journalSince } from './lib/requirement-ledger.js';
39
39
  import { requirementIdForFile, resolveFileRecord, resolveItRecord, unrecordedTests, type FileTestState, type FinishedTest, type TestFailure } from './lib/scenario-disposition.js';
40
40
  import { softSkipDisposition, softSkipDispositionSince, softSkipMark } from './lib/soft-skip.js';
41
+ import { attachObservation, takeNotedObservation } from './lib/row-observation.js';
41
42
  import { ItIdAllocator, takeExplicitRequirementId } from './lib/requirement-ids.js';
42
43
  import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-coherence.js';
43
44
  import type { DiscoveryPayload } from './lib/profiles.js';
@@ -472,6 +473,7 @@ afterEach(({ task }) => {
472
473
  // Suite 2.0.0: src/coherence/ rows feed the corpus ledger (scripts/check-spec-coherence.mjs), not a host bundle.
473
474
  if (!/[\\/]src[\\/](scenarios|coherence)[\\/]/.test(filepath)) {
474
475
  takeExplicitRequirementId();
476
+ takeNotedObservation();
475
477
  return;
476
478
  }
477
479
  // A leg that soft-skips before its first assertion never calls `req()`, so the
@@ -506,6 +508,11 @@ afterEach(({ task }) => {
506
508
  disposition = rec.disposition;
507
509
  detail = rec.detail;
508
510
  }
511
+ // Suite 2.44.3 (RFC 0225 witness): an informational note the `it` wrote
512
+ // (`noteObservation`) rides on the row's detail, on a pass too — the bundle
513
+ // writer carries it and the witness digest signs it. Taken whatever the
514
+ // disposition, so a note never leaks into the next `it`.
515
+ detail = attachObservation(detail, takeNotedObservation());
509
516
  try {
510
517
  const evidence = takeNotedEvidence();
511
518
  // `fold: true` — several `it` legs of one file legitimately share ONE