From 57259205eec4e420dca65e87c8fb3c9c92268065 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:04:48 +0900 Subject: [PATCH 01/11] test: reject non-json current workflow media type --- ...ral-current-lifecycle-response-bounds.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index 7dd122c3d..d2b162402 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -167,6 +167,22 @@ describe("procedural current-lifecycle response bounds", () => { await expectInvalid(new Response(null, { status: 200 })); }); + it("rejects parseable JSON when the durable owner does not identify it as JSON", async () => { + await expectInvalid(new Response(JSON.stringify({ + ok: true, + data: { + executionId, + planId: "plan-current-lifecycle-response-bound", + transitionSequence: 1, + cancellation: { requested: false, cancellationId: null }, + tasks: [{ taskId: "review", state: "pending" }], + }, + }), { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + })); + }); + it("fails closed on a malformed non-byte response chunk", async () => { const stream = new ReadableStream({ start(controller) { From fa095b8adb7853ee44626fa93a16f5600e41d953 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:05:55 +0900 Subject: [PATCH 02/11] fix: admit only JSON current workflow evidence --- src/agent-runtime/procedural-current-lifecycle.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/agent-runtime/procedural-current-lifecycle.ts b/src/agent-runtime/procedural-current-lifecycle.ts index 4c98ad18f..a3426f536 100644 --- a/src/agent-runtime/procedural-current-lifecycle.ts +++ b/src/agent-runtime/procedural-current-lifecycle.ts @@ -70,6 +70,10 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +function isJsonMediaType(value: string | null): boolean { + return /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu.test(value ?? ""); +} + function currentTaskState(value: unknown): CurrentWorkflowTaskState { if (typeof value !== "string" || !CURRENT_WORKFLOW_TASK_STATES.has(value as CurrentWorkflowTaskState)) { return rejectCurrentLifecycle("invalid_workflow_state_response"); @@ -218,6 +222,9 @@ async function readCurrentWorkflowEvidence( if (response.status === 409) return rejectCurrentLifecycle("workflow_state_conflict"); if (response.status === 503) return rejectCurrentLifecycle("workflow_state_unavailable"); if (response.status !== 200) return rejectCurrentLifecycle("invalid_workflow_state_response"); + if (!isJsonMediaType(response.headers.get("content-type"))) { + return rejectCurrentLifecycle("invalid_workflow_state_response"); + } const body = await boundedCurrentWorkflowResponse(response); if (!isRecord(body) || body.ok !== true) { From 9fbdb94093f2883fe29d6af7afc7e7e109b9a943 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:06:12 +0900 Subject: [PATCH 03/11] test: preserve deeper current workflow response branches --- ...ral-current-lifecycle-response-bounds.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index d2b162402..0dbb93ddd 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -8,6 +8,7 @@ import type { WorkflowStateDurableObjectEnv } from "../src/workflow-task-executi import type { WorkflowTaskPlan } from "../src/workflow-task-execution/task-plan"; const executionId = "run-current-lifecycle-response-bound"; +const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; function plan(): WorkflowTaskPlan { return { @@ -94,7 +95,7 @@ describe("procedural current-lifecycle response bounds", () => { await expectInvalid(new Response(stream, { status: 200, - headers: { "content-type": "application/json; charset=utf-8" }, + headers: JSON_HEADERS, })); expect(cancelled).toBe(true); @@ -123,7 +124,7 @@ describe("procedural current-lifecycle response bounds", () => { const outcome = guideProceduralExecutionFromCurrentWorkflowState( envFor(new Response(stream, { status: 200, - headers: { "content-type": "application/json; charset=utf-8" }, + headers: JSON_HEADERS, })), plan(), await session(), @@ -154,7 +155,10 @@ describe("procedural current-lifecycle response bounds", () => { }); it("normalizes a locked durable-owner response body to the stable fail-closed diagnostic", async () => { - const response = new Response(new ReadableStream(), { status: 200 }); + const response = new Response(new ReadableStream(), { + status: 200, + headers: JSON_HEADERS, + }); const heldReader = response.body!.getReader(); try { await expectInvalid(response); @@ -164,7 +168,7 @@ describe("procedural current-lifecycle response bounds", () => { }); it("rejects a successful status with no response body", async () => { - await expectInvalid(new Response(null, { status: 200 })); + await expectInvalid(new Response(null, { status: 200, headers: JSON_HEADERS })); }); it("rejects parseable JSON when the durable owner does not identify it as JSON", async () => { @@ -190,7 +194,7 @@ describe("procedural current-lifecycle response bounds", () => { controller.close(); }, }); - await expectInvalid(new Response(stream, { status: 200 })); + await expectInvalid(new Response(stream, { status: 200, headers: JSON_HEADERS })); }); it("normalizes a body-stream read failure to the stable fail-closed diagnostic", async () => { @@ -199,6 +203,6 @@ describe("procedural current-lifecycle response bounds", () => { controller.error(new Error("durable stream failed")); }, }); - await expectInvalid(new Response(stream, { status: 200 })); + await expectInvalid(new Response(stream, { status: 200, headers: JSON_HEADERS })); }); }); From e21844225359c9cf142551baec4275a5703d2ba6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:13:17 +0900 Subject: [PATCH 04/11] docs: explain current workflow media-type admission --- src/agent-runtime/procedural-current-lifecycle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/agent-runtime/procedural-current-lifecycle.ts b/src/agent-runtime/procedural-current-lifecycle.ts index a3426f536..15d0ea3e5 100644 --- a/src/agent-runtime/procedural-current-lifecycle.ts +++ b/src/agent-runtime/procedural-current-lifecycle.ts @@ -70,6 +70,7 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } +/** Accept only the exact JSON media-type forms emitted by the private workflow-state owner. */ function isJsonMediaType(value: string | null): boolean { return /^[ \t]*application\/json[ \t]*(?:;[ \t]*charset[ \t]*=[ \t]*utf-8[ \t]*)?$/iu.test(value ?? ""); } From 08dca285688258dbb27f0e5fd82f67909f337aa3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:15:07 +0900 Subject: [PATCH 05/11] docs: explain current workflow response fixtures --- test/procedural-current-lifecycle-response-bounds.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index 0dbb93ddd..d1a703dbc 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -10,6 +10,7 @@ import type { WorkflowTaskPlan } from "../src/workflow-task-execution/task-plan" const executionId = "run-current-lifecycle-response-bound"; const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; +/** Keeps scheduling trivial so failures stay attributable to current-response admission. */ function plan(): WorkflowTaskPlan { return { executionId, @@ -19,6 +20,7 @@ function plan(): WorkflowTaskPlan { }; } +/** Builds the matching Agent Runtime session required to exercise the real procedural ACL. */ async function session() { const graph = await createProceduralGraph({ schemaVersion: "noema.procedural-graph/v1", @@ -45,6 +47,7 @@ async function session() { }); } +/** Routes the supplied hostile response through the normal execution-scoped Durable Object port. */ function envFor(response: Response): WorkflowStateDurableObjectEnv { const namespace = { idFromName(name: string): DurableObjectId { @@ -57,6 +60,7 @@ function envFor(response: Response): WorkflowStateDurableObjectEnv { return { NOEMA_WORKFLOW_STATE: namespace as unknown as DurableObjectNamespace }; } +/** Requires every hostile transport fixture to collapse to the stable fail-closed diagnostic. */ async function expectInvalid(response: Response): Promise { await expect(guideProceduralExecutionFromCurrentWorkflowState( envFor(response), From b1740857908274e98cf55837fe380f291b5e97d7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:17:06 +0900 Subject: [PATCH 06/11] fix: cancel rejected current workflow response bodies --- .../procedural-current-lifecycle.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/agent-runtime/procedural-current-lifecycle.ts b/src/agent-runtime/procedural-current-lifecycle.ts index 15d0ea3e5..e22767e72 100644 --- a/src/agent-runtime/procedural-current-lifecycle.ts +++ b/src/agent-runtime/procedural-current-lifecycle.ts @@ -128,6 +128,19 @@ function currentWorkflowEvidence( }); } +/** Requests response-body cleanup without allowing cleanup completion to become decision authority. */ +function cancelCurrentWorkflowResponseBodyBestEffort( + body: ReadableStream | null, + reason: string, +): void { + if (body === null) return; + try { + void body.cancel(reason).catch(() => undefined); + } catch { + // A locked or hostile body must not replace the stable fail-closed admission result. + } +} + function cancelCurrentWorkflowReaderBestEffort( reader: ReadableStreamDefaultReader, reason: string, @@ -224,6 +237,10 @@ async function readCurrentWorkflowEvidence( if (response.status === 503) return rejectCurrentLifecycle("workflow_state_unavailable"); if (response.status !== 200) return rejectCurrentLifecycle("invalid_workflow_state_response"); if (!isJsonMediaType(response.headers.get("content-type"))) { + cancelCurrentWorkflowResponseBodyBestEffort( + response.body, + "Noema current workflow-state response used an unsupported media type", + ); return rejectCurrentLifecycle("invalid_workflow_state_response"); } From 1a7ae3a5487b591eaecbe1b23e07becc6d9721f6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:17:34 +0900 Subject: [PATCH 07/11] test: bound cleanup for rejected workflow media types --- ...-current-lifecycle-response-bounds.test.ts | 54 ++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index d1a703dbc..bc7a9e991 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -175,8 +175,16 @@ describe("procedural current-lifecycle response bounds", () => { await expectInvalid(new Response(null, { status: 200, headers: JSON_HEADERS })); }); - it("rejects parseable JSON when the durable owner does not identify it as JSON", async () => { - await expectInvalid(new Response(JSON.stringify({ + it("cancels parseable JSON identified as non-JSON without waiting for cleanup", async () => { + let markCancelStarted!: () => void; + const cancelStarted = new Promise((resolve) => { + markCancelStarted = resolve; + }); + let releaseCancellation!: () => void; + const cancellationBarrier = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const payload = JSON.stringify({ ok: true, data: { executionId, @@ -185,10 +193,44 @@ describe("procedural current-lifecycle response bounds", () => { cancellation: { requested: false, cancellationId: null }, tasks: [{ taskId: "review", state: "pending" }], }, - }), { - status: 200, - headers: { "content-type": "text/plain; charset=utf-8" }, - })); + }); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(payload)); + }, + cancel() { + markCancelStarted(); + return cancellationBarrier; + }, + }, { highWaterMark: 0 }); + const outcome = guideProceduralExecutionFromCurrentWorkflowState( + envFor(new Response(stream, { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + })), + plan(), + await session(), + { lastProcedure: null, hops: 1, maxEdges: 4 }, + ).then( + () => new Error("expected the non-JSON response to fail closed"), + (error: unknown) => error, + ); + + await cancelStarted; + let settled = false; + void outcome.then(() => { + settled = true; + }); + for (let index = 0; index < 5; index += 1) await Promise.resolve(); + const settledBeforeCleanup = settled; + + releaseCancellation(); + const error = await outcome; + expect(error).toMatchObject({ + name: "ProceduralCurrentLifecycleError", + code: "invalid_workflow_state_response", + }); + expect(settledBeforeCleanup).toBe(true); }); it("fails closed on a malformed non-byte response chunk", async () => { From ada01edd224c41badac60a7608ff15ad25c84222 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:19:32 +0900 Subject: [PATCH 08/11] test: preserve workflow evidence validation after media admission --- test/procedural-current-lifecycle-fail-closed.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/procedural-current-lifecycle-fail-closed.test.ts b/test/procedural-current-lifecycle-fail-closed.test.ts index 219395b66..82491ba35 100644 --- a/test/procedural-current-lifecycle-fail-closed.test.ts +++ b/test/procedural-current-lifecycle-fail-closed.test.ts @@ -13,6 +13,7 @@ type TaskState = "pending" | "running" | "succeeded" | "failed" | "cancelled" | type ResponseStep = Response | Error; const executionId = "run-current-lifecycle-fail-closed"; +const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; function plan(): WorkflowTaskPlan { return { @@ -50,7 +51,7 @@ function snapshot(states: readonly TaskState[], transitionSequence = 2): Record< } function ok(data: unknown): Response { - return new Response(JSON.stringify({ ok: true, data }), { status: 200 }); + return new Response(JSON.stringify({ ok: true, data }), { status: 200, headers: JSON_HEADERS }); } class WorkflowNamespace { @@ -136,10 +137,13 @@ describe("procedural current lifecycle fail-closed evidence validation", () => { }); it("rejects malformed success envelopes before they can become lifecycle evidence", async () => { - await expectFailure(new Response("{", { status: 200 }), "invalid_workflow_state_response"); + await expectFailure(new Response("{", { status: 200, headers: JSON_HEADERS }), "invalid_workflow_state_response"); await expectFailure(ok(null), "invalid_workflow_state_response"); await expectFailure( - new Response(JSON.stringify({ ok: false, data: snapshot(["running", "pending"]) }), { status: 200 }), + new Response( + JSON.stringify({ ok: false, data: snapshot(["running", "pending"]) }), + { status: 200, headers: JSON_HEADERS }, + ), "invalid_workflow_state_response", ); }); From 13203351fac18324aace66f37a1b1b55577e602b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:19:53 +0900 Subject: [PATCH 09/11] test: preserve current workflow deadline branch after media admission --- test/procedural-current-lifecycle-read-deadline.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/procedural-current-lifecycle-read-deadline.test.ts b/test/procedural-current-lifecycle-read-deadline.test.ts index 68c849603..e56f154d5 100644 --- a/test/procedural-current-lifecycle-read-deadline.test.ts +++ b/test/procedural-current-lifecycle-read-deadline.test.ts @@ -82,6 +82,7 @@ describe("procedural current-lifecycle read deadline", () => { }; const response = { status: 200, + headers: new Headers({ "content-type": "application/json; charset=utf-8" }), body: { getReader: () => reader }, } as unknown as Response; From da169126ae35c209ba52e614c63faedf51c3b9bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:31:46 +0900 Subject: [PATCH 10/11] test: cover media rejection cleanup edge cases --- ...l-current-lifecycle-response-bounds.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index bc7a9e991..a2bff506d 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -175,6 +175,24 @@ describe("procedural current-lifecycle response bounds", () => { await expectInvalid(new Response(null, { status: 200, headers: JSON_HEADERS })); }); + it("rejects a successful response without a declared JSON media type when no body exists", async () => { + await expectInvalid(new Response(null, { status: 200 })); + }); + + it("preserves fail-closed admission when non-JSON body cancellation throws synchronously", async () => { + const response = { + status: 200, + headers: new Headers({ "content-type": "text/plain; charset=utf-8" }), + body: { + cancel() { + throw new Error("cleanup transport failed synchronously"); + }, + }, + } as unknown as Response; + + await expectInvalid(response); + }); + it("cancels parseable JSON identified as non-JSON without waiting for cleanup", async () => { let markCancelStarted!: () => void; const cancelStarted = new Promise((resolve) => { From 42b15e865bdf88fde622c3bba2c0b123770d18be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 13 Sep 2026 14:32:34 +0900 Subject: [PATCH 11/11] test: cover rejected media cleanup promise --- ...l-current-lifecycle-response-bounds.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/test/procedural-current-lifecycle-response-bounds.test.ts b/test/procedural-current-lifecycle-response-bounds.test.ts index a2bff506d..d95f20fa8 100644 --- a/test/procedural-current-lifecycle-response-bounds.test.ts +++ b/test/procedural-current-lifecycle-response-bounds.test.ts @@ -193,6 +193,24 @@ describe("procedural current-lifecycle response bounds", () => { await expectInvalid(response); }); + it("preserves fail-closed admission when non-JSON body cancellation rejects asynchronously", async () => { + let cancelRequested = false; + const stream = new ReadableStream({ + cancel() { + cancelRequested = true; + return Promise.reject(new Error("cleanup transport rejected")); + }, + }); + + await expectInvalid(new Response(stream, { + status: 200, + headers: { "content-type": "text/plain; charset=utf-8" }, + })); + await Promise.resolve(); + + expect(cancelRequested).toBe(true); + }); + it("cancels parseable JSON identified as non-JSON without waiting for cleanup", async () => { let markCancelStarted!: () => void; const cancelStarted = new Promise((resolve) => {